1package config
2
3import (
4 "os"
5 "path/filepath"
6 "testing"
7)
8
9func TestLoad(t *testing.T) {
10 tests := []struct {
11 name string
12 yaml string
13 wantErr bool
14 check func(t *testing.T, cfg *Config)
15 }{
16 {
17 name: "valid config",
18 yaml: `version: 1
19components:
20 core:
21 paths: [internal/domain/**]
22 role: core
23 adapters:
24 paths: [internal/infrastructure/**]
25 role: adapter
26rules:
27 no-adapter-imports-in-core: error
28allow:
29 - rule: no-local-fakes-for-ports
30 path: internal/foo_test.go
31 reason: stateful fake
32`,
33 check: func(t *testing.T, cfg *Config) {
34 t.Helper()
35 if cfg.Version != 1 {
36 t.Fatalf("Version = %d", cfg.Version)
37 }
38 if cfg.Components["core"].Role != RoleCore {
39 t.Fatalf("core role = %q", cfg.Components["core"].Role)
40 }
41 if cfg.Severity("no-adapter-imports-in-core") != SeverityError {
42 t.Fatalf("severity not parsed")
43 }
44 if !cfg.IsAllowed("no-local-fakes-for-ports", "internal/foo_test.go") {
45 t.Fatalf("allow entry not matched")
46 }
47 },
48 },
49 {
50 name: "unknown top-level key",
51 yaml: "version: 1\ncomponent: {}\n",
52 wantErr: true,
53 },
54 {
55 name: "unknown nested key",
56 yaml: "version: 1\nheuristics:\n businessLogicTreshold: 3\n",
57 wantErr: true,
58 },
59 {
60 name: "unknown rule name",
61 yaml: "version: 1\nrules:\n no-adaptr-imports-in-core: error\n",
62 wantErr: true,
63 },
64 {
65 name: "unknown rule in ruleSettings",
66 yaml: "version: 1\nruleSettings:\n typo-rule:\n excludePaths: [cmd/**]\n",
67 wantErr: true,
68 },
69 {
70 name: "unknown rule in allow",
71 yaml: "version: 1\nallow:\n - rule: typo-rule\n path: cmd/**\n reason: x\n",
72 wantErr: true,
73 },
74 {
75 name: "empty file uses defaults",
76 yaml: "",
77 check: func(t *testing.T, cfg *Config) {
78 t.Helper()
79 if cfg.Severity("no-infra-imports-in-ports") != SeverityError {
80 t.Fatalf("default severity not applied")
81 }
82 },
83 },
84 {
85 name: "invalid role",
86 yaml: `version: 1
87components:
88 bad:
89 paths: [internal/**]
90 role: wat
91`,
92 wantErr: true,
93 },
94 }
95
96 for _, tt := range tests {
97 t.Run(tt.name, func(t *testing.T) {
98 dir := t.TempDir()
99 path := filepath.Join(dir, ".hexcheck.yaml")
100 if err := os.WriteFile(path, []byte(tt.yaml), 0o600); err != nil {
101 t.Fatal(err)
102 }
103 cfg, err := Load(path)
104 if tt.wantErr {
105 if err == nil {
106 t.Fatal("expected error")
107 }
108 return
109 }
110 if err != nil {
111 t.Fatal(err)
112 }
113 if tt.check != nil {
114 tt.check(t, cfg)
115 }
116 })
117 }
118}
119
120func TestLoadRejectsExplicitZeroVersion(t *testing.T) {
121 tests := []struct {
122 name string
123 yaml string
124 }{
125 {name: "zero", yaml: "version: 0\n"},
126 {name: "positive zero", yaml: "version: +0\n"},
127 {name: "double zero", yaml: "version: 00\n"},
128 }
129 for _, tt := range tests {
130 t.Run(tt.name, func(t *testing.T) {
131 dir := t.TempDir()
132 path := filepath.Join(dir, ".hexcheck.yaml")
133 data := []byte(tt.yaml + `components:
134 adapters:
135 paths: [internal/infrastructure/**]
136 role: adapter
137`)
138 if err := os.WriteFile(path, data, 0o600); err != nil {
139 t.Fatal(err)
140 }
141 if _, err := Load(path); err == nil {
142 t.Fatal("expected explicit zero version to be rejected")
143 }
144 })
145 }
146}
147
148func TestLoadHonorsExplicitExcludeTestFilesFalse(t *testing.T) {
149 dir := t.TempDir()
150 path := filepath.Join(dir, ".hexcheck.yaml")
151 data := []byte(`version: 1
152components:
153 adapters:
154 paths: [internal/infrastructure/**]
155 role: adapter
156heuristics:
157 excludeTestFiles: false
158`)
159 if err := os.WriteFile(path, data, 0o600); err != nil {
160 t.Fatal(err)
161 }
162 cfg, err := Load(path)
163 if err != nil {
164 t.Fatal(err)
165 }
166 if cfg.Heuristics.ExcludeTestFiles == nil || *cfg.Heuristics.ExcludeTestFiles {
167 t.Fatal("excludeTestFiles=false was not preserved")
168 }
169 if !filepath.IsAbs(cfg.Root) {
170 t.Fatalf("Root = %q, want absolute path", cfg.Root)
171 }
172}
173
174func TestValidateRejectsInvalidHeuristicKnobs(t *testing.T) {
175 tests := []struct {
176 name string
177 mutate func(*Config)
178 }{
179 {name: "negative threshold", mutate: func(c *Config) { c.Heuristics.BusinessLogicThreshold = intPtr(-1) }},
180 {name: "negative strong", mutate: func(c *Config) { c.Heuristics.BusinessLogicMinStrongSignals = intPtr(-1) }},
181 {name: "negative weak", mutate: func(c *Config) { c.Heuristics.BusinessLogicMinWeakSignals = intPtr(-1) }},
182 {name: "zero max nodes", mutate: func(c *Config) { c.Heuristics.BusinessLogicMaxFunctionNodes = intPtr(0) }},
183 {name: "zero max diagnostics", mutate: func(c *Config) { c.Heuristics.BusinessLogicMaxDiagnosticsPerPackage = intPtr(0) }},
184 {name: "invalid business logic mode", mutate: func(c *Config) { c.Heuristics.BusinessLogicMode = "loud" }},
185 {name: "invalid business logic min confidence", mutate: func(c *Config) { c.Heuristics.BusinessLogicMinConfidence = "sure" }},
186 }
187 for _, tt := range tests {
188 t.Run(tt.name, func(t *testing.T) {
189 cfg := &Config{Version: 1, Components: map[string]Component{"adapters": {Role: RoleAdapter, Paths: []string{"internal/**"}}}}
190 cfg.applyDefaults()
191 tt.mutate(cfg)
192 if err := cfg.Validate(); err == nil {
193 t.Fatal("expected validation error")
194 }
195 })
196 }
197}
198
199func TestComponentForPathPrecedence(t *testing.T) {
200 cfg := &Config{Components: map[string]Component{
201 "all": {Role: RoleCore, Paths: []string{"internal/**"}},
202 "adapter": {Role: RoleAdapter, Paths: []string{"internal/infrastructure/**"}},
203 "ignore": {Role: RoleIgnore, Paths: []string{"internal/infrastructure/generated/**"}},
204 }}
205 cfg.applyDefaults()
206
207 tests := []struct {
208 name string
209 path string
210 want Role
211 }{
212 {name: "specific adapter beats broad core", path: "internal/infrastructure/postgres/repo.go", want: RoleAdapter},
213 {name: "ignore beats adapter", path: "internal/infrastructure/generated/foo.go", want: RoleIgnore},
214 }
215 for _, tt := range tests {
216 t.Run(tt.name, func(t *testing.T) {
217 match, ok := cfg.ComponentForPath(tt.path)
218 if !ok || match.Role != tt.want {
219 t.Fatalf("match = %#v, %v; want role %q", match, ok, tt.want)
220 }
221 })
222 }
223}