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