1use anyhow::{Context, Result};
2use globset::{GlobBuilder, GlobSet, GlobSetBuilder};
3use serde::Deserialize;
4use std::collections::{BTreeMap, 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)]
35pub struct GraphConfig {
36 pub files: Vec<String>,
37 pub parser: String,
38 pub keys: Option<Vec<String>>,
41}
42
43#[derive(Debug, Deserialize)]
47#[serde(deny_unknown_fields)]
48struct RawGraph {
49 files: Option<Vec<String>>,
50 parser: String,
51 keys: Option<Vec<String>>,
52}
53
54#[derive(Debug, Clone)]
59pub struct RuleConfig {
60 pub severity: RuleSeverity,
61 ignore_compiled: Option<GlobSet>,
62}
63
64impl RuleConfig {
65 fn new(severity: RuleSeverity, ignore: Vec<String>) -> Result<Self> {
66 let ignore_compiled = compile_globs(&ignore).context("failed to compile ignore globs")?;
67 Ok(Self {
68 severity,
69 ignore_compiled,
70 })
71 }
72
73 pub fn is_path_ignored(&self, path: &str) -> bool {
74 self.ignore_compiled
75 .as_ref()
76 .is_some_and(|set| set.is_match(path))
77 }
78}
79
80#[derive(Debug, Deserialize)]
88#[serde(untagged)]
89enum RawRuleValue {
90 Severity(RuleSeverity),
91 Table {
92 #[serde(default = "default_warn")]
93 severity: RuleSeverity,
94 #[serde(default)]
95 ignore: Vec<String>,
96 #[serde(flatten)]
97 unknown: BTreeMap<String, toml::Value>,
98 },
99}
100
101const RULE_TABLE_FIELDS: &str = "`severity` or `ignore`";
103
104fn default_warn() -> RuleSeverity {
105 RuleSeverity::Warn
106}
107
108#[derive(Debug, Deserialize, Default)]
112struct RawRules {
113 #[serde(default)]
114 ignore: Vec<String>,
115 #[serde(flatten)]
116 rules: HashMap<String, RawRuleValue>,
117}
118
119#[derive(Debug, Clone)]
122pub struct Config {
123 pub ignore: Vec<String>,
126 pub graphs: BTreeMap<String, GraphConfig>,
128 pub rules: HashMap<String, RuleConfig>,
129 rule_ignore: Option<GlobSet>,
133 pub config_dir: Option<std::path::PathBuf>,
135}
136
137#[derive(Debug, Deserialize)]
138#[serde(rename_all = "kebab-case", deny_unknown_fields)]
139struct RawConfig {
140 ignore: Option<Vec<String>>,
141 graphs: Option<HashMap<String, RawGraph>>,
142 rules: Option<RawRules>,
143}
144
145const BUILTIN_RULES: &[&str] = &[
147 "stale-node",
148 "stale-edge",
149 "new-edge",
150 "removed-edge",
151 "removed-node",
152 "unresolved-edge",
153 "detached-node",
154];
155
156const KNOWN_PARSERS: &[&str] = &["markdown", "frontmatter"];
159
160const PARSERS_WITH_KEYS: &[&str] = &["frontmatter"];
162
163const RESERVED_GRAPH_NAMES: &[&str] = &["fs"];
166
167const DEFAULT_FILES: &str = "**/*.md";
169
170impl Config {
171 pub fn defaults() -> Self {
174 Config {
175 ignore: Vec::new(),
176 graphs: BTreeMap::new(),
177 rules: HashMap::new(),
178 rule_ignore: None,
179 config_dir: None,
180 }
181 }
182
183 pub fn load(root: &Path) -> Result<Self> {
184 let config_path = match Self::find_config(root) {
185 Some(p) => p,
186 None => anyhow::bail!("no drft.toml found (run `drft init` to create one)"),
187 };
188
189 let content = std::fs::read_to_string(&config_path)
190 .with_context(|| format!("failed to read {}", config_path.display()))?;
191 let raw: RawConfig = toml::from_str(&content)
192 .with_context(|| format!("failed to parse {}", config_path.display()))?;
193
194 let mut config = Self::defaults();
195 config.config_dir = config_path.parent().map(|p| p.to_path_buf());
196
197 if let Some(ignore) = raw.ignore {
198 config.ignore = ignore;
199 }
200
201 if let Some(raw_graphs) = raw.graphs {
203 for (name, raw) in raw_graphs {
204 crate::model::validate_label(&name)
205 .map_err(|e| anyhow::anyhow!("invalid graph name in drft.toml: {e}"))?;
206 if RESERVED_GRAPH_NAMES.contains(&name.as_str()) {
207 anyhow::bail!("graph name \"{name}\" is reserved (the implicit base graph)");
208 }
209 if !KNOWN_PARSERS.contains(&raw.parser.as_str()) {
210 anyhow::bail!(
211 "unknown parser \"{}\" for graph \"{name}\" (known: {})",
212 raw.parser,
213 KNOWN_PARSERS.join(", ")
214 );
215 }
216 if raw.keys.is_some() && !PARSERS_WITH_KEYS.contains(&raw.parser.as_str()) {
220 anyhow::bail!(
221 "`keys` is not supported by the \"{}\" parser in graph \"{name}\" (supported: {})",
222 raw.parser,
223 PARSERS_WITH_KEYS.join(", ")
224 );
225 }
226 if raw.keys.as_ref().is_some_and(Vec::is_empty) {
227 anyhow::bail!(
228 "`keys` is empty in graph \"{name}\" — the graph would track nothing (omit it for shape detection)"
229 );
230 }
231 config.graphs.insert(
232 name,
233 GraphConfig {
234 files: raw.files.unwrap_or_else(|| vec![DEFAULT_FILES.to_string()]),
235 parser: raw.parser,
236 keys: raw.keys,
237 },
238 );
239 }
240 }
241
242 if let Some(raw_rules) = raw.rules {
243 config.rule_ignore = compile_globs(&raw_rules.ignore)
246 .context("failed to compile [rules].ignore globs")?;
247 for (name, value) in raw_rules.rules {
248 let rule_config = match value {
249 RawRuleValue::Severity(severity) => RuleConfig::new(severity, Vec::new())?,
250 RawRuleValue::Table {
251 severity,
252 ignore,
253 unknown,
254 } => {
255 if let Some(key) = unknown.keys().next() {
258 anyhow::bail!(
259 "failed to parse {}: unknown field `{key}` in rules.{name}, expected {RULE_TABLE_FIELDS}",
260 config_path.display()
261 );
262 }
263 RuleConfig::new(severity, ignore)
264 .with_context(|| format!("invalid globs in rules.{name}"))?
265 }
266 };
267 if !BUILTIN_RULES.contains(&name.as_str()) {
268 eprintln!("warn: unknown rule \"{name}\" in drft.toml (ignored)");
269 }
270 config.rules.insert(name, rule_config);
271 }
272 }
273
274 Ok(config)
275 }
276
277 fn find_config(root: &Path) -> Option<std::path::PathBuf> {
280 let candidate = root.join("drft.toml");
281 candidate.exists().then_some(candidate)
282 }
283
284 pub fn ignore_patterns(&self) -> &[String] {
286 &self.ignore
287 }
288
289 pub fn is_rule_ignored(&self, rule: &str, path: &str) -> bool {
291 self.rule_ignore
292 .as_ref()
293 .is_some_and(|set| set.is_match(path))
294 || self
295 .rules
296 .get(rule)
297 .is_some_and(|r| r.is_path_ignored(path))
298 }
299}
300
301#[cfg(test)]
302mod tests {
303 use super::*;
304 use std::fs;
305 use tempfile::TempDir;
306
307 #[test]
308 fn errors_when_no_config() {
309 let dir = TempDir::new().unwrap();
310 let result = Config::load(dir.path());
311 assert!(result.is_err());
312 assert!(
313 result
314 .unwrap_err()
315 .to_string()
316 .contains("no drft.toml found")
317 );
318 }
319
320 #[test]
321 fn defaults_have_no_graphs() {
322 assert!(Config::defaults().graphs.is_empty());
324 }
325
326 #[test]
327 fn loads_ignore() {
328 let dir = TempDir::new().unwrap();
329 fs::write(dir.path().join("drft.toml"), "ignore = [\"target/**\"]\n").unwrap();
330 let config = Config::load(dir.path()).unwrap();
331 assert_eq!(config.ignore, vec!["target/**"]);
332 }
333
334 #[test]
335 fn declares_graphs() {
336 let dir = TempDir::new().unwrap();
337 fs::write(
338 dir.path().join("drft.toml"),
339 "[graphs.docs]\nparser = \"markdown\"\nfiles = [\"docs/**/*.md\"]\n",
340 )
341 .unwrap();
342 let config = Config::load(dir.path()).unwrap();
343 assert_eq!(config.graphs.len(), 1);
344 assert_eq!(config.graphs["docs"].parser, "markdown");
345 assert_eq!(config.graphs["docs"].files, vec!["docs/**/*.md"]);
346 }
347
348 #[test]
349 fn files_defaults_to_markdown_when_omitted() {
350 let dir = TempDir::new().unwrap();
351 fs::write(
352 dir.path().join("drft.toml"),
353 "[graphs.markdown]\nparser = \"markdown\"\n",
354 )
355 .unwrap();
356 let config = Config::load(dir.path()).unwrap();
357 assert_eq!(config.graphs["markdown"].files, vec!["**/*.md"]);
358 }
359
360 #[test]
361 fn unknown_parser_errors() {
362 let dir = TempDir::new().unwrap();
363 fs::write(
364 dir.path().join("drft.toml"),
365 "[graphs.x]\nparser = \"markdwn\"\n",
366 )
367 .unwrap();
368 let err = Config::load(dir.path()).unwrap_err().to_string();
369 assert!(err.contains("unknown parser"), "got: {err}");
370 }
371
372 #[test]
373 fn parser_fs_value_errors() {
374 let dir = TempDir::new().unwrap();
376 fs::write(
377 dir.path().join("drft.toml"),
378 "[graphs.x]\nparser = \"fs\"\n",
379 )
380 .unwrap();
381 assert!(Config::load(dir.path()).is_err());
382 }
383
384 #[test]
385 fn reserved_graph_name_fs_errors() {
386 let dir = TempDir::new().unwrap();
388 fs::write(
389 dir.path().join("drft.toml"),
390 "[graphs.fs]\nparser = \"markdown\"\n",
391 )
392 .unwrap();
393 let err = Config::load(dir.path()).unwrap_err().to_string();
394 assert!(err.contains("reserved"), "got: {err}");
395 }
396
397 #[test]
398 fn invalid_graph_name_errors() {
399 let dir = TempDir::new().unwrap();
401 fs::write(
402 dir.path().join("drft.toml"),
403 "[graphs._internal]\nparser = \"markdown\"\n",
404 )
405 .unwrap();
406 assert!(Config::load(dir.path()).is_err());
407 }
408
409 #[test]
410 fn loads_rule_severity_and_ignore() {
411 let dir = TempDir::new().unwrap();
412 fs::write(
413 dir.path().join("drft.toml"),
414 "[rules]\nstale-node = \"error\"\n\n[rules.detached-node]\nignore = [\"README.md\"]\n",
415 )
416 .unwrap();
417 let config = Config::load(dir.path()).unwrap();
418 assert_eq!(config.rules["stale-node"].severity, RuleSeverity::Error);
419 assert!(config.is_rule_ignored("detached-node", "README.md"));
420 assert!(!config.is_rule_ignored("detached-node", "other.md"));
421 }
422
423 #[test]
424 fn global_rule_ignore_applies_to_every_rule() {
425 let dir = TempDir::new().unwrap();
426 fs::write(
427 dir.path().join("drft.toml"),
428 "[rules]\nignore = [\"vendor/**\"]\n\n[rules.stale-node]\nseverity = \"error\"\n",
429 )
430 .unwrap();
431 let config = Config::load(dir.path()).unwrap();
432 assert_eq!(config.rules["stale-node"].severity, RuleSeverity::Error);
434 assert!(config.is_rule_ignored("stale-node", "vendor/x.md"));
436 assert!(config.is_rule_ignored("unresolved-edge", "vendor/x.md"));
437 assert!(!config.is_rule_ignored("stale-node", "yours.md"));
439 }
440
441 #[test]
442 fn unknown_graph_key_errors() {
443 let dir = TempDir::new().unwrap();
447 fs::write(
448 dir.path().join("drft.toml"),
449 "[graphs.x]\nparser = \"frontmatter\"\ninclude_keys = [\"sources\"]\n",
450 )
451 .unwrap();
452 let err = format!("{:#}", Config::load(dir.path()).unwrap_err());
453 assert!(err.contains("unknown field `include_keys`"), "got: {err}");
454 assert!(err.contains("files"), "expected set not named: {err}");
455 }
456
457 #[test]
458 fn frontmatter_graph_accepts_keys() {
459 let dir = TempDir::new().unwrap();
460 fs::write(
461 dir.path().join("drft.toml"),
462 "[graphs.fm]\nparser = \"frontmatter\"\nkeys = [\"sources\"]\n",
463 )
464 .unwrap();
465 let config = Config::load(dir.path()).unwrap();
466 assert_eq!(
467 config.graphs["fm"].keys.as_deref(),
468 Some(&["sources".to_string()][..])
469 );
470 }
471
472 #[test]
473 fn keys_omitted_is_shape_detection() {
474 let dir = TempDir::new().unwrap();
475 fs::write(
476 dir.path().join("drft.toml"),
477 "[graphs.fm]\nparser = \"frontmatter\"\n",
478 )
479 .unwrap();
480 assert!(
481 Config::load(dir.path()).unwrap().graphs["fm"]
482 .keys
483 .is_none()
484 );
485 }
486
487 #[test]
488 fn keys_on_markdown_parser_errors() {
489 let dir = TempDir::new().unwrap();
492 fs::write(
493 dir.path().join("drft.toml"),
494 "[graphs.md]\nparser = \"markdown\"\nkeys = [\"sources\"]\n",
495 )
496 .unwrap();
497 let err = format!("{:#}", Config::load(dir.path()).unwrap_err());
498 assert!(
499 err.contains("not supported by the \"markdown\" parser"),
500 "got: {err}"
501 );
502 }
503
504 #[test]
505 fn empty_keys_errors() {
506 let dir = TempDir::new().unwrap();
508 fs::write(
509 dir.path().join("drft.toml"),
510 "[graphs.fm]\nparser = \"frontmatter\"\nkeys = []\n",
511 )
512 .unwrap();
513 let err = format!("{:#}", Config::load(dir.path()).unwrap_err());
514 assert!(err.contains("track nothing"), "got: {err}");
515 }
516
517 #[test]
518 fn unknown_top_level_key_errors() {
519 let dir = TempDir::new().unwrap();
520 fs::write(dir.path().join("drft.toml"), "ignores = [\"target/**\"]\n").unwrap();
521 let err = format!("{:#}", Config::load(dir.path()).unwrap_err());
522 assert!(err.contains("unknown field `ignores`"), "got: {err}");
523 }
524
525 #[test]
526 fn unknown_rule_table_key_errors() {
527 let dir = TempDir::new().unwrap();
531 fs::write(
532 dir.path().join("drft.toml"),
533 "[rules.stale-node]\nseverty = \"error\"\n",
534 )
535 .unwrap();
536 let err = format!("{:#}", Config::load(dir.path()).unwrap_err());
537 assert!(err.contains("unknown field `severty`"), "got: {err}");
538 assert!(err.contains("rules.stale-node"), "got: {err}");
539 }
540
541 #[test]
542 fn known_rule_table_keys_still_parse() {
543 let dir = TempDir::new().unwrap();
545 fs::write(
546 dir.path().join("drft.toml"),
547 "[rules.detached-node]\nseverity = \"error\"\nignore = [\"README.md\"]\n",
548 )
549 .unwrap();
550 let config = Config::load(dir.path()).unwrap();
551 assert_eq!(config.rules["detached-node"].severity, RuleSeverity::Error);
552 assert!(config.is_rule_ignored("detached-node", "README.md"));
553 }
554
555 #[test]
556 fn invalid_toml_errors() {
557 let dir = TempDir::new().unwrap();
558 fs::write(dir.path().join("drft.toml"), "not valid toml {{{{").unwrap();
559 assert!(Config::load(dir.path()).is_err());
560 }
561}