1use crate::domain::{Divergence, RuleId, Severity};
12use crate::error::SpecDriftError;
13use globset::{Glob, GlobMatcher, GlobSet, GlobSetBuilder};
14use serde::Deserialize;
15use std::collections::HashMap;
16use std::path::{Path, PathBuf};
17
18#[derive(Debug, Clone)]
20pub struct ConstraintRule {
21 pub name: String,
22 pub glob: GlobMatcher,
23 pub return_type: Option<String>,
24}
25
26#[derive(Debug, Default, Clone)]
28pub struct Config {
29 pub severities: HashMap<RuleId, Severity>,
30 pub ignored_rules: Vec<RuleId>,
31 pub ignored_paths: GlobSet,
32 pub ignored_symbols: GlobSet,
33 pub constraint_rules: Vec<ConstraintRule>,
34 pub llm: LlmConfig,
35}
36
37#[derive(Debug, Clone)]
39pub struct LlmConfig {
40 pub enabled: bool,
41 pub provider: LlmProvider,
42 pub model: String,
43 pub max_calls: u32,
44 pub timeout_s: u32,
45}
46
47impl Default for LlmConfig {
48 fn default() -> Self {
49 Self {
50 enabled: false,
51 provider: LlmProvider::Anthropic,
52 model: "claude-sonnet-4-6".to_string(),
53 max_calls: 50,
54 timeout_s: 30,
55 }
56 }
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum LlmProvider {
61 Anthropic,
62 OpenAi,
63 Local,
64}
65
66impl Config {
67 pub fn load(path: &Path) -> Result<Self, SpecDriftError> {
70 if !path.exists() {
71 return Ok(Self::default());
72 }
73 let raw = std::fs::read_to_string(path).map_err(|e| SpecDriftError::Io {
74 path: path.to_path_buf(),
75 source: e,
76 })?;
77 let parsed: ConfigFile = toml::from_str(&raw).map_err(|e| SpecDriftError::Config {
78 path: path.to_path_buf(),
79 message: e.to_string(),
80 })?;
81 parsed.compile(path)
82 }
83
84 pub fn discover(start: &Path) -> Option<PathBuf> {
87 let mut dir = Some(start);
88 while let Some(d) = dir {
89 let candidate = d.join("spec-drift.toml");
90 if candidate.exists() {
91 return Some(candidate);
92 }
93 dir = d.parent();
94 }
95 None
96 }
97
98 pub fn is_suppressed(&self, d: &Divergence, root: &Path) -> bool {
100 if self.ignored_rules.contains(&d.rule) {
101 return true;
102 }
103 let rel = d
104 .location
105 .file
106 .strip_prefix(root)
107 .unwrap_or(&d.location.file);
108 if self.ignored_paths.is_match(rel) {
109 return true;
110 }
111 if let Some(sym) = extract_backticked_symbol(&d.stated)
114 && self.ignored_symbols.is_match(sym)
115 {
116 return true;
117 }
118 false
119 }
120
121 pub fn apply_severity_overrides(&self, divs: &mut [Divergence]) {
123 if self.severities.is_empty() {
124 return;
125 }
126 for d in divs.iter_mut() {
127 if let Some(s) = self.severities.get(&d.rule) {
128 d.severity = *s;
129 }
130 }
131 }
132}
133
134fn extract_backticked_symbol(text: &str) -> Option<&str> {
135 let start = text.find('`')?;
136 let rest = &text[start + 1..];
137 let end = rest.find('`')?;
138 let inner = &rest[..end];
139 let inner = inner.strip_suffix("()").unwrap_or(inner);
140 Some(inner)
141}
142
143#[derive(Debug, Default, Deserialize)]
148#[serde(deny_unknown_fields)]
149struct ConfigFile {
150 #[serde(default)]
151 severity: HashMap<String, String>,
152 #[serde(default)]
153 ignore: IgnoreBlock,
154 #[serde(default)]
155 rules: RulesBlock,
156 #[serde(default)]
157 llm: Option<RawLlmConfig>,
158}
159
160#[derive(Debug, Deserialize)]
161#[serde(deny_unknown_fields)]
162struct RawLlmConfig {
163 #[serde(default)]
164 enabled: bool,
165 #[serde(default)]
166 provider: Option<String>,
167 #[serde(default)]
168 model: Option<String>,
169 #[serde(default)]
170 max_calls: Option<u32>,
171 #[serde(default)]
172 timeout_s: Option<u32>,
173}
174
175#[derive(Debug, Default, Deserialize)]
176#[serde(deny_unknown_fields)]
177struct IgnoreBlock {
178 #[serde(default)]
179 rules: Vec<String>,
180 #[serde(default)]
181 paths: Vec<String>,
182 #[serde(default)]
183 symbols: Vec<String>,
184}
185
186#[derive(Debug, Default, Deserialize)]
187#[serde(deny_unknown_fields)]
188struct RulesBlock {
189 #[serde(default)]
190 constraint_violation: Vec<RawConstraintRule>,
191}
192
193#[derive(Debug, Deserialize)]
194#[serde(deny_unknown_fields)]
195struct RawConstraintRule {
196 name: String,
197 glob: String,
198 #[serde(default)]
199 return_type: Option<String>,
200}
201
202impl ConfigFile {
203 fn compile(self, path: &Path) -> Result<Config, SpecDriftError> {
204 let mut severities = HashMap::new();
205 for (rule_name, sev) in self.severity {
206 let rule = parse_rule_id(&rule_name).ok_or_else(|| SpecDriftError::Config {
207 path: path.to_path_buf(),
208 message: format!("unknown rule in [severity]: `{rule_name}`"),
209 })?;
210 let sev = parse_severity(&sev).ok_or_else(|| SpecDriftError::Config {
211 path: path.to_path_buf(),
212 message: format!("unknown severity for `{rule_name}`: `{sev}`"),
213 })?;
214 severities.insert(rule, sev);
215 }
216
217 let mut ignored_rules = Vec::new();
218 for rule_name in self.ignore.rules {
219 let rule = parse_rule_id(&rule_name).ok_or_else(|| SpecDriftError::Config {
220 path: path.to_path_buf(),
221 message: format!("unknown rule in [ignore].rules: `{rule_name}`"),
222 })?;
223 ignored_rules.push(rule);
224 }
225
226 let mut constraint_rules = Vec::new();
227 for raw in self.rules.constraint_violation {
228 let glob = Glob::new(&raw.glob)
229 .map_err(|e| SpecDriftError::Config {
230 path: path.to_path_buf(),
231 message: format!(
232 "invalid glob in [[rules.constraint_violation]] `{}`: {e}",
233 raw.name
234 ),
235 })?
236 .compile_matcher();
237 constraint_rules.push(ConstraintRule {
238 name: raw.name,
239 glob,
240 return_type: raw.return_type,
241 });
242 }
243
244 let llm = match self.llm {
245 Some(raw) => {
246 let provider = match raw.provider.as_deref() {
247 None | Some("anthropic") => LlmProvider::Anthropic,
248 Some("openai") => LlmProvider::OpenAi,
249 Some("local") => LlmProvider::Local,
250 Some(other) => {
251 return Err(SpecDriftError::Config {
252 path: path.to_path_buf(),
253 message: format!("unknown [llm].provider: `{other}`"),
254 });
255 }
256 };
257 LlmConfig {
258 enabled: raw.enabled,
259 provider,
260 model: raw.model.unwrap_or_else(|| "claude-sonnet-4-6".to_string()),
261 max_calls: raw.max_calls.unwrap_or(50),
262 timeout_s: raw.timeout_s.unwrap_or(30),
263 }
264 }
265 None => LlmConfig::default(),
266 };
267
268 Ok(Config {
269 severities,
270 ignored_rules,
271 ignored_paths: build_globset(&self.ignore.paths, path, "paths")?,
272 ignored_symbols: build_globset(&self.ignore.symbols, path, "symbols")?,
273 constraint_rules,
274 llm,
275 })
276 }
277}
278
279fn build_globset(patterns: &[String], path: &Path, field: &str) -> Result<GlobSet, SpecDriftError> {
280 let mut builder = GlobSetBuilder::new();
281 for p in patterns {
282 let glob = Glob::new(p).map_err(|e| SpecDriftError::Config {
283 path: path.to_path_buf(),
284 message: format!("invalid glob in [ignore].{field}: `{p}`: {e}"),
285 })?;
286 builder.add(glob);
287 }
288 builder.build().map_err(|e| SpecDriftError::Config {
289 path: path.to_path_buf(),
290 message: format!("failed to compile [ignore].{field} glob set: {e}"),
291 })
292}
293
294fn parse_rule_id(name: &str) -> Option<RuleId> {
295 Some(match name {
296 "symbol_absence" => RuleId::SymbolAbsence,
297 "constraint_violation" => RuleId::ConstraintViolation,
298 "outdated_logic" => RuleId::OutdatedLogic,
299 "compile_failure" => RuleId::CompileFailure,
300 "deprecated_usage" => RuleId::DeprecatedUsage,
301 "logic_gap" => RuleId::LogicGap,
302 "lying_test" => RuleId::LyingTest,
303 "missing_coverage" => RuleId::MissingCoverage,
304 "ghost_command" => RuleId::GhostCommand,
305 "env_mismatch" => RuleId::EnvMismatch,
306 _ => return None,
307 })
308}
309
310fn parse_severity(name: &str) -> Option<Severity> {
311 Some(match name {
312 "notice" => Severity::Notice,
313 "warning" => Severity::Warning,
314 "critical" => Severity::Critical,
315 _ => return None,
316 })
317}
318
319#[cfg(test)]
320mod tests {
321 use super::*;
322 use crate::domain::Location;
323
324 fn make_div(rule: RuleId, file: &str) -> Divergence {
325 Divergence {
326 rule,
327 severity: Severity::Critical,
328 location: Location::new(file, 1),
329 stated: "`legacy_shim` exists in the codebase".into(),
330 reality: "not found".into(),
331 risk: "x".into(),
332 attribution: None,
333
334 }
335 }
336
337 fn write_config(body: &str) -> (tempfile::TempDir, PathBuf) {
338 let tmp = tempfile::tempdir().unwrap();
339 let path = tmp.path().join("spec-drift.toml");
340 std::fs::write(&path, body).unwrap();
341 (tmp, path)
342 }
343
344 #[test]
345 fn absent_config_loads_as_default() {
346 let tmp = tempfile::tempdir().unwrap();
347 let cfg = Config::load(&tmp.path().join("nope.toml")).unwrap();
348 assert!(cfg.severities.is_empty());
349 assert!(cfg.ignored_rules.is_empty());
350 }
351
352 #[test]
353 fn parses_severity_overrides() {
354 let (_tmp, path) = write_config(
355 r#"
356 [severity]
357 symbol_absence = "warning"
358 "#,
359 );
360 let cfg = Config::load(&path).unwrap();
361 assert_eq!(
362 cfg.severities.get(&RuleId::SymbolAbsence),
363 Some(&Severity::Warning)
364 );
365 }
366
367 #[test]
368 fn rejects_unknown_severity_value() {
369 let (_tmp, path) = write_config(
370 r#"
371 [severity]
372 symbol_absence = "loud"
373 "#,
374 );
375 assert!(Config::load(&path).is_err());
376 }
377
378 #[test]
379 fn rejects_unknown_rule_name() {
380 let (_tmp, path) = write_config(
381 r#"
382 [severity]
383 not_a_rule = "warning"
384 "#,
385 );
386 assert!(Config::load(&path).is_err());
387 }
388
389 #[test]
390 fn suppresses_by_rule_and_by_path() {
391 let (_tmp, path) = write_config(
392 r#"
393 [ignore]
394 rules = ["outdated_logic"]
395 paths = ["docs/legacy/**"]
396 symbols = ["legacy_*"]
397 "#,
398 );
399 let cfg = Config::load(&path).unwrap();
400 let root = Path::new("/root");
401
402 let by_rule = make_div(RuleId::OutdatedLogic, "/root/README.md");
403 assert!(cfg.is_suppressed(&by_rule, root));
404
405 let by_path = make_div(RuleId::SymbolAbsence, "/root/docs/legacy/api.md");
406 assert!(cfg.is_suppressed(&by_path, root));
407
408 let by_symbol = make_div(RuleId::SymbolAbsence, "/root/README.md");
409 assert!(cfg.is_suppressed(&by_symbol, root));
410
411 let kept = Divergence {
412 location: Location::new("/root/src/lib.rs", 1),
413 stated: "`keep_me` exists in the codebase".into(),
414 ..make_div(RuleId::SymbolAbsence, "/root/src/lib.rs")
415 };
416 assert!(!cfg.is_suppressed(&kept, root));
417 }
418
419 #[test]
420 fn applies_severity_overrides_in_place() {
421 let (_tmp, path) = write_config(
422 r#"
423 [severity]
424 symbol_absence = "notice"
425 "#,
426 );
427 let cfg = Config::load(&path).unwrap();
428 let mut divs = vec![make_div(RuleId::SymbolAbsence, "/root/a.md")];
429 cfg.apply_severity_overrides(&mut divs);
430 assert_eq!(divs[0].severity, Severity::Notice);
431 }
432
433 #[test]
434 fn llm_defaults_when_block_absent() {
435 let (_tmp, path) = write_config("");
436 let cfg = Config::load(&path).unwrap();
437 assert!(!cfg.llm.enabled);
438 assert_eq!(cfg.llm.provider, LlmProvider::Anthropic);
439 assert_eq!(cfg.llm.max_calls, 50);
440 }
441
442 #[test]
443 fn llm_block_parses_all_fields() {
444 let (_tmp, path) = write_config(
445 r#"
446 [llm]
447 enabled = true
448 provider = "openai"
449 model = "gpt-5"
450 max_calls = 10
451 timeout_s = 15
452 "#,
453 );
454 let cfg = Config::load(&path).unwrap();
455 assert!(cfg.llm.enabled);
456 assert_eq!(cfg.llm.provider, LlmProvider::OpenAi);
457 assert_eq!(cfg.llm.model, "gpt-5");
458 assert_eq!(cfg.llm.max_calls, 10);
459 assert_eq!(cfg.llm.timeout_s, 15);
460 }
461
462 #[test]
463 fn llm_rejects_unknown_provider() {
464 let (_tmp, path) = write_config(
465 r#"
466 [llm]
467 enabled = true
468 provider = "invented-corp"
469 "#,
470 );
471 assert!(Config::load(&path).is_err());
472 }
473
474 #[test]
475 fn discover_walks_upward() {
476 let tmp = tempfile::tempdir().unwrap();
477 let nested = tmp.path().join("a").join("b");
478 std::fs::create_dir_all(&nested).unwrap();
479 std::fs::write(tmp.path().join("spec-drift.toml"), "").unwrap();
480 let found = Config::discover(&nested).unwrap();
481 assert_eq!(found, tmp.path().join("spec-drift.toml"));
482 }
483}