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