1use anyhow::{Context, Result};
2use globset::{GlobBuilder, GlobSet, GlobSetBuilder};
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5use std::path::Path;
6
7pub 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#[derive(Debug, Clone, Serialize)]
36pub struct ParserConfig {
37 #[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 #[serde(skip_serializing_if = "Option::is_none")]
46 pub options: Option<toml::Value>,
47}
48
49#[derive(Debug, Deserialize)]
51#[serde(untagged)]
52enum RawParserValue {
53 Bool(bool),
55 Types(Vec<String>),
57 Table {
59 files: Option<Vec<String>>,
60 command: Option<String>,
61 timeout: Option<u64>,
62 options: Option<toml::Value>,
63 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 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 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 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, }
128 } else {
129 options
130 };
131
132 Some(ParserConfig {
133 files,
134 command,
135 timeout,
136 options,
137 })
138 }
139 }
140 }
141}
142
143#[derive(Debug, Clone)]
148pub struct RuleConfig {
149 pub severity: RuleSeverity,
150 pub files: Vec<String>,
152 pub ignore: Vec<String>,
154 pub parsers: Vec<String>,
156 pub command: Option<String>,
157 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 let mut len = 1; 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, }
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#[derive(Debug, Deserialize)]
248#[serde(untagged)]
249enum RawRuleValue {
250 Severity(RuleSeverity),
252 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#[derive(Debug, Clone, Serialize)]
274pub struct Config {
275 pub include: Vec<String>,
278 #[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 #[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 ignore: Option<Vec<String>>,
298 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
306const 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}