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#[derive(Debug, Clone)]
22pub struct ParserConfig {
23 pub files: Option<Vec<String>>,
25 pub command: Option<String>,
26 pub timeout: Option<u64>,
27 pub options: Option<toml::Value>,
29}
30
31#[derive(Debug, Deserialize)]
33#[serde(untagged)]
34enum RawParserValue {
35 Bool(bool),
37 Types(Vec<String>),
39 Table {
41 files: Option<Vec<String>>,
42 command: Option<String>,
43 timeout: Option<u64>,
44 options: Option<toml::Value>,
45 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 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 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 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, }
110 } else {
111 options
112 };
113
114 Some(ParserConfig {
115 files,
116 command,
117 timeout,
118 options,
119 })
120 }
121 }
122 }
123}
124
125#[derive(Debug, Clone, Deserialize)]
128pub struct InterfaceConfig {
129 pub nodes: Vec<String>,
130}
131
132#[derive(Debug, Clone)]
137pub struct RuleConfig {
138 pub severity: RuleSeverity,
139 pub ignore: Vec<String>,
140 pub command: Option<String>,
141 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#[derive(Debug, Deserialize)]
158#[serde(untagged)]
159enum RawRuleValue {
160 Severity(RuleSeverity),
162 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#[derive(Debug, Clone)]
180pub struct Config {
181 pub include: Vec<String>,
184 pub exclude: Vec<String>,
187 pub interface: Option<InterfaceConfig>,
188 pub parsers: HashMap<String, ParserConfig>,
189 pub rules: HashMap<String, RuleConfig>,
190 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 ignore: Option<Vec<String>>,
204 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
212const 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 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 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 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 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 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 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 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 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 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 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 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}