Skip to main content

spec_drift/
config.rs

1//! `spec-drift.toml` configuration.
2//!
3//! The config is intentionally small. It supports the subset of the README-
4//! documented config that materially changes analyzer output:
5//!
6//! - `[severity]` — override the severity of a rule by ID.
7//! - `[ignore]` — silence rules, paths, or symbol patterns globally.
8//!
9//! Unknown keys are rejected so typos don't silently fail open.
10
11use 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/// A user-declared structural constraint applied by `ConstraintAnalyzer`.
19#[derive(Debug, Clone)]
20pub struct ConstraintRule {
21    pub name: String,
22    pub glob: GlobMatcher,
23    pub return_type: Option<String>,
24}
25
26/// Parsed `spec-drift.toml`.
27#[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/// `[llm]` block — always off by default. `--no-llm` on the CLI wins unconditionally.
38#[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/// Whether a config path was user-specified or auto-discovered.
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub enum ConfigSource {
69    Explicit,
70    Discovered,
71}
72
73impl Config {
74    /// Load a config from `path`. When `source` is [`ConfigSource::Discovered`],
75    /// a missing file silently returns the default config. When `Explicit`, a
76    /// missing file is an error — the user asked for a specific config that
77    /// doesn't exist.
78    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    /// Walk upward from `start` looking for a `spec-drift.toml`. Returns
100    /// `Ok(None)` if none is found.
101    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    /// True when this divergence should be dropped before reporting.
114    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        // Best-effort symbol matcher: pull the first backticked token out of
127        // `stated`, strip any trailing `()`, and test against the symbols glob.
128        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    /// Apply `[severity]` overrides in place.
137    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// ---------------------------------------------------------------------------
159// Raw on-disk representation
160// ---------------------------------------------------------------------------
161
162#[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}