Skip to main content

drft/
config.rs

1use anyhow::{Context, Result};
2use globset::{Glob, GlobSet, GlobSetBuilder};
3use serde::Deserialize;
4use std::collections::HashMap;
5use std::path::Path;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, Deserialize)]
8#[serde(rename_all = "lowercase")]
9pub enum RuleSeverity {
10    Error,
11    Warn,
12    Off,
13}
14
15// ── Parser config ──────────────────────────────────────────────
16
17/// Configuration for a single parser under `[parsers]`.
18/// Supports shorthand (`markdown = true`, `markdown = ["frontmatter"]`)
19/// and expanded table form (`[parsers.markdown]` with fields).
20#[derive(Debug, Clone)]
21pub struct ParserConfig {
22    pub glob: Option<String>,
23    pub types: Option<Vec<String>>,
24    pub command: Option<String>,
25    pub timeout: Option<u64>,
26}
27
28/// Serde helper: untagged enum to parse shorthand or table forms.
29#[derive(Debug, Deserialize)]
30#[serde(untagged)]
31enum RawParserValue {
32    /// `markdown = true`
33    Bool(bool),
34    /// `markdown = ["frontmatter", "wikilink"]`
35    Types(Vec<String>),
36    /// `[parsers.markdown]` with fields
37    Table {
38        glob: Option<String>,
39        types: Option<Vec<String>>,
40        command: Option<String>,
41        timeout: Option<u64>,
42    },
43}
44
45impl From<RawParserValue> for Option<ParserConfig> {
46    fn from(val: RawParserValue) -> Self {
47        match val {
48            RawParserValue::Bool(false) => None,
49            RawParserValue::Bool(true) => Some(ParserConfig {
50                glob: None,
51                types: None,
52                command: None,
53                timeout: None,
54            }),
55            RawParserValue::Types(types) => Some(ParserConfig {
56                glob: None,
57                types: Some(types),
58                command: None,
59                timeout: None,
60            }),
61            RawParserValue::Table {
62                glob,
63                types,
64                command,
65                timeout,
66            } => Some(ParserConfig {
67                glob,
68                types,
69                command,
70                timeout,
71            }),
72        }
73    }
74}
75
76// ── Interface config ───────────────────────────────────────────
77
78#[derive(Debug, Clone, Deserialize)]
79pub struct InterfaceConfig {
80    pub nodes: Vec<String>,
81}
82
83// ── Rule config ────────────────────────────────────────────────
84
85/// Configuration for a single rule under `[rules]`.
86/// Supports shorthand (`cycle = "warn"`) and table form (`[rules.orphan]`).
87#[derive(Debug, Clone)]
88pub struct RuleConfig {
89    pub severity: RuleSeverity,
90    #[allow(dead_code)]
91    pub ignore: Vec<String>,
92    pub command: Option<String>,
93    #[allow(dead_code)]
94    pub timeout: Option<u64>,
95    pub(crate) ignore_compiled: Option<GlobSet>,
96}
97
98impl RuleConfig {
99    pub fn is_path_ignored(&self, path: &str) -> bool {
100        if let Some(ref glob_set) = self.ignore_compiled {
101            glob_set.is_match(path)
102        } else {
103            false
104        }
105    }
106}
107
108/// Serde helper: untagged enum for shorthand or table forms.
109#[derive(Debug, Deserialize)]
110#[serde(untagged)]
111enum RawRuleValue {
112    /// `cycle = "warn"`
113    Severity(RuleSeverity),
114    /// `[rules.orphan]` with fields
115    Table {
116        #[serde(default = "default_warn")]
117        severity: RuleSeverity,
118        #[serde(default)]
119        ignore: Vec<String>,
120        command: Option<String>,
121        timeout: Option<u64>,
122    },
123}
124
125fn default_warn() -> RuleSeverity {
126    RuleSeverity::Warn
127}
128
129// ── Config ─────────────────────────────────────────────────────
130
131#[derive(Debug, Clone)]
132pub struct Config {
133    pub ignore: Vec<String>,
134    pub interface: Option<InterfaceConfig>,
135    pub parsers: HashMap<String, ParserConfig>,
136    pub rules: HashMap<String, RuleConfig>,
137    /// Directory containing the drft.toml this config was loaded from.
138    pub config_dir: Option<std::path::PathBuf>,
139}
140
141#[derive(Debug, Deserialize)]
142#[serde(rename_all = "kebab-case")]
143struct RawConfig {
144    ignore: Option<Vec<String>>,
145    interface: Option<InterfaceConfig>,
146    parsers: Option<HashMap<String, RawParserValue>>,
147    rules: Option<HashMap<String, RawRuleValue>>,
148    // v0.2 keys — detected for migration warnings
149    manifest: Option<toml::Value>,
150    custom_rules: Option<toml::Value>,
151    custom_analyses: Option<toml::Value>,
152    custom_metrics: Option<toml::Value>,
153    ignore_rules: Option<toml::Value>,
154}
155
156/// Names of all built-in rules (for unknown-rule warnings).
157const BUILTIN_RULES: &[&str] = &[
158    "broken-link",
159    "containment",
160    "cycle",
161    "directory-link",
162    "encapsulation",
163    "fragility",
164    "fragmentation",
165    "indirect-link",
166    "layer-violation",
167    "orphan",
168    "redundant-edge",
169    "stale",
170];
171
172impl Config {
173    pub fn defaults() -> Self {
174        // When no drft.toml exists, default to markdown parser enabled
175        let mut parsers = HashMap::new();
176        parsers.insert(
177            "markdown".to_string(),
178            ParserConfig {
179                glob: None,
180                types: None,
181                command: None,
182                timeout: None,
183            },
184        );
185
186        let rules = [
187            ("broken-link", RuleSeverity::Warn),
188            ("containment", RuleSeverity::Warn),
189            ("cycle", RuleSeverity::Warn),
190            ("directory-link", RuleSeverity::Warn),
191            ("encapsulation", RuleSeverity::Warn),
192            ("fragility", RuleSeverity::Off),
193            ("fragmentation", RuleSeverity::Off),
194            ("indirect-link", RuleSeverity::Off),
195            ("layer-violation", RuleSeverity::Off),
196            ("orphan", RuleSeverity::Off),
197            ("redundant-edge", RuleSeverity::Off),
198            ("stale", RuleSeverity::Error),
199        ]
200        .into_iter()
201        .map(|(k, v)| {
202            (
203                k.to_string(),
204                RuleConfig {
205                    severity: v,
206                    ignore: Vec::new(),
207                    command: None,
208                    timeout: None,
209                    ignore_compiled: None,
210                },
211            )
212        })
213        .collect();
214
215        Config {
216            ignore: Vec::new(),
217            interface: None,
218            parsers,
219            rules,
220            config_dir: None,
221        }
222    }
223
224    pub fn load(root: &Path) -> Result<Self> {
225        let config_path = Self::find_config(root);
226        let config_path = match config_path {
227            Some(p) => p,
228            None => return Ok(Self::defaults()),
229        };
230
231        let content = std::fs::read_to_string(&config_path)
232            .with_context(|| format!("failed to read {}", config_path.display()))?;
233
234        let raw: RawConfig = toml::from_str(&content)
235            .with_context(|| format!("failed to parse {}", config_path.display()))?;
236
237        // Warn about v0.2 config keys
238        if raw.manifest.is_some() {
239            eprintln!("warn: drft.toml uses v0.2 'manifest' key — migrate to [interface] section");
240        }
241        if raw.custom_rules.is_some() {
242            eprintln!(
243                "warn: drft.toml uses v0.2 [custom-rules] — migrate to [rules] with 'command' field"
244            );
245        }
246        if raw.custom_analyses.is_some() {
247            eprintln!(
248                "warn: drft.toml uses v0.2 [custom-analyses] — custom analyses are no longer supported"
249            );
250        }
251        if raw.custom_metrics.is_some() {
252            eprintln!(
253                "warn: drft.toml uses v0.2 [custom-metrics] — custom metrics are no longer supported"
254            );
255        }
256        if raw.ignore_rules.is_some() {
257            eprintln!(
258                "warn: drft.toml uses v0.2 [ignore-rules] — migrate to per-rule 'ignore' field"
259            );
260        }
261
262        let mut config = Self::defaults();
263        config.config_dir = config_path.parent().map(|p| p.to_path_buf());
264
265        if let Some(ignore) = raw.ignore {
266            config.ignore = ignore;
267        }
268
269        config.interface = raw.interface;
270
271        // Parse parsers
272        if let Some(raw_parsers) = raw.parsers {
273            config.parsers.clear();
274            for (name, value) in raw_parsers {
275                if let Some(parser_config) = Option::<ParserConfig>::from(value) {
276                    config.parsers.insert(name, parser_config);
277                }
278            }
279        }
280
281        // Parse rules (unified: built-in severities + table form + script rules)
282        if let Some(raw_rules) = raw.rules {
283            for (name, value) in raw_rules {
284                let rule_config = match value {
285                    RawRuleValue::Severity(severity) => RuleConfig {
286                        severity,
287                        ignore: Vec::new(),
288                        command: None,
289                        timeout: None,
290                        ignore_compiled: None,
291                    },
292                    RawRuleValue::Table {
293                        severity,
294                        ignore,
295                        command,
296                        timeout,
297                    } => {
298                        let compiled = if ignore.is_empty() {
299                            None
300                        } else {
301                            let mut builder = GlobSetBuilder::new();
302                            for pattern in &ignore {
303                                builder.add(Glob::new(pattern).with_context(|| {
304                                    format!("invalid glob in rules.{name}.ignore")
305                                })?);
306                            }
307                            Some(builder.build().with_context(|| {
308                                format!("failed to compile globs for rules.{name}.ignore")
309                            })?)
310                        };
311                        RuleConfig {
312                            severity,
313                            ignore,
314                            command,
315                            timeout,
316                            ignore_compiled: compiled,
317                        }
318                    }
319                };
320
321                // Warn about unknown built-in rules (but allow script rules with command)
322                if rule_config.command.is_none() && !BUILTIN_RULES.contains(&name.as_str()) {
323                    eprintln!("warn: unknown rule \"{name}\" in drft.toml (ignored)");
324                }
325
326                config.rules.insert(name, rule_config);
327            }
328        }
329
330        Ok(config)
331    }
332
333    /// Find the nearest drft.toml by walking up from `root`.
334    fn find_config(root: &Path) -> Option<std::path::PathBuf> {
335        let mut current = root.to_path_buf();
336        loop {
337            let candidate = current.join("drft.toml");
338            if candidate.exists() {
339                return Some(candidate);
340            }
341            if !current.pop() {
342                return None;
343            }
344        }
345    }
346
347    pub fn rule_severity(&self, name: &str) -> RuleSeverity {
348        self.rules
349            .get(name)
350            .map(|r| r.severity)
351            .unwrap_or(RuleSeverity::Off)
352    }
353
354    /// Check if a path should be ignored for a specific rule.
355    pub fn is_rule_ignored(&self, rule: &str, path: &str) -> bool {
356        self.rules
357            .get(rule)
358            .is_some_and(|r| r.is_path_ignored(path))
359    }
360
361    /// Get script rules (rules with a command field).
362    pub fn script_rules(&self) -> impl Iterator<Item = (&str, &RuleConfig)> {
363        self.rules
364            .iter()
365            .filter(|(_, r)| r.command.is_some())
366            .map(|(name, config)| (name.as_str(), config))
367    }
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373    use std::fs;
374    use tempfile::TempDir;
375
376    #[test]
377    fn defaults_when_no_config() {
378        let dir = TempDir::new().unwrap();
379        let config = Config::load(dir.path()).unwrap();
380        assert_eq!(config.rule_severity("broken-link"), RuleSeverity::Warn);
381        assert_eq!(config.rule_severity("orphan"), RuleSeverity::Off);
382        assert!(config.ignore.is_empty());
383        assert!(config.parsers.contains_key("markdown"));
384    }
385
386    #[test]
387    fn loads_rule_severities() {
388        let dir = TempDir::new().unwrap();
389        fs::write(
390            dir.path().join("drft.toml"),
391            "[rules]\nbroken-link = \"error\"\norphan = \"warn\"\n",
392        )
393        .unwrap();
394        let config = Config::load(dir.path()).unwrap();
395        assert_eq!(config.rule_severity("broken-link"), RuleSeverity::Error);
396        assert_eq!(config.rule_severity("orphan"), RuleSeverity::Warn);
397        assert_eq!(config.rule_severity("cycle"), RuleSeverity::Warn);
398    }
399
400    #[test]
401    fn loads_rule_with_ignore() {
402        let dir = TempDir::new().unwrap();
403        fs::write(
404            dir.path().join("drft.toml"),
405            "[rules.orphan]\nseverity = \"warn\"\nignore = [\"README.md\", \"index.md\"]\n",
406        )
407        .unwrap();
408        let config = Config::load(dir.path()).unwrap();
409        assert_eq!(config.rule_severity("orphan"), RuleSeverity::Warn);
410        assert!(config.is_rule_ignored("orphan", "README.md"));
411        assert!(config.is_rule_ignored("orphan", "index.md"));
412        assert!(!config.is_rule_ignored("orphan", "other.md"));
413        assert!(!config.is_rule_ignored("broken-link", "README.md"));
414    }
415
416    #[test]
417    fn loads_parser_shorthand_bool() {
418        let dir = TempDir::new().unwrap();
419        fs::write(dir.path().join("drft.toml"), "[parsers]\nmarkdown = true\n").unwrap();
420        let config = Config::load(dir.path()).unwrap();
421        assert!(config.parsers.contains_key("markdown"));
422        let p = &config.parsers["markdown"];
423        assert!(p.glob.is_none());
424        assert!(p.types.is_none());
425        assert!(p.command.is_none());
426    }
427
428    #[test]
429    fn loads_parser_shorthand_types() {
430        let dir = TempDir::new().unwrap();
431        fs::write(
432            dir.path().join("drft.toml"),
433            "[parsers]\nmarkdown = [\"frontmatter\", \"wikilink\"]\n",
434        )
435        .unwrap();
436        let config = Config::load(dir.path()).unwrap();
437        let p = &config.parsers["markdown"];
438        assert_eq!(
439            p.types.as_deref(),
440            Some(vec!["frontmatter".to_string(), "wikilink".to_string()]).as_deref()
441        );
442    }
443
444    #[test]
445    fn loads_parser_table() {
446        let dir = TempDir::new().unwrap();
447        fs::write(
448            dir.path().join("drft.toml"),
449            "[parsers.tsx]\nglob = \"*.tsx\"\ncommand = \"./parse.sh\"\ntimeout = 10000\n",
450        )
451        .unwrap();
452        let config = Config::load(dir.path()).unwrap();
453        let p = &config.parsers["tsx"];
454        assert_eq!(p.glob.as_deref(), Some("*.tsx"));
455        assert_eq!(p.command.as_deref(), Some("./parse.sh"));
456        assert_eq!(p.timeout, Some(10000));
457    }
458
459    #[test]
460    fn parser_false_disables() {
461        let dir = TempDir::new().unwrap();
462        fs::write(
463            dir.path().join("drft.toml"),
464            "[parsers]\nmarkdown = false\n",
465        )
466        .unwrap();
467        let config = Config::load(dir.path()).unwrap();
468        assert!(!config.parsers.contains_key("markdown"));
469    }
470
471    #[test]
472    fn loads_interface() {
473        let dir = TempDir::new().unwrap();
474        fs::write(
475            dir.path().join("drft.toml"),
476            "[interface]\nnodes = [\"overview.md\", \"api/*.md\"]\n",
477        )
478        .unwrap();
479        let config = Config::load(dir.path()).unwrap();
480        let iface = config.interface.unwrap();
481        assert_eq!(iface.nodes, vec!["overview.md", "api/*.md"]);
482    }
483
484    #[test]
485    fn loads_script_rule() {
486        let dir = TempDir::new().unwrap();
487        fs::write(
488            dir.path().join("drft.toml"),
489            "[rules.my-check]\ncommand = \"./check.sh\"\nseverity = \"warn\"\n",
490        )
491        .unwrap();
492        let config = Config::load(dir.path()).unwrap();
493        let script_rules: Vec<_> = config.script_rules().collect();
494        assert_eq!(script_rules.len(), 1);
495        assert_eq!(script_rules[0].0, "my-check");
496        assert_eq!(script_rules[0].1.command.as_deref(), Some("./check.sh"));
497    }
498
499    #[test]
500    fn invalid_toml_returns_error() {
501        let dir = TempDir::new().unwrap();
502        fs::write(dir.path().join("drft.toml"), "not valid toml {{{{").unwrap();
503        assert!(Config::load(dir.path()).is_err());
504    }
505
506    #[test]
507    fn inherits_config_from_parent() {
508        let dir = TempDir::new().unwrap();
509        fs::write(
510            dir.path().join("drft.toml"),
511            "[rules]\norphan = \"error\"\n",
512        )
513        .unwrap();
514
515        let child = dir.path().join("child");
516        fs::create_dir(&child).unwrap();
517
518        let config = Config::load(&child).unwrap();
519        assert_eq!(config.rule_severity("orphan"), RuleSeverity::Error);
520    }
521
522    #[test]
523    fn child_config_overrides_parent() {
524        let dir = TempDir::new().unwrap();
525        fs::write(
526            dir.path().join("drft.toml"),
527            "[rules]\norphan = \"error\"\n",
528        )
529        .unwrap();
530
531        let child = dir.path().join("child");
532        fs::create_dir(&child).unwrap();
533        fs::write(child.join("drft.toml"), "[rules]\norphan = \"off\"\n").unwrap();
534
535        let config = Config::load(&child).unwrap();
536        assert_eq!(config.rule_severity("orphan"), RuleSeverity::Off);
537    }
538}