Skip to main content

drft/
config.rs

1use anyhow::{Context, Result};
2use globset::{GlobBuilder, GlobSet, GlobSetBuilder};
3use serde::Deserialize;
4use std::collections::{BTreeMap, HashMap};
5use std::path::Path;
6
7/// Compile a list of glob patterns into a GlobSet. Returns None if patterns is
8/// empty. Uses `literal_separator` so `*` matches a single path component and
9/// `**` matches across directory boundaries.
10pub 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// ── Graph config ───────────────────────────────────────────────
30
31/// A configured graph: a file scope and the parser that interprets it. v0.8
32/// ships the `markdown` and `frontmatter` parsers. `fs` is the implicit base
33/// graph (a provider, not a parser) and is not configured here.
34#[derive(Debug, Clone)]
35pub struct GraphConfig {
36    pub files: Vec<String>,
37    pub parser: String,
38    /// `frontmatter` only: the keys whose values yield edges. `None` keeps
39    /// shape detection over the whole block.
40    pub keys: Option<Vec<String>>,
41}
42
43/// `deny_unknown_fields` so a key the parser does not support is a hard error
44/// rather than a silent discard. A graph table that parses is read as a graph
45/// that works — a speculative `keys = [...]` must not exit 0 doing nothing.
46#[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// ── Rule config ────────────────────────────────────────────────
55
56/// Per-rule configuration: a severity and a list of ignore globs matched against
57/// the finding's subject path.
58#[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/// Serde helper: a rule is either a bare severity (`stale-node = "error"`) or a
81/// table (`[rules.stale-node]` with `severity` and `ignore`).
82///
83/// The table variant cannot use `deny_unknown_fields` — an untagged enum reports
84/// a rejected variant as "data did not match any variant", which names neither
85/// the bad key nor the known set. Capturing the leftovers instead lets `load`
86/// raise the same precise error the graph tables give.
87#[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
101/// Fields a `[rules.*]` table accepts, for the unknown-key error.
102const RULE_TABLE_FIELDS: &str = "`severity` or `ignore`";
103
104fn default_warn() -> RuleSeverity {
105    RuleSeverity::Warn
106}
107
108/// Serde helper for the `[rules]` table: a global `ignore` applied to every rule,
109/// plus the per-rule entries (`stale-node = "error"`, `[rules.detached-node]`, …)
110/// captured by flatten. `ignore` is therefore a reserved key under `[rules]`.
111#[derive(Debug, Deserialize, Default)]
112struct RawRules {
113    #[serde(default)]
114    ignore: Vec<String>,
115    #[serde(flatten)]
116    rules: HashMap<String, RawRuleValue>,
117}
118
119// ── Config ─────────────────────────────────────────────────────
120
121#[derive(Debug, Clone)]
122pub struct Config {
123    /// Glob patterns the `fs` walk removes from the graph (also respects
124    /// `.gitignore`).
125    pub ignore: Vec<String>,
126    /// Configured graphs, keyed by name. `fs` is implicit and always built.
127    pub graphs: BTreeMap<String, GraphConfig>,
128    pub rules: HashMap<String, RuleConfig>,
129    /// Globs from `[rules].ignore` — subjects suppressed across *every* rule
130    /// (configured or not), unioned with each rule's own `ignore`. Unlike the
131    /// top-level `ignore`, the paths stay in the graph; only findings are dropped.
132    rule_ignore: Option<GlobSet>,
133    /// Directory containing the `drft.toml` this config was loaded from.
134    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
145/// Names of all built-in rules (for unknown-rule warnings).
146const 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
156/// Parsers a graph may declare. `fs` is the implicit base graph (a provider, not
157/// a parser) and is intentionally absent — `parser = "fs"` is rejected.
158const KNOWN_PARSERS: &[&str] = &["markdown", "frontmatter"];
159
160/// Parsers that accept `keys`. Markdown has no keyed structure to scope.
161const PARSERS_WITH_KEYS: &[&str] = &["frontmatter"];
162
163/// Graph names reserved for drft's implicit graphs. Declaring one would collide
164/// with the core `@fs` namespace at compose and overwrite its `type`/`hash`.
165const RESERVED_GRAPH_NAMES: &[&str] = &["fs"];
166
167/// A graph's `files` scope defaults to markdown when omitted.
168const DEFAULT_FILES: &str = "**/*.md";
169
170impl Config {
171    /// The base config: no graphs (the `drft.toml` declares the full set), no
172    /// ignores, every rule at `warn`. `fs` is always built regardless.
173    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        // The drft.toml declares the full graph set — there are no defaults.
202        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                // `keys` scopes a keyed structure; only the frontmatter parser has
217                // one. Accepting it elsewhere would reintroduce exactly the silent
218                // no-op that made it unfindable in the first place (#71).
219                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            // The global rule-ignore applies to every rule, including ones with
244            // no explicit entry below.
245            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                        // Raised after parsing, so it carries the config path the
256                        // serde-level errors get from the `failed to parse` context.
257                        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    /// Find `drft.toml` in `root`. No directory walking — if it's not here, the
278    /// caller falls back to defaults or errors.
279    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    /// Glob patterns the `fs` walk removes from the graph.
285    pub fn ignore_patterns(&self) -> &[String] {
286        &self.ignore
287    }
288
289    /// Whether `path` is ignored for `rule`.
290    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        // No runtime defaults — the drft.toml declares the full set.
323        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        // `fs` is a provider, not a parser, so it's not a valid parser value.
375        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        // Naming a graph `fs` would clobber the implicit base graph's @fs block.
387        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        // Leading underscore is reserved.
400        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        // The flattened per-rule entry still parses alongside the global ignore.
433        assert_eq!(config.rules["stale-node"].severity, RuleSeverity::Error);
434        // Global ignore hits a configured rule and an unconfigured one alike.
435        assert!(config.is_rule_ignored("stale-node", "vendor/x.md"));
436        assert!(config.is_rule_ignored("unresolved-edge", "vendor/x.md"));
437        // It does not touch paths outside the group.
438        assert!(!config.is_rule_ignored("stale-node", "yours.md"));
439    }
440
441    #[test]
442    fn unknown_graph_key_errors() {
443        // A key the parser does not support must not parse and do nothing — the
444        // near-miss spellings (`fields`, `include_keys`) are the likely case, so
445        // the error names the key and the accepted set.
446        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        // Markdown has no keyed structure — accepting `keys` there would be the
490        // silent no-op this option exists to remove.
491        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        // `keys = []` would scope the graph to nothing — always a mistake.
507        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        // The untagged enum accepts any table (both fields default), so a typo'd
528        // key silently parsed as an all-defaults rule before the leftovers were
529        // captured. Distinct from an unknown *rule name*, which only warns.
530        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        // Guard against the flatten capture swallowing the real fields.
544        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}