Skip to main content

drft/
config.rs

1use anyhow::{Context, Result};
2use globset::{Glob, GlobSet, GlobSetBuilder};
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5use std::path::Path;
6
7/// Compile a list of glob patterns into a GlobSet. Returns None if patterns is empty.
8pub fn compile_globs(patterns: &[String]) -> Result<Option<GlobSet>> {
9    if patterns.is_empty() {
10        return Ok(None);
11    }
12    let mut builder = GlobSetBuilder::new();
13    for pattern in patterns {
14        builder.add(Glob::new(pattern)?);
15    }
16    Ok(Some(builder.build()?))
17}
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, Deserialize)]
20#[serde(rename_all = "lowercase")]
21pub enum RuleSeverity {
22    Error,
23    Warn,
24    Off,
25}
26
27// ── Parser config ──────────────────────────────────────────────
28
29/// Configuration for a single parser under `[parsers]`.
30/// Supports shorthand (`markdown = true`) and expanded table form
31/// (`[parsers.markdown]` with fields). Parser-specific options go
32/// under `[parsers.<name>.options]` and are passed through to the parser.
33#[derive(Debug, Clone, Serialize)]
34pub struct ParserConfig {
35    /// Which File nodes to send to this parser. None = all File nodes.
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub files: Option<Vec<String>>,
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub command: Option<String>,
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub timeout: Option<u64>,
42    /// Arbitrary options passed through to the parser (not interpreted by drft).
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub options: Option<toml::Value>,
45}
46
47/// Serde helper: untagged enum to parse shorthand or table forms.
48#[derive(Debug, Deserialize)]
49#[serde(untagged)]
50enum RawParserValue {
51    /// `markdown = true`
52    Bool(bool),
53    /// `markdown = ["frontmatter", "wikilink"]` (v0.3 shorthand for types)
54    Types(Vec<String>),
55    /// `[parsers.markdown]` with fields
56    Table {
57        files: Option<Vec<String>>,
58        command: Option<String>,
59        timeout: Option<u64>,
60        options: Option<toml::Value>,
61        // v0.3 keys — accepted as migration aliases
62        glob: Option<String>,
63        types: Option<Vec<String>>,
64    },
65}
66
67impl From<RawParserValue> for Option<ParserConfig> {
68    fn from(val: RawParserValue) -> Self {
69        match val {
70            RawParserValue::Bool(false) => None,
71            RawParserValue::Bool(true) => Some(ParserConfig {
72                files: None,
73                command: None,
74                timeout: None,
75                options: None,
76            }),
77            RawParserValue::Types(types) => {
78                // v0.3 shorthand: `markdown = ["frontmatter"]` → options.types
79                let options = toml::Value::Table(toml::map::Map::from_iter([(
80                    "types".to_string(),
81                    toml::Value::Array(types.into_iter().map(toml::Value::String).collect()),
82                )]));
83                Some(ParserConfig {
84                    files: None,
85                    command: None,
86                    timeout: None,
87                    options: Some(options),
88                })
89            }
90            RawParserValue::Table {
91                files,
92                command,
93                timeout,
94                options,
95                glob,
96                types,
97            } => {
98                // Migrate v0.3 `glob` → `files`
99                let files = if files.is_some() {
100                    files
101                } else if let Some(glob) = glob {
102                    eprintln!("warn: parser 'glob' is deprecated — rename to 'files' (v0.4)");
103                    Some(vec![glob])
104                } else {
105                    None
106                };
107
108                // Migrate v0.3 bare `types` → options.types
109                let options = if let Some(types) = types {
110                    eprintln!(
111                        "warn: parser 'types' is deprecated — move to [parsers.<name>.options] (v0.4)"
112                    );
113                    let types_val =
114                        toml::Value::Array(types.into_iter().map(toml::Value::String).collect());
115                    match options {
116                        Some(toml::Value::Table(mut tbl)) => {
117                            tbl.entry("types").or_insert(types_val);
118                            Some(toml::Value::Table(tbl))
119                        }
120                        None => {
121                            let tbl = toml::map::Map::from_iter([("types".to_string(), types_val)]);
122                            Some(toml::Value::Table(tbl))
123                        }
124                        other => other, // options exists but isn't a table — leave it
125                    }
126                } else {
127                    options
128                };
129
130                Some(ParserConfig {
131                    files,
132                    command,
133                    timeout,
134                    options,
135                })
136            }
137        }
138    }
139}
140
141// ── Interface config ───────────────────────────────────────────
142
143#[derive(Debug, Clone, Serialize, Deserialize)]
144pub struct InterfaceConfig {
145    pub files: Vec<String>,
146    #[serde(default)]
147    pub ignore: Vec<String>,
148}
149
150// ── Rule config ────────────────────────────────────────────────
151
152/// Configuration for a single rule under `[rules]`.
153/// Supports shorthand (`cycle = "warn"`) and table form (`[rules.orphan]`).
154#[derive(Debug, Clone)]
155pub struct RuleConfig {
156    pub severity: RuleSeverity,
157    /// Scope which nodes the rule evaluates (default: all).
158    pub files: Vec<String>,
159    /// Exclude nodes from diagnostics (default: none).
160    pub ignore: Vec<String>,
161    /// Scope which parser edges the rule evaluates (default: all).
162    pub parsers: Vec<String>,
163    pub command: Option<String>,
164    /// Arbitrary structured data passed through to the rule. drft doesn't interpret it.
165    pub options: Option<toml::Value>,
166    pub(crate) files_compiled: Option<GlobSet>,
167    pub(crate) ignore_compiled: Option<GlobSet>,
168}
169
170impl Serialize for RuleConfig {
171    fn serialize<S: serde::Serializer>(
172        &self,
173        serializer: S,
174    ) -> std::result::Result<S::Ok, S::Error> {
175        use serde::ser::SerializeMap;
176        // Count how many fields to serialize (skip empty/default fields for cleaner output)
177        let mut len = 1; // severity always present
178        if !self.files.is_empty() {
179            len += 1;
180        }
181        if !self.ignore.is_empty() {
182            len += 1;
183        }
184        if !self.parsers.is_empty() {
185            len += 1;
186        }
187        if self.command.is_some() {
188            len += 1;
189        }
190        if self.options.is_some() {
191            len += 1;
192        }
193        let mut map = serializer.serialize_map(Some(len))?;
194        map.serialize_entry("severity", &self.severity)?;
195        if !self.files.is_empty() {
196            map.serialize_entry("files", &self.files)?;
197        }
198        if !self.ignore.is_empty() {
199            map.serialize_entry("ignore", &self.ignore)?;
200        }
201        if !self.parsers.is_empty() {
202            map.serialize_entry("parsers", &self.parsers)?;
203        }
204        if let Some(ref command) = self.command {
205            map.serialize_entry("command", command)?;
206        }
207        if let Some(ref options) = self.options {
208            map.serialize_entry("options", options)?;
209        }
210        map.end()
211    }
212}
213
214impl RuleConfig {
215    pub fn new(
216        severity: RuleSeverity,
217        files: Vec<String>,
218        ignore: Vec<String>,
219        parsers: Vec<String>,
220        command: Option<String>,
221        options: Option<toml::Value>,
222    ) -> Result<Self> {
223        let files_compiled = compile_globs(&files).context("failed to compile files globs")?;
224        let ignore_compiled = compile_globs(&ignore).context("failed to compile ignore globs")?;
225        Ok(Self {
226            severity,
227            files,
228            ignore,
229            parsers,
230            command,
231            options,
232            files_compiled,
233            ignore_compiled,
234        })
235    }
236
237    pub fn is_path_in_scope(&self, path: &str) -> bool {
238        match self.files_compiled {
239            Some(ref glob_set) => glob_set.is_match(path),
240            None => true, // no files = all in scope
241        }
242    }
243
244    pub fn is_path_ignored(&self, path: &str) -> bool {
245        if let Some(ref glob_set) = self.ignore_compiled {
246            glob_set.is_match(path)
247        } else {
248            false
249        }
250    }
251}
252
253/// Serde helper: untagged enum for shorthand or table forms.
254#[derive(Debug, Deserialize)]
255#[serde(untagged)]
256enum RawRuleValue {
257    /// `cycle = "warn"`
258    Severity(RuleSeverity),
259    /// `[rules.orphan]` with fields
260    Table {
261        #[serde(default = "default_warn")]
262        severity: RuleSeverity,
263        #[serde(default)]
264        files: Vec<String>,
265        #[serde(default)]
266        ignore: Vec<String>,
267        #[serde(default)]
268        parsers: Vec<String>,
269        command: Option<String>,
270        options: Option<toml::Value>,
271    },
272}
273
274fn default_warn() -> RuleSeverity {
275    RuleSeverity::Warn
276}
277
278// ── Config ─────────────────────────────────────────────────────
279
280#[derive(Debug, Clone, Serialize)]
281pub struct Config {
282    /// Glob patterns declaring which filesystem paths become File nodes.
283    /// Default: `["*.md"]`.
284    pub include: Vec<String>,
285    /// Glob patterns removed from the graph (applied after `include`).
286    /// Also respects `.gitignore`.
287    #[serde(skip_serializing_if = "Vec::is_empty")]
288    pub exclude: Vec<String>,
289    #[serde(skip_serializing_if = "Option::is_none")]
290    pub interface: Option<InterfaceConfig>,
291    pub parsers: HashMap<String, ParserConfig>,
292    pub rules: HashMap<String, RuleConfig>,
293    /// Directory containing the drft.toml this config was loaded from.
294    #[serde(skip)]
295    pub config_dir: Option<std::path::PathBuf>,
296}
297
298#[derive(Debug, Deserialize)]
299#[serde(rename_all = "kebab-case")]
300struct RawConfig {
301    include: Option<Vec<String>>,
302    exclude: Option<Vec<String>>,
303    interface: Option<InterfaceConfig>,
304    parsers: Option<HashMap<String, RawParserValue>>,
305    rules: Option<HashMap<String, RawRuleValue>>,
306    // v0.3 key — accepted as alias for `exclude`
307    ignore: Option<Vec<String>>,
308    // v0.2 keys — detected for migration warnings
309    manifest: Option<toml::Value>,
310    custom_rules: Option<toml::Value>,
311    custom_analyses: Option<toml::Value>,
312    custom_metrics: Option<toml::Value>,
313    ignore_rules: Option<toml::Value>,
314}
315
316/// Names of all built-in rules (for unknown-rule warnings).
317const BUILTIN_RULES: &[&str] = &[
318    "boundary-violation",
319    "dangling-edge",
320    "directed-cycle",
321    "encapsulation-violation",
322    "fragmentation",
323    "orphan-node",
324    "schema-violation",
325    "stale",
326    "symlink-edge",
327    "untrackable-target",
328];
329
330impl Config {
331    pub fn defaults() -> Self {
332        // When no drft.toml exists, default to markdown parser enabled
333        let mut parsers = HashMap::new();
334        parsers.insert(
335            "markdown".to_string(),
336            ParserConfig {
337                files: None,
338                command: None,
339                timeout: None,
340                options: None,
341            },
342        );
343
344        let rules = [
345            ("boundary-violation", RuleSeverity::Warn),
346            ("dangling-edge", RuleSeverity::Warn),
347            ("directed-cycle", RuleSeverity::Warn),
348            ("encapsulation-violation", RuleSeverity::Warn),
349            ("fragmentation", RuleSeverity::Warn),
350            ("orphan-node", RuleSeverity::Warn),
351            ("stale", RuleSeverity::Warn),
352            ("symlink-edge", RuleSeverity::Warn),
353            ("untrackable-target", RuleSeverity::Warn),
354        ]
355        .into_iter()
356        .map(|(k, v)| {
357            (
358                k.to_string(),
359                RuleConfig::new(v, Vec::new(), Vec::new(), Vec::new(), None, None)
360                    .expect("default rule config"),
361            )
362        })
363        .collect();
364
365        Config {
366            include: vec!["*.md".to_string()],
367            exclude: Vec::new(),
368            interface: None,
369            parsers,
370            rules,
371            config_dir: None,
372        }
373    }
374
375    pub fn load(root: &Path) -> Result<Self> {
376        let config_path = Self::find_config(root);
377        let config_path = match config_path {
378            Some(p) => p,
379            None => anyhow::bail!("no drft.toml found (run `drft init` to create one)"),
380        };
381
382        let content = std::fs::read_to_string(&config_path)
383            .with_context(|| format!("failed to read {}", config_path.display()))?;
384
385        let raw: RawConfig = toml::from_str(&content)
386            .with_context(|| format!("failed to parse {}", config_path.display()))?;
387
388        // Warn about v0.2 config keys
389        if raw.manifest.is_some() {
390            eprintln!("warn: drft.toml uses v0.2 'manifest' key — migrate to [interface] section");
391        }
392        if raw.custom_rules.is_some() {
393            eprintln!(
394                "warn: drft.toml uses v0.2 [custom-rules] — migrate to [rules] with 'command' field"
395            );
396        }
397        if raw.custom_analyses.is_some() {
398            eprintln!(
399                "warn: drft.toml uses v0.2 [custom-analyses] — custom analyses are no longer supported"
400            );
401        }
402        if raw.custom_metrics.is_some() {
403            eprintln!(
404                "warn: drft.toml uses v0.2 [custom-metrics] — custom metrics are no longer supported"
405            );
406        }
407        if raw.ignore_rules.is_some() {
408            eprintln!(
409                "warn: drft.toml uses v0.2 [ignore-rules] — migrate to per-rule 'ignore' field"
410            );
411        }
412
413        let mut config = Self::defaults();
414        config.config_dir = config_path.parent().map(|p| p.to_path_buf());
415
416        if let Some(include) = raw.include {
417            config.include = include;
418        }
419
420        // `ignore` is the v0.3 name for `exclude` — accept with warning
421        if raw.ignore.is_some() && raw.exclude.is_some() {
422            anyhow::bail!(
423                "drft.toml has both 'ignore' and 'exclude' — remove 'ignore' (renamed to 'exclude' in v0.4)"
424            );
425        }
426        if let Some(ignore) = raw.ignore {
427            eprintln!("warn: drft.toml uses 'ignore' — rename to 'exclude' (v0.4)");
428            config.exclude = ignore;
429        }
430        if let Some(exclude) = raw.exclude {
431            config.exclude = exclude;
432        }
433
434        config.interface = raw.interface;
435
436        // Parse parsers
437        if let Some(raw_parsers) = raw.parsers {
438            config.parsers.clear();
439            for (name, value) in raw_parsers {
440                if let Some(parser_config) = Option::<ParserConfig>::from(value) {
441                    config.parsers.insert(name, parser_config);
442                }
443            }
444        }
445
446        // Parse rules (unified: built-in severities + table form + custom rules)
447        if let Some(raw_rules) = raw.rules {
448            for (name, value) in raw_rules {
449                let rule_config = match value {
450                    RawRuleValue::Severity(severity) => {
451                        RuleConfig::new(severity, Vec::new(), Vec::new(), Vec::new(), None, None)?
452                    }
453                    RawRuleValue::Table {
454                        severity,
455                        files,
456                        ignore,
457                        parsers,
458                        command,
459                        options,
460                    } => RuleConfig::new(severity, files, ignore, parsers, command, options)
461                        .with_context(|| format!("invalid globs in rules.{name}"))?,
462                };
463
464                // Warn about unknown built-in rules (but allow custom rules with command)
465                if rule_config.command.is_none() && !BUILTIN_RULES.contains(&name.as_str()) {
466                    eprintln!("warn: unknown rule \"{name}\" in drft.toml (ignored)");
467                }
468
469                // Warn about unknown parser references in rule parsers
470                for parser_name in &rule_config.parsers {
471                    if !config.parsers.contains_key(parser_name) {
472                        eprintln!(
473                            "warn: unknown parser \"{parser_name}\" in rules.{name}.parsers in drft.toml"
474                        );
475                    }
476                }
477
478                config.rules.insert(name, rule_config);
479            }
480        }
481
482        Ok(config)
483    }
484
485    /// Find drft.toml in `root`. No directory walking — if it's not here, use defaults.
486    fn find_config(root: &Path) -> Option<std::path::PathBuf> {
487        let candidate = root.join("drft.toml");
488        candidate.exists().then_some(candidate)
489    }
490
491    pub fn rule_severity(&self, name: &str) -> RuleSeverity {
492        self.rules
493            .get(name)
494            .map(|r| r.severity)
495            .unwrap_or(RuleSeverity::Off)
496    }
497
498    /// Check if a path is in scope for a specific rule (passes `files` filter).
499    pub fn is_rule_in_scope(&self, rule: &str, path: &str) -> bool {
500        self.rules
501            .get(rule)
502            .is_none_or(|r| r.is_path_in_scope(path))
503    }
504
505    /// Check if a path should be ignored for a specific rule.
506    pub fn is_rule_ignored(&self, rule: &str, path: &str) -> bool {
507        self.rules
508            .get(rule)
509            .is_some_and(|r| r.is_path_ignored(path))
510    }
511
512    /// Get a rule's options (the `[rules.<name>.options]` section).
513    pub fn rule_options(&self, name: &str) -> Option<&toml::Value> {
514        self.rules.get(name).and_then(|r| r.options.as_ref())
515    }
516
517    /// Get parser names a rule is scoped to (empty = all parsers).
518    pub fn rule_parsers(&self, name: &str) -> &[String] {
519        self.rules
520            .get(name)
521            .map(|r| r.parsers.as_slice())
522            .unwrap_or(&[])
523    }
524
525    /// Get custom rules (rules with a command field).
526    pub fn custom_rules(&self) -> impl Iterator<Item = (&str, &RuleConfig)> {
527        self.rules
528            .iter()
529            .filter(|(_, r)| r.command.is_some())
530            .map(|(name, config)| (name.as_str(), config))
531    }
532}
533
534#[cfg(test)]
535mod tests {
536    use super::*;
537    use std::fs;
538    use tempfile::TempDir;
539
540    #[test]
541    fn errors_when_no_config() {
542        let dir = TempDir::new().unwrap();
543        let result = Config::load(dir.path());
544        assert!(result.is_err());
545        assert!(
546            result
547                .unwrap_err()
548                .to_string()
549                .contains("no drft.toml found"),
550        );
551    }
552
553    #[test]
554    fn loads_rule_severities() {
555        let dir = TempDir::new().unwrap();
556        fs::write(
557            dir.path().join("drft.toml"),
558            "[rules]\ndangling-edge = \"error\"\norphan-node = \"warn\"\n",
559        )
560        .unwrap();
561        let config = Config::load(dir.path()).unwrap();
562        assert_eq!(config.rule_severity("dangling-edge"), RuleSeverity::Error);
563        assert_eq!(config.rule_severity("orphan-node"), RuleSeverity::Warn);
564        assert_eq!(config.rule_severity("directed-cycle"), RuleSeverity::Warn);
565    }
566
567    #[test]
568    fn loads_rule_with_ignore() {
569        let dir = TempDir::new().unwrap();
570        fs::write(
571            dir.path().join("drft.toml"),
572            "[rules.orphan-node]\nseverity = \"warn\"\nignore = [\"README.md\", \"index.md\"]\n",
573        )
574        .unwrap();
575        let config = Config::load(dir.path()).unwrap();
576        assert_eq!(config.rule_severity("orphan-node"), RuleSeverity::Warn);
577        assert!(config.is_rule_ignored("orphan-node", "README.md"));
578        assert!(config.is_rule_ignored("orphan-node", "index.md"));
579        assert!(!config.is_rule_ignored("orphan-node", "other.md"));
580        assert!(!config.is_rule_ignored("dangling-edge", "README.md"));
581    }
582
583    #[test]
584    fn loads_rule_with_options() {
585        let dir = TempDir::new().unwrap();
586        fs::write(
587            dir.path().join("drft.toml"),
588            r#"
589[rules.schema-violation]
590severity = "warn"
591
592[rules.schema-violation.options]
593required = ["title"]
594
595[rules.schema-violation.options.schemas."observations/*.md"]
596required = ["title", "date", "status"]
597"#,
598        )
599        .unwrap();
600        let config = Config::load(dir.path()).unwrap();
601        let opts = config.rule_options("schema-violation").unwrap();
602        let required = opts.get("required").unwrap().as_array().unwrap();
603        assert_eq!(required.len(), 1);
604        assert_eq!(required[0].as_str().unwrap(), "title");
605        let schemas = opts.get("schemas").unwrap().as_table().unwrap();
606        assert!(schemas.contains_key("observations/*.md"));
607    }
608
609    #[test]
610    fn shorthand_rule_has_no_options() {
611        let dir = TempDir::new().unwrap();
612        fs::write(
613            dir.path().join("drft.toml"),
614            "[rules]\ndangling-edge = \"error\"\n",
615        )
616        .unwrap();
617        let config = Config::load(dir.path()).unwrap();
618        assert!(config.rule_options("dangling-edge").is_none());
619    }
620
621    #[test]
622    fn loads_parser_shorthand_bool() {
623        let dir = TempDir::new().unwrap();
624        fs::write(dir.path().join("drft.toml"), "[parsers]\nmarkdown = true\n").unwrap();
625        let config = Config::load(dir.path()).unwrap();
626        assert!(config.parsers.contains_key("markdown"));
627        let p = &config.parsers["markdown"];
628        assert!(p.files.is_none());
629        assert!(p.options.is_none());
630        assert!(p.command.is_none());
631    }
632
633    #[test]
634    fn loads_parser_shorthand_types_migrates_to_options() {
635        let dir = TempDir::new().unwrap();
636        fs::write(
637            dir.path().join("drft.toml"),
638            "[parsers]\nmarkdown = [\"frontmatter\", \"wikilink\"]\n",
639        )
640        .unwrap();
641        let config = Config::load(dir.path()).unwrap();
642        let p = &config.parsers["markdown"];
643        // v0.3 shorthand types → options.types
644        let opts = p.options.as_ref().unwrap();
645        let types = opts.get("types").unwrap().as_array().unwrap();
646        assert_eq!(types.len(), 2);
647        assert_eq!(types[0].as_str().unwrap(), "frontmatter");
648        assert_eq!(types[1].as_str().unwrap(), "wikilink");
649    }
650
651    #[test]
652    fn loads_parser_table_with_files() {
653        let dir = TempDir::new().unwrap();
654        fs::write(
655            dir.path().join("drft.toml"),
656            "[parsers.tsx]\nfiles = [\"*.tsx\", \"*.ts\"]\ncommand = \"./parse.sh\"\ntimeout = 10000\n",
657        )
658        .unwrap();
659        let config = Config::load(dir.path()).unwrap();
660        let p = &config.parsers["tsx"];
661        assert_eq!(
662            p.files.as_deref(),
663            Some(&["*.tsx".to_string(), "*.ts".to_string()][..])
664        );
665        assert_eq!(p.command.as_deref(), Some("./parse.sh"));
666        assert_eq!(p.timeout, Some(10000));
667    }
668
669    #[test]
670    fn loads_parser_glob_migrates_to_files() {
671        let dir = TempDir::new().unwrap();
672        fs::write(
673            dir.path().join("drft.toml"),
674            "[parsers.tsx]\nglob = \"*.tsx\"\ncommand = \"./parse.sh\"\n",
675        )
676        .unwrap();
677        let config = Config::load(dir.path()).unwrap();
678        let p = &config.parsers["tsx"];
679        assert_eq!(p.files.as_deref(), Some(&["*.tsx".to_string()][..]));
680    }
681
682    #[test]
683    fn loads_parser_options() {
684        let dir = TempDir::new().unwrap();
685        fs::write(
686            dir.path().join("drft.toml"),
687            "[parsers.markdown]\nfiles = [\"*.md\"]\n\n[parsers.markdown.options]\ntypes = [\"inline\"]\nextract_metadata = true\n",
688        )
689        .unwrap();
690        let config = Config::load(dir.path()).unwrap();
691        let p = &config.parsers["markdown"];
692        let opts = p.options.as_ref().unwrap();
693        assert!(opts.get("types").is_some());
694        assert_eq!(opts.get("extract_metadata").unwrap().as_bool(), Some(true));
695    }
696
697    #[test]
698    fn parser_false_disables() {
699        let dir = TempDir::new().unwrap();
700        fs::write(
701            dir.path().join("drft.toml"),
702            "[parsers]\nmarkdown = false\n",
703        )
704        .unwrap();
705        let config = Config::load(dir.path()).unwrap();
706        assert!(!config.parsers.contains_key("markdown"));
707    }
708
709    #[test]
710    fn loads_interface() {
711        let dir = TempDir::new().unwrap();
712        fs::write(
713            dir.path().join("drft.toml"),
714            "[interface]\nfiles = [\"overview.md\", \"api/*.md\"]\n",
715        )
716        .unwrap();
717        let config = Config::load(dir.path()).unwrap();
718        let iface = config.interface.unwrap();
719        assert_eq!(iface.files, vec!["overview.md", "api/*.md"]);
720    }
721
722    #[test]
723    fn loads_custom_rule() {
724        let dir = TempDir::new().unwrap();
725        fs::write(
726            dir.path().join("drft.toml"),
727            "[rules.my-check]\ncommand = \"./check.sh\"\nseverity = \"warn\"\n",
728        )
729        .unwrap();
730        let config = Config::load(dir.path()).unwrap();
731        let custom_rules: Vec<_> = config.custom_rules().collect();
732        assert_eq!(custom_rules.len(), 1);
733        assert_eq!(custom_rules[0].0, "my-check");
734        assert_eq!(custom_rules[0].1.command.as_deref(), Some("./check.sh"));
735    }
736
737    #[test]
738    fn loads_include_exclude() {
739        let dir = TempDir::new().unwrap();
740        fs::write(
741            dir.path().join("drft.toml"),
742            "include = [\"*.md\", \"*.yaml\"]\nexclude = [\"drafts/*\"]\n",
743        )
744        .unwrap();
745        let config = Config::load(dir.path()).unwrap();
746        assert_eq!(config.include, vec!["*.md", "*.yaml"]);
747        assert_eq!(config.exclude, vec!["drafts/*"]);
748    }
749
750    #[test]
751    fn ignore_migrates_to_exclude() {
752        let dir = TempDir::new().unwrap();
753        fs::write(dir.path().join("drft.toml"), "ignore = [\"drafts/*\"]\n").unwrap();
754        let config = Config::load(dir.path()).unwrap();
755        assert_eq!(config.exclude, vec!["drafts/*"]);
756    }
757
758    #[test]
759    fn ignore_and_exclude_conflicts() {
760        let dir = TempDir::new().unwrap();
761        fs::write(
762            dir.path().join("drft.toml"),
763            "ignore = [\"a/*\"]\nexclude = [\"b/*\"]\n",
764        )
765        .unwrap();
766        assert!(Config::load(dir.path()).is_err());
767    }
768
769    #[test]
770    fn invalid_toml_returns_error() {
771        let dir = TempDir::new().unwrap();
772        fs::write(dir.path().join("drft.toml"), "not valid toml {{{{").unwrap();
773        assert!(Config::load(dir.path()).is_err());
774    }
775
776    #[test]
777    fn child_without_config_errors() {
778        let dir = TempDir::new().unwrap();
779        fs::write(
780            dir.path().join("drft.toml"),
781            "[rules]\norphan-node = \"error\"\n",
782        )
783        .unwrap();
784
785        let child = dir.path().join("child");
786        fs::create_dir(&child).unwrap();
787
788        let result = Config::load(&child);
789        assert!(result.is_err());
790    }
791}