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 parsers: Vec<String>,
163 pub command: Option<String>,
164 pub options: Option<toml::Value>,
166 pub(crate) files_compiled: Option<GlobSet>,
167 pub(crate) ignore_compiled: Option<GlobSet>,
168}
169
170impl Serialize for RuleConfig {
171 fn serialize<S: serde::Serializer>(
172 &self,
173 serializer: S,
174 ) -> std::result::Result<S::Ok, S::Error> {
175 use serde::ser::SerializeMap;
176 let mut len = 1; if !self.files.is_empty() {
179 len += 1;
180 }
181 if !self.ignore.is_empty() {
182 len += 1;
183 }
184 if !self.parsers.is_empty() {
185 len += 1;
186 }
187 if self.command.is_some() {
188 len += 1;
189 }
190 if self.options.is_some() {
191 len += 1;
192 }
193 let mut map = serializer.serialize_map(Some(len))?;
194 map.serialize_entry("severity", &self.severity)?;
195 if !self.files.is_empty() {
196 map.serialize_entry("files", &self.files)?;
197 }
198 if !self.ignore.is_empty() {
199 map.serialize_entry("ignore", &self.ignore)?;
200 }
201 if !self.parsers.is_empty() {
202 map.serialize_entry("parsers", &self.parsers)?;
203 }
204 if let Some(ref command) = self.command {
205 map.serialize_entry("command", command)?;
206 }
207 if let Some(ref options) = self.options {
208 map.serialize_entry("options", options)?;
209 }
210 map.end()
211 }
212}
213
214impl RuleConfig {
215 pub fn new(
216 severity: RuleSeverity,
217 files: Vec<String>,
218 ignore: Vec<String>,
219 parsers: Vec<String>,
220 command: Option<String>,
221 options: Option<toml::Value>,
222 ) -> Result<Self> {
223 let files_compiled = compile_globs(&files).context("failed to compile files globs")?;
224 let ignore_compiled = compile_globs(&ignore).context("failed to compile ignore globs")?;
225 Ok(Self {
226 severity,
227 files,
228 ignore,
229 parsers,
230 command,
231 options,
232 files_compiled,
233 ignore_compiled,
234 })
235 }
236
237 pub fn is_path_in_scope(&self, path: &str) -> bool {
238 match self.files_compiled {
239 Some(ref glob_set) => glob_set.is_match(path),
240 None => true, }
242 }
243
244 pub fn is_path_ignored(&self, path: &str) -> bool {
245 if let Some(ref glob_set) = self.ignore_compiled {
246 glob_set.is_match(path)
247 } else {
248 false
249 }
250 }
251}
252
253#[derive(Debug, Deserialize)]
255#[serde(untagged)]
256enum RawRuleValue {
257 Severity(RuleSeverity),
259 Table {
261 #[serde(default = "default_warn")]
262 severity: RuleSeverity,
263 #[serde(default)]
264 files: Vec<String>,
265 #[serde(default)]
266 ignore: Vec<String>,
267 #[serde(default)]
268 parsers: Vec<String>,
269 command: Option<String>,
270 options: Option<toml::Value>,
271 },
272}
273
274fn default_warn() -> RuleSeverity {
275 RuleSeverity::Warn
276}
277
278#[derive(Debug, Clone, Serialize)]
281pub struct Config {
282 pub include: Vec<String>,
285 #[serde(skip_serializing_if = "Vec::is_empty")]
288 pub exclude: Vec<String>,
289 #[serde(skip_serializing_if = "Option::is_none")]
290 pub interface: Option<InterfaceConfig>,
291 pub parsers: HashMap<String, ParserConfig>,
292 pub rules: HashMap<String, RuleConfig>,
293 #[serde(skip)]
295 pub config_dir: Option<std::path::PathBuf>,
296}
297
298#[derive(Debug, Deserialize)]
299#[serde(rename_all = "kebab-case")]
300struct RawConfig {
301 include: Option<Vec<String>>,
302 exclude: Option<Vec<String>>,
303 interface: Option<InterfaceConfig>,
304 parsers: Option<HashMap<String, RawParserValue>>,
305 rules: Option<HashMap<String, RawRuleValue>>,
306 ignore: Option<Vec<String>>,
308 manifest: Option<toml::Value>,
310 custom_rules: Option<toml::Value>,
311 custom_analyses: Option<toml::Value>,
312 custom_metrics: Option<toml::Value>,
313 ignore_rules: Option<toml::Value>,
314}
315
316const BUILTIN_RULES: &[&str] = &[
318 "boundary-violation",
319 "dangling-edge",
320 "directed-cycle",
321 "encapsulation-violation",
322 "fragmentation",
323 "orphan-node",
324 "schema-violation",
325 "stale",
326 "symlink-edge",
327 "untrackable-target",
328];
329
330impl Config {
331 pub fn defaults() -> Self {
332 let mut parsers = HashMap::new();
334 parsers.insert(
335 "markdown".to_string(),
336 ParserConfig {
337 files: None,
338 command: None,
339 timeout: None,
340 options: None,
341 },
342 );
343
344 let rules = [
345 ("boundary-violation", RuleSeverity::Warn),
346 ("dangling-edge", RuleSeverity::Warn),
347 ("directed-cycle", RuleSeverity::Warn),
348 ("encapsulation-violation", RuleSeverity::Warn),
349 ("fragmentation", RuleSeverity::Warn),
350 ("orphan-node", RuleSeverity::Warn),
351 ("stale", RuleSeverity::Warn),
352 ("symlink-edge", RuleSeverity::Warn),
353 ("untrackable-target", RuleSeverity::Warn),
354 ]
355 .into_iter()
356 .map(|(k, v)| {
357 (
358 k.to_string(),
359 RuleConfig::new(v, Vec::new(), Vec::new(), Vec::new(), None, None)
360 .expect("default rule config"),
361 )
362 })
363 .collect();
364
365 Config {
366 include: vec!["*.md".to_string()],
367 exclude: Vec::new(),
368 interface: None,
369 parsers,
370 rules,
371 config_dir: None,
372 }
373 }
374
375 pub fn load(root: &Path) -> Result<Self> {
376 let config_path = Self::find_config(root);
377 let config_path = match config_path {
378 Some(p) => p,
379 None => anyhow::bail!("no drft.toml found (run `drft init` to create one)"),
380 };
381
382 let content = std::fs::read_to_string(&config_path)
383 .with_context(|| format!("failed to read {}", config_path.display()))?;
384
385 let raw: RawConfig = toml::from_str(&content)
386 .with_context(|| format!("failed to parse {}", config_path.display()))?;
387
388 if raw.manifest.is_some() {
390 eprintln!("warn: drft.toml uses v0.2 'manifest' key — migrate to [interface] section");
391 }
392 if raw.custom_rules.is_some() {
393 eprintln!(
394 "warn: drft.toml uses v0.2 [custom-rules] — migrate to [rules] with 'command' field"
395 );
396 }
397 if raw.custom_analyses.is_some() {
398 eprintln!(
399 "warn: drft.toml uses v0.2 [custom-analyses] — custom analyses are no longer supported"
400 );
401 }
402 if raw.custom_metrics.is_some() {
403 eprintln!(
404 "warn: drft.toml uses v0.2 [custom-metrics] — custom metrics are no longer supported"
405 );
406 }
407 if raw.ignore_rules.is_some() {
408 eprintln!(
409 "warn: drft.toml uses v0.2 [ignore-rules] — migrate to per-rule 'ignore' field"
410 );
411 }
412
413 let mut config = Self::defaults();
414 config.config_dir = config_path.parent().map(|p| p.to_path_buf());
415
416 if let Some(include) = raw.include {
417 config.include = include;
418 }
419
420 if raw.ignore.is_some() && raw.exclude.is_some() {
422 anyhow::bail!(
423 "drft.toml has both 'ignore' and 'exclude' — remove 'ignore' (renamed to 'exclude' in v0.4)"
424 );
425 }
426 if let Some(ignore) = raw.ignore {
427 eprintln!("warn: drft.toml uses 'ignore' — rename to 'exclude' (v0.4)");
428 config.exclude = ignore;
429 }
430 if let Some(exclude) = raw.exclude {
431 config.exclude = exclude;
432 }
433
434 config.interface = raw.interface;
435
436 if let Some(raw_parsers) = raw.parsers {
438 config.parsers.clear();
439 for (name, value) in raw_parsers {
440 if let Some(parser_config) = Option::<ParserConfig>::from(value) {
441 config.parsers.insert(name, parser_config);
442 }
443 }
444 }
445
446 if let Some(raw_rules) = raw.rules {
448 for (name, value) in raw_rules {
449 let rule_config = match value {
450 RawRuleValue::Severity(severity) => {
451 RuleConfig::new(severity, Vec::new(), Vec::new(), Vec::new(), None, None)?
452 }
453 RawRuleValue::Table {
454 severity,
455 files,
456 ignore,
457 parsers,
458 command,
459 options,
460 } => RuleConfig::new(severity, files, ignore, parsers, command, options)
461 .with_context(|| format!("invalid globs in rules.{name}"))?,
462 };
463
464 if rule_config.command.is_none() && !BUILTIN_RULES.contains(&name.as_str()) {
466 eprintln!("warn: unknown rule \"{name}\" in drft.toml (ignored)");
467 }
468
469 for parser_name in &rule_config.parsers {
471 if !config.parsers.contains_key(parser_name) {
472 eprintln!(
473 "warn: unknown parser \"{parser_name}\" in rules.{name}.parsers in drft.toml"
474 );
475 }
476 }
477
478 config.rules.insert(name, rule_config);
479 }
480 }
481
482 Ok(config)
483 }
484
485 fn find_config(root: &Path) -> Option<std::path::PathBuf> {
487 let candidate = root.join("drft.toml");
488 candidate.exists().then_some(candidate)
489 }
490
491 pub fn rule_severity(&self, name: &str) -> RuleSeverity {
492 self.rules
493 .get(name)
494 .map(|r| r.severity)
495 .unwrap_or(RuleSeverity::Off)
496 }
497
498 pub fn is_rule_in_scope(&self, rule: &str, path: &str) -> bool {
500 self.rules
501 .get(rule)
502 .is_none_or(|r| r.is_path_in_scope(path))
503 }
504
505 pub fn is_rule_ignored(&self, rule: &str, path: &str) -> bool {
507 self.rules
508 .get(rule)
509 .is_some_and(|r| r.is_path_ignored(path))
510 }
511
512 pub fn rule_options(&self, name: &str) -> Option<&toml::Value> {
514 self.rules.get(name).and_then(|r| r.options.as_ref())
515 }
516
517 pub fn rule_parsers(&self, name: &str) -> &[String] {
519 self.rules
520 .get(name)
521 .map(|r| r.parsers.as_slice())
522 .unwrap_or(&[])
523 }
524
525 pub fn custom_rules(&self) -> impl Iterator<Item = (&str, &RuleConfig)> {
527 self.rules
528 .iter()
529 .filter(|(_, r)| r.command.is_some())
530 .map(|(name, config)| (name.as_str(), config))
531 }
532}
533
534#[cfg(test)]
535mod tests {
536 use super::*;
537 use std::fs;
538 use tempfile::TempDir;
539
540 #[test]
541 fn errors_when_no_config() {
542 let dir = TempDir::new().unwrap();
543 let result = Config::load(dir.path());
544 assert!(result.is_err());
545 assert!(
546 result
547 .unwrap_err()
548 .to_string()
549 .contains("no drft.toml found"),
550 );
551 }
552
553 #[test]
554 fn loads_rule_severities() {
555 let dir = TempDir::new().unwrap();
556 fs::write(
557 dir.path().join("drft.toml"),
558 "[rules]\ndangling-edge = \"error\"\norphan-node = \"warn\"\n",
559 )
560 .unwrap();
561 let config = Config::load(dir.path()).unwrap();
562 assert_eq!(config.rule_severity("dangling-edge"), RuleSeverity::Error);
563 assert_eq!(config.rule_severity("orphan-node"), RuleSeverity::Warn);
564 assert_eq!(config.rule_severity("directed-cycle"), RuleSeverity::Warn);
565 }
566
567 #[test]
568 fn loads_rule_with_ignore() {
569 let dir = TempDir::new().unwrap();
570 fs::write(
571 dir.path().join("drft.toml"),
572 "[rules.orphan-node]\nseverity = \"warn\"\nignore = [\"README.md\", \"index.md\"]\n",
573 )
574 .unwrap();
575 let config = Config::load(dir.path()).unwrap();
576 assert_eq!(config.rule_severity("orphan-node"), RuleSeverity::Warn);
577 assert!(config.is_rule_ignored("orphan-node", "README.md"));
578 assert!(config.is_rule_ignored("orphan-node", "index.md"));
579 assert!(!config.is_rule_ignored("orphan-node", "other.md"));
580 assert!(!config.is_rule_ignored("dangling-edge", "README.md"));
581 }
582
583 #[test]
584 fn loads_rule_with_options() {
585 let dir = TempDir::new().unwrap();
586 fs::write(
587 dir.path().join("drft.toml"),
588 r#"
589[rules.schema-violation]
590severity = "warn"
591
592[rules.schema-violation.options]
593required = ["title"]
594
595[rules.schema-violation.options.schemas."observations/*.md"]
596required = ["title", "date", "status"]
597"#,
598 )
599 .unwrap();
600 let config = Config::load(dir.path()).unwrap();
601 let opts = config.rule_options("schema-violation").unwrap();
602 let required = opts.get("required").unwrap().as_array().unwrap();
603 assert_eq!(required.len(), 1);
604 assert_eq!(required[0].as_str().unwrap(), "title");
605 let schemas = opts.get("schemas").unwrap().as_table().unwrap();
606 assert!(schemas.contains_key("observations/*.md"));
607 }
608
609 #[test]
610 fn shorthand_rule_has_no_options() {
611 let dir = TempDir::new().unwrap();
612 fs::write(
613 dir.path().join("drft.toml"),
614 "[rules]\ndangling-edge = \"error\"\n",
615 )
616 .unwrap();
617 let config = Config::load(dir.path()).unwrap();
618 assert!(config.rule_options("dangling-edge").is_none());
619 }
620
621 #[test]
622 fn loads_parser_shorthand_bool() {
623 let dir = TempDir::new().unwrap();
624 fs::write(dir.path().join("drft.toml"), "[parsers]\nmarkdown = true\n").unwrap();
625 let config = Config::load(dir.path()).unwrap();
626 assert!(config.parsers.contains_key("markdown"));
627 let p = &config.parsers["markdown"];
628 assert!(p.files.is_none());
629 assert!(p.options.is_none());
630 assert!(p.command.is_none());
631 }
632
633 #[test]
634 fn loads_parser_shorthand_types_migrates_to_options() {
635 let dir = TempDir::new().unwrap();
636 fs::write(
637 dir.path().join("drft.toml"),
638 "[parsers]\nmarkdown = [\"frontmatter\", \"wikilink\"]\n",
639 )
640 .unwrap();
641 let config = Config::load(dir.path()).unwrap();
642 let p = &config.parsers["markdown"];
643 let opts = p.options.as_ref().unwrap();
645 let types = opts.get("types").unwrap().as_array().unwrap();
646 assert_eq!(types.len(), 2);
647 assert_eq!(types[0].as_str().unwrap(), "frontmatter");
648 assert_eq!(types[1].as_str().unwrap(), "wikilink");
649 }
650
651 #[test]
652 fn loads_parser_table_with_files() {
653 let dir = TempDir::new().unwrap();
654 fs::write(
655 dir.path().join("drft.toml"),
656 "[parsers.tsx]\nfiles = [\"*.tsx\", \"*.ts\"]\ncommand = \"./parse.sh\"\ntimeout = 10000\n",
657 )
658 .unwrap();
659 let config = Config::load(dir.path()).unwrap();
660 let p = &config.parsers["tsx"];
661 assert_eq!(
662 p.files.as_deref(),
663 Some(&["*.tsx".to_string(), "*.ts".to_string()][..])
664 );
665 assert_eq!(p.command.as_deref(), Some("./parse.sh"));
666 assert_eq!(p.timeout, Some(10000));
667 }
668
669 #[test]
670 fn loads_parser_glob_migrates_to_files() {
671 let dir = TempDir::new().unwrap();
672 fs::write(
673 dir.path().join("drft.toml"),
674 "[parsers.tsx]\nglob = \"*.tsx\"\ncommand = \"./parse.sh\"\n",
675 )
676 .unwrap();
677 let config = Config::load(dir.path()).unwrap();
678 let p = &config.parsers["tsx"];
679 assert_eq!(p.files.as_deref(), Some(&["*.tsx".to_string()][..]));
680 }
681
682 #[test]
683 fn loads_parser_options() {
684 let dir = TempDir::new().unwrap();
685 fs::write(
686 dir.path().join("drft.toml"),
687 "[parsers.markdown]\nfiles = [\"*.md\"]\n\n[parsers.markdown.options]\ntypes = [\"inline\"]\nextract_metadata = true\n",
688 )
689 .unwrap();
690 let config = Config::load(dir.path()).unwrap();
691 let p = &config.parsers["markdown"];
692 let opts = p.options.as_ref().unwrap();
693 assert!(opts.get("types").is_some());
694 assert_eq!(opts.get("extract_metadata").unwrap().as_bool(), Some(true));
695 }
696
697 #[test]
698 fn parser_false_disables() {
699 let dir = TempDir::new().unwrap();
700 fs::write(
701 dir.path().join("drft.toml"),
702 "[parsers]\nmarkdown = false\n",
703 )
704 .unwrap();
705 let config = Config::load(dir.path()).unwrap();
706 assert!(!config.parsers.contains_key("markdown"));
707 }
708
709 #[test]
710 fn loads_interface() {
711 let dir = TempDir::new().unwrap();
712 fs::write(
713 dir.path().join("drft.toml"),
714 "[interface]\nfiles = [\"overview.md\", \"api/*.md\"]\n",
715 )
716 .unwrap();
717 let config = Config::load(dir.path()).unwrap();
718 let iface = config.interface.unwrap();
719 assert_eq!(iface.files, vec!["overview.md", "api/*.md"]);
720 }
721
722 #[test]
723 fn loads_custom_rule() {
724 let dir = TempDir::new().unwrap();
725 fs::write(
726 dir.path().join("drft.toml"),
727 "[rules.my-check]\ncommand = \"./check.sh\"\nseverity = \"warn\"\n",
728 )
729 .unwrap();
730 let config = Config::load(dir.path()).unwrap();
731 let custom_rules: Vec<_> = config.custom_rules().collect();
732 assert_eq!(custom_rules.len(), 1);
733 assert_eq!(custom_rules[0].0, "my-check");
734 assert_eq!(custom_rules[0].1.command.as_deref(), Some("./check.sh"));
735 }
736
737 #[test]
738 fn loads_include_exclude() {
739 let dir = TempDir::new().unwrap();
740 fs::write(
741 dir.path().join("drft.toml"),
742 "include = [\"*.md\", \"*.yaml\"]\nexclude = [\"drafts/*\"]\n",
743 )
744 .unwrap();
745 let config = Config::load(dir.path()).unwrap();
746 assert_eq!(config.include, vec!["*.md", "*.yaml"]);
747 assert_eq!(config.exclude, vec!["drafts/*"]);
748 }
749
750 #[test]
751 fn ignore_migrates_to_exclude() {
752 let dir = TempDir::new().unwrap();
753 fs::write(dir.path().join("drft.toml"), "ignore = [\"drafts/*\"]\n").unwrap();
754 let config = Config::load(dir.path()).unwrap();
755 assert_eq!(config.exclude, vec!["drafts/*"]);
756 }
757
758 #[test]
759 fn ignore_and_exclude_conflicts() {
760 let dir = TempDir::new().unwrap();
761 fs::write(
762 dir.path().join("drft.toml"),
763 "ignore = [\"a/*\"]\nexclude = [\"b/*\"]\n",
764 )
765 .unwrap();
766 assert!(Config::load(dir.path()).is_err());
767 }
768
769 #[test]
770 fn invalid_toml_returns_error() {
771 let dir = TempDir::new().unwrap();
772 fs::write(dir.path().join("drft.toml"), "not valid toml {{{{").unwrap();
773 assert!(Config::load(dir.path()).is_err());
774 }
775
776 #[test]
777 fn child_without_config_errors() {
778 let dir = TempDir::new().unwrap();
779 fs::write(
780 dir.path().join("drft.toml"),
781 "[rules]\norphan-node = \"error\"\n",
782 )
783 .unwrap();
784
785 let child = dir.path().join("child");
786 fs::create_dir(&child).unwrap();
787
788 let result = Config::load(&child);
789 assert!(result.is_err());
790 }
791}