Skip to main content

drft/
config.rs

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