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
66impl Config {
67    /// Load a config from `path`. Returns `Config::default()` if the file does
68    /// not exist — a missing config is not an error.
69    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    /// Walk upward from `start` looking for a `spec-drift.toml`. Returns
85    /// `Ok(None)` if none is found.
86    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    /// True when this divergence should be dropped before reporting.
99    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        // Best-effort symbol matcher: pull the first backticked token out of
112        // `stated`, strip any trailing `()`, and test against the symbols glob.
113        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    /// Apply `[severity]` overrides in place.
122    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// ---------------------------------------------------------------------------
144// Raw on-disk representation
145// ---------------------------------------------------------------------------
146
147#[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}