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    "fragmentation",
311    "orphan-node",
312    "schema-violation",
313    "stale",
314    "symlink-edge",
315    "untrackable-target",
316];
317
318impl Config {
319    pub fn defaults() -> Self {
320        // When no drft.toml exists, default to markdown parser enabled
321        let mut parsers = HashMap::new();
322        parsers.insert(
323            "markdown".to_string(),
324            ParserConfig {
325                files: None,
326                command: None,
327                timeout: None,
328                options: None,
329            },
330        );
331
332        let rules = [
333            ("boundary-violation", RuleSeverity::Warn),
334            ("dangling-edge", RuleSeverity::Warn),
335            ("directed-cycle", RuleSeverity::Warn),
336            ("encapsulation-violation", RuleSeverity::Warn),
337            ("fragmentation", RuleSeverity::Warn),
338            ("orphan-node", RuleSeverity::Warn),
339            ("stale", RuleSeverity::Warn),
340            ("symlink-edge", RuleSeverity::Warn),
341            ("untrackable-target", RuleSeverity::Warn),
342        ]
343        .into_iter()
344        .map(|(k, v)| {
345            (
346                k.to_string(),
347                RuleConfig::new(v, Vec::new(), Vec::new(), None, None)
348                    .expect("default rule config"),
349            )
350        })
351        .collect();
352
353        Config {
354            include: vec!["*.md".to_string()],
355            exclude: Vec::new(),
356            interface: None,
357            parsers,
358            rules,
359            config_dir: None,
360        }
361    }
362
363    pub fn load(root: &Path) -> Result<Self> {
364        let config_path = Self::find_config(root);
365        let config_path = match config_path {
366            Some(p) => p,
367            None => anyhow::bail!("no drft.toml found (run `drft init` to create one)"),
368        };
369
370        let content = std::fs::read_to_string(&config_path)
371            .with_context(|| format!("failed to read {}", config_path.display()))?;
372
373        let raw: RawConfig = toml::from_str(&content)
374            .with_context(|| format!("failed to parse {}", config_path.display()))?;
375
376        // Warn about v0.2 config keys
377        if raw.manifest.is_some() {
378            eprintln!("warn: drft.toml uses v0.2 'manifest' key — migrate to [interface] section");
379        }
380        if raw.custom_rules.is_some() {
381            eprintln!(
382                "warn: drft.toml uses v0.2 [custom-rules] — migrate to [rules] with 'command' field"
383            );
384        }
385        if raw.custom_analyses.is_some() {
386            eprintln!(
387                "warn: drft.toml uses v0.2 [custom-analyses] — custom analyses are no longer supported"
388            );
389        }
390        if raw.custom_metrics.is_some() {
391            eprintln!(
392                "warn: drft.toml uses v0.2 [custom-metrics] — custom metrics are no longer supported"
393            );
394        }
395        if raw.ignore_rules.is_some() {
396            eprintln!(
397                "warn: drft.toml uses v0.2 [ignore-rules] — migrate to per-rule 'ignore' field"
398            );
399        }
400
401        let mut config = Self::defaults();
402        config.config_dir = config_path.parent().map(|p| p.to_path_buf());
403
404        if let Some(include) = raw.include {
405            config.include = include;
406        }
407
408        // `ignore` is the v0.3 name for `exclude` — accept with warning
409        if raw.ignore.is_some() && raw.exclude.is_some() {
410            anyhow::bail!(
411                "drft.toml has both 'ignore' and 'exclude' — remove 'ignore' (renamed to 'exclude' in v0.4)"
412            );
413        }
414        if let Some(ignore) = raw.ignore {
415            eprintln!("warn: drft.toml uses 'ignore' — rename to 'exclude' (v0.4)");
416            config.exclude = ignore;
417        }
418        if let Some(exclude) = raw.exclude {
419            config.exclude = exclude;
420        }
421
422        config.interface = raw.interface;
423
424        // Parse parsers
425        if let Some(raw_parsers) = raw.parsers {
426            config.parsers.clear();
427            for (name, value) in raw_parsers {
428                if let Some(parser_config) = Option::<ParserConfig>::from(value) {
429                    config.parsers.insert(name, parser_config);
430                }
431            }
432        }
433
434        // Parse rules (unified: built-in severities + table form + custom rules)
435        if let Some(raw_rules) = raw.rules {
436            for (name, value) in raw_rules {
437                let rule_config = match value {
438                    RawRuleValue::Severity(severity) => {
439                        RuleConfig::new(severity, Vec::new(), Vec::new(), None, None)?
440                    }
441                    RawRuleValue::Table {
442                        severity,
443                        files,
444                        ignore,
445                        command,
446                        options,
447                    } => RuleConfig::new(severity, files, ignore, command, options)
448                        .with_context(|| format!("invalid globs in rules.{name}"))?,
449                };
450
451                // Warn about unknown built-in rules (but allow custom rules with command)
452                if rule_config.command.is_none() && !BUILTIN_RULES.contains(&name.as_str()) {
453                    eprintln!("warn: unknown rule \"{name}\" in drft.toml (ignored)");
454                }
455
456                config.rules.insert(name, rule_config);
457            }
458        }
459
460        Ok(config)
461    }
462
463    /// Find drft.toml in `root`. No directory walking — if it's not here, use defaults.
464    fn find_config(root: &Path) -> Option<std::path::PathBuf> {
465        let candidate = root.join("drft.toml");
466        candidate.exists().then_some(candidate)
467    }
468
469    pub fn rule_severity(&self, name: &str) -> RuleSeverity {
470        self.rules
471            .get(name)
472            .map(|r| r.severity)
473            .unwrap_or(RuleSeverity::Off)
474    }
475
476    /// Check if a path is in scope for a specific rule (passes `files` filter).
477    pub fn is_rule_in_scope(&self, rule: &str, path: &str) -> bool {
478        self.rules
479            .get(rule)
480            .is_none_or(|r| r.is_path_in_scope(path))
481    }
482
483    /// Check if a path should be ignored for a specific rule.
484    pub fn is_rule_ignored(&self, rule: &str, path: &str) -> bool {
485        self.rules
486            .get(rule)
487            .is_some_and(|r| r.is_path_ignored(path))
488    }
489
490    /// Get a rule's options (the `[rules.<name>.options]` section).
491    pub fn rule_options(&self, name: &str) -> Option<&toml::Value> {
492        self.rules.get(name).and_then(|r| r.options.as_ref())
493    }
494
495    /// Get custom rules (rules with a command field).
496    pub fn custom_rules(&self) -> impl Iterator<Item = (&str, &RuleConfig)> {
497        self.rules
498            .iter()
499            .filter(|(_, r)| r.command.is_some())
500            .map(|(name, config)| (name.as_str(), config))
501    }
502}
503
504#[cfg(test)]
505mod tests {
506    use super::*;
507    use std::fs;
508    use tempfile::TempDir;
509
510    #[test]
511    fn errors_when_no_config() {
512        let dir = TempDir::new().unwrap();
513        let result = Config::load(dir.path());
514        assert!(result.is_err());
515        assert!(
516            result
517                .unwrap_err()
518                .to_string()
519                .contains("no drft.toml found"),
520        );
521    }
522
523    #[test]
524    fn loads_rule_severities() {
525        let dir = TempDir::new().unwrap();
526        fs::write(
527            dir.path().join("drft.toml"),
528            "[rules]\ndangling-edge = \"error\"\norphan-node = \"warn\"\n",
529        )
530        .unwrap();
531        let config = Config::load(dir.path()).unwrap();
532        assert_eq!(config.rule_severity("dangling-edge"), RuleSeverity::Error);
533        assert_eq!(config.rule_severity("orphan-node"), RuleSeverity::Warn);
534        assert_eq!(config.rule_severity("directed-cycle"), RuleSeverity::Warn);
535    }
536
537    #[test]
538    fn loads_rule_with_ignore() {
539        let dir = TempDir::new().unwrap();
540        fs::write(
541            dir.path().join("drft.toml"),
542            "[rules.orphan-node]\nseverity = \"warn\"\nignore = [\"README.md\", \"index.md\"]\n",
543        )
544        .unwrap();
545        let config = Config::load(dir.path()).unwrap();
546        assert_eq!(config.rule_severity("orphan-node"), RuleSeverity::Warn);
547        assert!(config.is_rule_ignored("orphan-node", "README.md"));
548        assert!(config.is_rule_ignored("orphan-node", "index.md"));
549        assert!(!config.is_rule_ignored("orphan-node", "other.md"));
550        assert!(!config.is_rule_ignored("dangling-edge", "README.md"));
551    }
552
553    #[test]
554    fn loads_rule_with_options() {
555        let dir = TempDir::new().unwrap();
556        fs::write(
557            dir.path().join("drft.toml"),
558            r#"
559[rules.schema-violation]
560severity = "warn"
561
562[rules.schema-violation.options]
563required = ["title"]
564
565[rules.schema-violation.options.schemas."observations/*.md"]
566required = ["title", "date", "status"]
567"#,
568        )
569        .unwrap();
570        let config = Config::load(dir.path()).unwrap();
571        let opts = config.rule_options("schema-violation").unwrap();
572        let required = opts.get("required").unwrap().as_array().unwrap();
573        assert_eq!(required.len(), 1);
574        assert_eq!(required[0].as_str().unwrap(), "title");
575        let schemas = opts.get("schemas").unwrap().as_table().unwrap();
576        assert!(schemas.contains_key("observations/*.md"));
577    }
578
579    #[test]
580    fn shorthand_rule_has_no_options() {
581        let dir = TempDir::new().unwrap();
582        fs::write(
583            dir.path().join("drft.toml"),
584            "[rules]\ndangling-edge = \"error\"\n",
585        )
586        .unwrap();
587        let config = Config::load(dir.path()).unwrap();
588        assert!(config.rule_options("dangling-edge").is_none());
589    }
590
591    #[test]
592    fn loads_parser_shorthand_bool() {
593        let dir = TempDir::new().unwrap();
594        fs::write(dir.path().join("drft.toml"), "[parsers]\nmarkdown = true\n").unwrap();
595        let config = Config::load(dir.path()).unwrap();
596        assert!(config.parsers.contains_key("markdown"));
597        let p = &config.parsers["markdown"];
598        assert!(p.files.is_none());
599        assert!(p.options.is_none());
600        assert!(p.command.is_none());
601    }
602
603    #[test]
604    fn loads_parser_shorthand_types_migrates_to_options() {
605        let dir = TempDir::new().unwrap();
606        fs::write(
607            dir.path().join("drft.toml"),
608            "[parsers]\nmarkdown = [\"frontmatter\", \"wikilink\"]\n",
609        )
610        .unwrap();
611        let config = Config::load(dir.path()).unwrap();
612        let p = &config.parsers["markdown"];
613        // v0.3 shorthand types → options.types
614        let opts = p.options.as_ref().unwrap();
615        let types = opts.get("types").unwrap().as_array().unwrap();
616        assert_eq!(types.len(), 2);
617        assert_eq!(types[0].as_str().unwrap(), "frontmatter");
618        assert_eq!(types[1].as_str().unwrap(), "wikilink");
619    }
620
621    #[test]
622    fn loads_parser_table_with_files() {
623        let dir = TempDir::new().unwrap();
624        fs::write(
625            dir.path().join("drft.toml"),
626            "[parsers.tsx]\nfiles = [\"*.tsx\", \"*.ts\"]\ncommand = \"./parse.sh\"\ntimeout = 10000\n",
627        )
628        .unwrap();
629        let config = Config::load(dir.path()).unwrap();
630        let p = &config.parsers["tsx"];
631        assert_eq!(
632            p.files.as_deref(),
633            Some(&["*.tsx".to_string(), "*.ts".to_string()][..])
634        );
635        assert_eq!(p.command.as_deref(), Some("./parse.sh"));
636        assert_eq!(p.timeout, Some(10000));
637    }
638
639    #[test]
640    fn loads_parser_glob_migrates_to_files() {
641        let dir = TempDir::new().unwrap();
642        fs::write(
643            dir.path().join("drft.toml"),
644            "[parsers.tsx]\nglob = \"*.tsx\"\ncommand = \"./parse.sh\"\n",
645        )
646        .unwrap();
647        let config = Config::load(dir.path()).unwrap();
648        let p = &config.parsers["tsx"];
649        assert_eq!(p.files.as_deref(), Some(&["*.tsx".to_string()][..]));
650    }
651
652    #[test]
653    fn loads_parser_options() {
654        let dir = TempDir::new().unwrap();
655        fs::write(
656            dir.path().join("drft.toml"),
657            "[parsers.markdown]\nfiles = [\"*.md\"]\n\n[parsers.markdown.options]\ntypes = [\"inline\"]\nextract_metadata = true\n",
658        )
659        .unwrap();
660        let config = Config::load(dir.path()).unwrap();
661        let p = &config.parsers["markdown"];
662        let opts = p.options.as_ref().unwrap();
663        assert!(opts.get("types").is_some());
664        assert_eq!(opts.get("extract_metadata").unwrap().as_bool(), Some(true));
665    }
666
667    #[test]
668    fn parser_false_disables() {
669        let dir = TempDir::new().unwrap();
670        fs::write(
671            dir.path().join("drft.toml"),
672            "[parsers]\nmarkdown = false\n",
673        )
674        .unwrap();
675        let config = Config::load(dir.path()).unwrap();
676        assert!(!config.parsers.contains_key("markdown"));
677    }
678
679    #[test]
680    fn loads_interface() {
681        let dir = TempDir::new().unwrap();
682        fs::write(
683            dir.path().join("drft.toml"),
684            "[interface]\nfiles = [\"overview.md\", \"api/*.md\"]\n",
685        )
686        .unwrap();
687        let config = Config::load(dir.path()).unwrap();
688        let iface = config.interface.unwrap();
689        assert_eq!(iface.files, vec!["overview.md", "api/*.md"]);
690    }
691
692    #[test]
693    fn loads_custom_rule() {
694        let dir = TempDir::new().unwrap();
695        fs::write(
696            dir.path().join("drft.toml"),
697            "[rules.my-check]\ncommand = \"./check.sh\"\nseverity = \"warn\"\n",
698        )
699        .unwrap();
700        let config = Config::load(dir.path()).unwrap();
701        let custom_rules: Vec<_> = config.custom_rules().collect();
702        assert_eq!(custom_rules.len(), 1);
703        assert_eq!(custom_rules[0].0, "my-check");
704        assert_eq!(custom_rules[0].1.command.as_deref(), Some("./check.sh"));
705    }
706
707    #[test]
708    fn loads_include_exclude() {
709        let dir = TempDir::new().unwrap();
710        fs::write(
711            dir.path().join("drft.toml"),
712            "include = [\"*.md\", \"*.yaml\"]\nexclude = [\"drafts/*\"]\n",
713        )
714        .unwrap();
715        let config = Config::load(dir.path()).unwrap();
716        assert_eq!(config.include, vec!["*.md", "*.yaml"]);
717        assert_eq!(config.exclude, vec!["drafts/*"]);
718    }
719
720    #[test]
721    fn ignore_migrates_to_exclude() {
722        let dir = TempDir::new().unwrap();
723        fs::write(dir.path().join("drft.toml"), "ignore = [\"drafts/*\"]\n").unwrap();
724        let config = Config::load(dir.path()).unwrap();
725        assert_eq!(config.exclude, vec!["drafts/*"]);
726    }
727
728    #[test]
729    fn ignore_and_exclude_conflicts() {
730        let dir = TempDir::new().unwrap();
731        fs::write(
732            dir.path().join("drft.toml"),
733            "ignore = [\"a/*\"]\nexclude = [\"b/*\"]\n",
734        )
735        .unwrap();
736        assert!(Config::load(dir.path()).is_err());
737    }
738
739    #[test]
740    fn invalid_toml_returns_error() {
741        let dir = TempDir::new().unwrap();
742        fs::write(dir.path().join("drft.toml"), "not valid toml {{{{").unwrap();
743        assert!(Config::load(dir.path()).is_err());
744    }
745
746    #[test]
747    fn child_without_config_errors() {
748        let dir = TempDir::new().unwrap();
749        fs::write(
750            dir.path().join("drft.toml"),
751            "[rules]\norphan-node = \"error\"\n",
752        )
753        .unwrap();
754
755        let child = dir.path().join("child");
756        fs::create_dir(&child).unwrap();
757
758        let result = Config::load(&child);
759        assert!(result.is_err());
760    }
761}