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