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)]
21pub struct ParserConfig {
22 pub glob: Option<String>,
23 pub types: Option<Vec<String>>,
24 pub command: Option<String>,
25 pub timeout: Option<u64>,
26}
27
28#[derive(Debug, Deserialize)]
30#[serde(untagged)]
31enum RawParserValue {
32 Bool(bool),
34 Types(Vec<String>),
36 Table {
38 glob: Option<String>,
39 types: Option<Vec<String>>,
40 command: Option<String>,
41 timeout: Option<u64>,
42 },
43}
44
45impl From<RawParserValue> for Option<ParserConfig> {
46 fn from(val: RawParserValue) -> Self {
47 match val {
48 RawParserValue::Bool(false) => None,
49 RawParserValue::Bool(true) => Some(ParserConfig {
50 glob: None,
51 types: None,
52 command: None,
53 timeout: None,
54 }),
55 RawParserValue::Types(types) => Some(ParserConfig {
56 glob: None,
57 types: Some(types),
58 command: None,
59 timeout: None,
60 }),
61 RawParserValue::Table {
62 glob,
63 types,
64 command,
65 timeout,
66 } => Some(ParserConfig {
67 glob,
68 types,
69 command,
70 timeout,
71 }),
72 }
73 }
74}
75
76#[derive(Debug, Clone, Deserialize)]
79pub struct InterfaceConfig {
80 pub nodes: Vec<String>,
81}
82
83#[derive(Debug, Clone)]
88pub struct RuleConfig {
89 pub severity: RuleSeverity,
90 #[allow(dead_code)]
91 pub ignore: Vec<String>,
92 pub command: Option<String>,
93 #[allow(dead_code)]
94 pub timeout: Option<u64>,
95 pub(crate) ignore_compiled: Option<GlobSet>,
96}
97
98impl RuleConfig {
99 pub fn is_path_ignored(&self, path: &str) -> bool {
100 if let Some(ref glob_set) = self.ignore_compiled {
101 glob_set.is_match(path)
102 } else {
103 false
104 }
105 }
106}
107
108#[derive(Debug, Deserialize)]
110#[serde(untagged)]
111enum RawRuleValue {
112 Severity(RuleSeverity),
114 Table {
116 #[serde(default = "default_warn")]
117 severity: RuleSeverity,
118 #[serde(default)]
119 ignore: Vec<String>,
120 command: Option<String>,
121 timeout: Option<u64>,
122 },
123}
124
125fn default_warn() -> RuleSeverity {
126 RuleSeverity::Warn
127}
128
129#[derive(Debug, Clone)]
132pub struct Config {
133 pub ignore: Vec<String>,
134 pub interface: Option<InterfaceConfig>,
135 pub parsers: HashMap<String, ParserConfig>,
136 pub rules: HashMap<String, RuleConfig>,
137 pub config_dir: Option<std::path::PathBuf>,
139}
140
141#[derive(Debug, Deserialize)]
142#[serde(rename_all = "kebab-case")]
143struct RawConfig {
144 ignore: Option<Vec<String>>,
145 interface: Option<InterfaceConfig>,
146 parsers: Option<HashMap<String, RawParserValue>>,
147 rules: Option<HashMap<String, RawRuleValue>>,
148 manifest: Option<toml::Value>,
150 custom_rules: Option<toml::Value>,
151 custom_analyses: Option<toml::Value>,
152 custom_metrics: Option<toml::Value>,
153 ignore_rules: Option<toml::Value>,
154}
155
156const BUILTIN_RULES: &[&str] = &[
158 "broken-link",
159 "containment",
160 "cycle",
161 "directory-link",
162 "encapsulation",
163 "fragility",
164 "fragmentation",
165 "indirect-link",
166 "layer-violation",
167 "orphan",
168 "redundant-edge",
169 "stale",
170];
171
172impl Config {
173 pub fn defaults() -> Self {
174 let mut parsers = HashMap::new();
176 parsers.insert(
177 "markdown".to_string(),
178 ParserConfig {
179 glob: None,
180 types: None,
181 command: None,
182 timeout: None,
183 },
184 );
185
186 let rules = [
187 ("broken-link", RuleSeverity::Warn),
188 ("containment", RuleSeverity::Warn),
189 ("cycle", RuleSeverity::Warn),
190 ("directory-link", RuleSeverity::Warn),
191 ("encapsulation", RuleSeverity::Warn),
192 ("fragility", RuleSeverity::Off),
193 ("fragmentation", RuleSeverity::Off),
194 ("indirect-link", RuleSeverity::Off),
195 ("layer-violation", RuleSeverity::Off),
196 ("orphan", RuleSeverity::Off),
197 ("redundant-edge", RuleSeverity::Off),
198 ("stale", RuleSeverity::Error),
199 ]
200 .into_iter()
201 .map(|(k, v)| {
202 (
203 k.to_string(),
204 RuleConfig {
205 severity: v,
206 ignore: Vec::new(),
207 command: None,
208 timeout: None,
209 ignore_compiled: None,
210 },
211 )
212 })
213 .collect();
214
215 Config {
216 ignore: Vec::new(),
217 interface: None,
218 parsers,
219 rules,
220 config_dir: None,
221 }
222 }
223
224 pub fn load(root: &Path) -> Result<Self> {
225 let config_path = Self::find_config(root);
226 let config_path = match config_path {
227 Some(p) => p,
228 None => return Ok(Self::defaults()),
229 };
230
231 let content = std::fs::read_to_string(&config_path)
232 .with_context(|| format!("failed to read {}", config_path.display()))?;
233
234 let raw: RawConfig = toml::from_str(&content)
235 .with_context(|| format!("failed to parse {}", config_path.display()))?;
236
237 if raw.manifest.is_some() {
239 eprintln!("warn: drft.toml uses v0.2 'manifest' key — migrate to [interface] section");
240 }
241 if raw.custom_rules.is_some() {
242 eprintln!(
243 "warn: drft.toml uses v0.2 [custom-rules] — migrate to [rules] with 'command' field"
244 );
245 }
246 if raw.custom_analyses.is_some() {
247 eprintln!(
248 "warn: drft.toml uses v0.2 [custom-analyses] — custom analyses are no longer supported"
249 );
250 }
251 if raw.custom_metrics.is_some() {
252 eprintln!(
253 "warn: drft.toml uses v0.2 [custom-metrics] — custom metrics are no longer supported"
254 );
255 }
256 if raw.ignore_rules.is_some() {
257 eprintln!(
258 "warn: drft.toml uses v0.2 [ignore-rules] — migrate to per-rule 'ignore' field"
259 );
260 }
261
262 let mut config = Self::defaults();
263 config.config_dir = config_path.parent().map(|p| p.to_path_buf());
264
265 if let Some(ignore) = raw.ignore {
266 config.ignore = ignore;
267 }
268
269 config.interface = raw.interface;
270
271 if let Some(raw_parsers) = raw.parsers {
273 config.parsers.clear();
274 for (name, value) in raw_parsers {
275 if let Some(parser_config) = Option::<ParserConfig>::from(value) {
276 config.parsers.insert(name, parser_config);
277 }
278 }
279 }
280
281 if let Some(raw_rules) = raw.rules {
283 for (name, value) in raw_rules {
284 let rule_config = match value {
285 RawRuleValue::Severity(severity) => RuleConfig {
286 severity,
287 ignore: Vec::new(),
288 command: None,
289 timeout: None,
290 ignore_compiled: None,
291 },
292 RawRuleValue::Table {
293 severity,
294 ignore,
295 command,
296 timeout,
297 } => {
298 let compiled = if ignore.is_empty() {
299 None
300 } else {
301 let mut builder = GlobSetBuilder::new();
302 for pattern in &ignore {
303 builder.add(Glob::new(pattern).with_context(|| {
304 format!("invalid glob in rules.{name}.ignore")
305 })?);
306 }
307 Some(builder.build().with_context(|| {
308 format!("failed to compile globs for rules.{name}.ignore")
309 })?)
310 };
311 RuleConfig {
312 severity,
313 ignore,
314 command,
315 timeout,
316 ignore_compiled: compiled,
317 }
318 }
319 };
320
321 if rule_config.command.is_none() && !BUILTIN_RULES.contains(&name.as_str()) {
323 eprintln!("warn: unknown rule \"{name}\" in drft.toml (ignored)");
324 }
325
326 config.rules.insert(name, rule_config);
327 }
328 }
329
330 Ok(config)
331 }
332
333 fn find_config(root: &Path) -> Option<std::path::PathBuf> {
335 let mut current = root.to_path_buf();
336 loop {
337 let candidate = current.join("drft.toml");
338 if candidate.exists() {
339 return Some(candidate);
340 }
341 if !current.pop() {
342 return None;
343 }
344 }
345 }
346
347 pub fn rule_severity(&self, name: &str) -> RuleSeverity {
348 self.rules
349 .get(name)
350 .map(|r| r.severity)
351 .unwrap_or(RuleSeverity::Off)
352 }
353
354 pub fn is_rule_ignored(&self, rule: &str, path: &str) -> bool {
356 self.rules
357 .get(rule)
358 .is_some_and(|r| r.is_path_ignored(path))
359 }
360
361 pub fn script_rules(&self) -> impl Iterator<Item = (&str, &RuleConfig)> {
363 self.rules
364 .iter()
365 .filter(|(_, r)| r.command.is_some())
366 .map(|(name, config)| (name.as_str(), config))
367 }
368}
369
370#[cfg(test)]
371mod tests {
372 use super::*;
373 use std::fs;
374 use tempfile::TempDir;
375
376 #[test]
377 fn defaults_when_no_config() {
378 let dir = TempDir::new().unwrap();
379 let config = Config::load(dir.path()).unwrap();
380 assert_eq!(config.rule_severity("broken-link"), RuleSeverity::Warn);
381 assert_eq!(config.rule_severity("orphan"), RuleSeverity::Off);
382 assert!(config.ignore.is_empty());
383 assert!(config.parsers.contains_key("markdown"));
384 }
385
386 #[test]
387 fn loads_rule_severities() {
388 let dir = TempDir::new().unwrap();
389 fs::write(
390 dir.path().join("drft.toml"),
391 "[rules]\nbroken-link = \"error\"\norphan = \"warn\"\n",
392 )
393 .unwrap();
394 let config = Config::load(dir.path()).unwrap();
395 assert_eq!(config.rule_severity("broken-link"), RuleSeverity::Error);
396 assert_eq!(config.rule_severity("orphan"), RuleSeverity::Warn);
397 assert_eq!(config.rule_severity("cycle"), RuleSeverity::Warn);
398 }
399
400 #[test]
401 fn loads_rule_with_ignore() {
402 let dir = TempDir::new().unwrap();
403 fs::write(
404 dir.path().join("drft.toml"),
405 "[rules.orphan]\nseverity = \"warn\"\nignore = [\"README.md\", \"index.md\"]\n",
406 )
407 .unwrap();
408 let config = Config::load(dir.path()).unwrap();
409 assert_eq!(config.rule_severity("orphan"), RuleSeverity::Warn);
410 assert!(config.is_rule_ignored("orphan", "README.md"));
411 assert!(config.is_rule_ignored("orphan", "index.md"));
412 assert!(!config.is_rule_ignored("orphan", "other.md"));
413 assert!(!config.is_rule_ignored("broken-link", "README.md"));
414 }
415
416 #[test]
417 fn loads_parser_shorthand_bool() {
418 let dir = TempDir::new().unwrap();
419 fs::write(dir.path().join("drft.toml"), "[parsers]\nmarkdown = true\n").unwrap();
420 let config = Config::load(dir.path()).unwrap();
421 assert!(config.parsers.contains_key("markdown"));
422 let p = &config.parsers["markdown"];
423 assert!(p.glob.is_none());
424 assert!(p.types.is_none());
425 assert!(p.command.is_none());
426 }
427
428 #[test]
429 fn loads_parser_shorthand_types() {
430 let dir = TempDir::new().unwrap();
431 fs::write(
432 dir.path().join("drft.toml"),
433 "[parsers]\nmarkdown = [\"frontmatter\", \"wikilink\"]\n",
434 )
435 .unwrap();
436 let config = Config::load(dir.path()).unwrap();
437 let p = &config.parsers["markdown"];
438 assert_eq!(
439 p.types.as_deref(),
440 Some(vec!["frontmatter".to_string(), "wikilink".to_string()]).as_deref()
441 );
442 }
443
444 #[test]
445 fn loads_parser_table() {
446 let dir = TempDir::new().unwrap();
447 fs::write(
448 dir.path().join("drft.toml"),
449 "[parsers.tsx]\nglob = \"*.tsx\"\ncommand = \"./parse.sh\"\ntimeout = 10000\n",
450 )
451 .unwrap();
452 let config = Config::load(dir.path()).unwrap();
453 let p = &config.parsers["tsx"];
454 assert_eq!(p.glob.as_deref(), Some("*.tsx"));
455 assert_eq!(p.command.as_deref(), Some("./parse.sh"));
456 assert_eq!(p.timeout, Some(10000));
457 }
458
459 #[test]
460 fn parser_false_disables() {
461 let dir = TempDir::new().unwrap();
462 fs::write(
463 dir.path().join("drft.toml"),
464 "[parsers]\nmarkdown = false\n",
465 )
466 .unwrap();
467 let config = Config::load(dir.path()).unwrap();
468 assert!(!config.parsers.contains_key("markdown"));
469 }
470
471 #[test]
472 fn loads_interface() {
473 let dir = TempDir::new().unwrap();
474 fs::write(
475 dir.path().join("drft.toml"),
476 "[interface]\nnodes = [\"overview.md\", \"api/*.md\"]\n",
477 )
478 .unwrap();
479 let config = Config::load(dir.path()).unwrap();
480 let iface = config.interface.unwrap();
481 assert_eq!(iface.nodes, vec!["overview.md", "api/*.md"]);
482 }
483
484 #[test]
485 fn loads_script_rule() {
486 let dir = TempDir::new().unwrap();
487 fs::write(
488 dir.path().join("drft.toml"),
489 "[rules.my-check]\ncommand = \"./check.sh\"\nseverity = \"warn\"\n",
490 )
491 .unwrap();
492 let config = Config::load(dir.path()).unwrap();
493 let script_rules: Vec<_> = config.script_rules().collect();
494 assert_eq!(script_rules.len(), 1);
495 assert_eq!(script_rules[0].0, "my-check");
496 assert_eq!(script_rules[0].1.command.as_deref(), Some("./check.sh"));
497 }
498
499 #[test]
500 fn invalid_toml_returns_error() {
501 let dir = TempDir::new().unwrap();
502 fs::write(dir.path().join("drft.toml"), "not valid toml {{{{").unwrap();
503 assert!(Config::load(dir.path()).is_err());
504 }
505
506 #[test]
507 fn inherits_config_from_parent() {
508 let dir = TempDir::new().unwrap();
509 fs::write(
510 dir.path().join("drft.toml"),
511 "[rules]\norphan = \"error\"\n",
512 )
513 .unwrap();
514
515 let child = dir.path().join("child");
516 fs::create_dir(&child).unwrap();
517
518 let config = Config::load(&child).unwrap();
519 assert_eq!(config.rule_severity("orphan"), RuleSeverity::Error);
520 }
521
522 #[test]
523 fn child_config_overrides_parent() {
524 let dir = TempDir::new().unwrap();
525 fs::write(
526 dir.path().join("drft.toml"),
527 "[rules]\norphan = \"error\"\n",
528 )
529 .unwrap();
530
531 let child = dir.path().join("child");
532 fs::create_dir(&child).unwrap();
533 fs::write(child.join("drft.toml"), "[rules]\norphan = \"off\"\n").unwrap();
534
535 let config = Config::load(&child).unwrap();
536 assert_eq!(config.rule_severity("orphan"), RuleSeverity::Off);
537 }
538}