Skip to main content

drft/
config.rs

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