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}
39
40#[derive(Debug, Deserialize)]
41struct RawGraph {
42    files: Option<Vec<String>>,
43    parser: String,
44}
45
46// ── Rule config ────────────────────────────────────────────────
47
48/// Per-rule configuration: a severity and a list of ignore globs matched against
49/// the finding's subject path.
50#[derive(Debug, Clone)]
51pub struct RuleConfig {
52    pub severity: RuleSeverity,
53    ignore_compiled: Option<GlobSet>,
54}
55
56impl RuleConfig {
57    fn new(severity: RuleSeverity, ignore: Vec<String>) -> Result<Self> {
58        let ignore_compiled = compile_globs(&ignore).context("failed to compile ignore globs")?;
59        Ok(Self {
60            severity,
61            ignore_compiled,
62        })
63    }
64
65    pub fn is_path_ignored(&self, path: &str) -> bool {
66        self.ignore_compiled
67            .as_ref()
68            .is_some_and(|set| set.is_match(path))
69    }
70}
71
72/// Serde helper: a rule is either a bare severity (`stale-node = "error"`) or a
73/// table (`[rules.stale-node]` with `severity` and `ignore`).
74#[derive(Debug, Deserialize)]
75#[serde(untagged)]
76enum RawRuleValue {
77    Severity(RuleSeverity),
78    Table {
79        #[serde(default = "default_warn")]
80        severity: RuleSeverity,
81        #[serde(default)]
82        ignore: Vec<String>,
83    },
84}
85
86fn default_warn() -> RuleSeverity {
87    RuleSeverity::Warn
88}
89
90/// Serde helper for the `[rules]` table: a global `ignore` applied to every rule,
91/// plus the per-rule entries (`stale-node = "error"`, `[rules.detached-node]`, …)
92/// captured by flatten. `ignore` is therefore a reserved key under `[rules]`.
93#[derive(Debug, Deserialize, Default)]
94struct RawRules {
95    #[serde(default)]
96    ignore: Vec<String>,
97    #[serde(flatten)]
98    rules: HashMap<String, RawRuleValue>,
99}
100
101// ── Config ─────────────────────────────────────────────────────
102
103#[derive(Debug, Clone)]
104pub struct Config {
105    /// Glob patterns the `fs` walk removes from the graph (also respects
106    /// `.gitignore`).
107    pub ignore: Vec<String>,
108    /// Configured graphs, keyed by name. `fs` is implicit and always built.
109    pub graphs: BTreeMap<String, GraphConfig>,
110    pub rules: HashMap<String, RuleConfig>,
111    /// Globs from `[rules].ignore` — subjects suppressed across *every* rule
112    /// (configured or not), unioned with each rule's own `ignore`. Unlike the
113    /// top-level `ignore`, the paths stay in the graph; only findings are dropped.
114    rule_ignore: Option<GlobSet>,
115    /// Directory containing the `drft.toml` this config was loaded from.
116    pub config_dir: Option<std::path::PathBuf>,
117}
118
119#[derive(Debug, Deserialize)]
120#[serde(rename_all = "kebab-case")]
121struct RawConfig {
122    ignore: Option<Vec<String>>,
123    graphs: Option<HashMap<String, RawGraph>>,
124    rules: Option<RawRules>,
125}
126
127/// Names of all built-in rules (for unknown-rule warnings).
128const BUILTIN_RULES: &[&str] = &[
129    "stale-node",
130    "stale-edge",
131    "new-edge",
132    "removed-edge",
133    "removed-node",
134    "unresolved-edge",
135    "detached-node",
136];
137
138/// Parsers a graph may declare. `fs` is the implicit base graph (a provider, not
139/// a parser) and is intentionally absent — `parser = "fs"` is rejected.
140const KNOWN_PARSERS: &[&str] = &["markdown", "frontmatter"];
141
142/// Graph names reserved for drft's implicit graphs. Declaring one would collide
143/// with the core `@fs` namespace at compose and overwrite its `type`/`hash`.
144const RESERVED_GRAPH_NAMES: &[&str] = &["fs"];
145
146/// A graph's `files` scope defaults to markdown when omitted.
147const DEFAULT_FILES: &str = "**/*.md";
148
149impl Config {
150    /// The base config: no graphs (the `drft.toml` declares the full set), no
151    /// ignores, every rule at `warn`. `fs` is always built regardless.
152    pub fn defaults() -> Self {
153        Config {
154            ignore: Vec::new(),
155            graphs: BTreeMap::new(),
156            rules: HashMap::new(),
157            rule_ignore: None,
158            config_dir: None,
159        }
160    }
161
162    pub fn load(root: &Path) -> Result<Self> {
163        let config_path = match Self::find_config(root) {
164            Some(p) => p,
165            None => anyhow::bail!("no drft.toml found (run `drft init` to create one)"),
166        };
167
168        let content = std::fs::read_to_string(&config_path)
169            .with_context(|| format!("failed to read {}", config_path.display()))?;
170        let raw: RawConfig = toml::from_str(&content)
171            .with_context(|| format!("failed to parse {}", config_path.display()))?;
172
173        let mut config = Self::defaults();
174        config.config_dir = config_path.parent().map(|p| p.to_path_buf());
175
176        if let Some(ignore) = raw.ignore {
177            config.ignore = ignore;
178        }
179
180        // The drft.toml declares the full graph set — there are no defaults.
181        if let Some(raw_graphs) = raw.graphs {
182            for (name, raw) in raw_graphs {
183                crate::model::validate_label(&name)
184                    .map_err(|e| anyhow::anyhow!("invalid graph name in drft.toml: {e}"))?;
185                if RESERVED_GRAPH_NAMES.contains(&name.as_str()) {
186                    anyhow::bail!("graph name \"{name}\" is reserved (the implicit base graph)");
187                }
188                if !KNOWN_PARSERS.contains(&raw.parser.as_str()) {
189                    anyhow::bail!(
190                        "unknown parser \"{}\" for graph \"{name}\" (known: {})",
191                        raw.parser,
192                        KNOWN_PARSERS.join(", ")
193                    );
194                }
195                config.graphs.insert(
196                    name,
197                    GraphConfig {
198                        files: raw.files.unwrap_or_else(|| vec![DEFAULT_FILES.to_string()]),
199                        parser: raw.parser,
200                    },
201                );
202            }
203        }
204
205        if let Some(raw_rules) = raw.rules {
206            // The global rule-ignore applies to every rule, including ones with
207            // no explicit entry below.
208            config.rule_ignore = compile_globs(&raw_rules.ignore)
209                .context("failed to compile [rules].ignore globs")?;
210            for (name, value) in raw_rules.rules {
211                let rule_config = match value {
212                    RawRuleValue::Severity(severity) => RuleConfig::new(severity, Vec::new())?,
213                    RawRuleValue::Table { severity, ignore } => {
214                        RuleConfig::new(severity, ignore)
215                            .with_context(|| format!("invalid globs in rules.{name}"))?
216                    }
217                };
218                if !BUILTIN_RULES.contains(&name.as_str()) {
219                    eprintln!("warn: unknown rule \"{name}\" in drft.toml (ignored)");
220                }
221                config.rules.insert(name, rule_config);
222            }
223        }
224
225        Ok(config)
226    }
227
228    /// Find `drft.toml` in `root`. No directory walking — if it's not here, the
229    /// caller falls back to defaults or errors.
230    fn find_config(root: &Path) -> Option<std::path::PathBuf> {
231        let candidate = root.join("drft.toml");
232        candidate.exists().then_some(candidate)
233    }
234
235    /// Glob patterns the `fs` walk removes from the graph.
236    pub fn ignore_patterns(&self) -> &[String] {
237        &self.ignore
238    }
239
240    /// Whether `path` is ignored for `rule`.
241    pub fn is_rule_ignored(&self, rule: &str, path: &str) -> bool {
242        self.rule_ignore
243            .as_ref()
244            .is_some_and(|set| set.is_match(path))
245            || self
246                .rules
247                .get(rule)
248                .is_some_and(|r| r.is_path_ignored(path))
249    }
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255    use std::fs;
256    use tempfile::TempDir;
257
258    #[test]
259    fn errors_when_no_config() {
260        let dir = TempDir::new().unwrap();
261        let result = Config::load(dir.path());
262        assert!(result.is_err());
263        assert!(
264            result
265                .unwrap_err()
266                .to_string()
267                .contains("no drft.toml found")
268        );
269    }
270
271    #[test]
272    fn defaults_have_no_graphs() {
273        // No runtime defaults — the drft.toml declares the full set.
274        assert!(Config::defaults().graphs.is_empty());
275    }
276
277    #[test]
278    fn loads_ignore() {
279        let dir = TempDir::new().unwrap();
280        fs::write(dir.path().join("drft.toml"), "ignore = [\"target/**\"]\n").unwrap();
281        let config = Config::load(dir.path()).unwrap();
282        assert_eq!(config.ignore, vec!["target/**"]);
283    }
284
285    #[test]
286    fn declares_graphs() {
287        let dir = TempDir::new().unwrap();
288        fs::write(
289            dir.path().join("drft.toml"),
290            "[graphs.docs]\nparser = \"markdown\"\nfiles = [\"docs/**/*.md\"]\n",
291        )
292        .unwrap();
293        let config = Config::load(dir.path()).unwrap();
294        assert_eq!(config.graphs.len(), 1);
295        assert_eq!(config.graphs["docs"].parser, "markdown");
296        assert_eq!(config.graphs["docs"].files, vec!["docs/**/*.md"]);
297    }
298
299    #[test]
300    fn files_defaults_to_markdown_when_omitted() {
301        let dir = TempDir::new().unwrap();
302        fs::write(
303            dir.path().join("drft.toml"),
304            "[graphs.markdown]\nparser = \"markdown\"\n",
305        )
306        .unwrap();
307        let config = Config::load(dir.path()).unwrap();
308        assert_eq!(config.graphs["markdown"].files, vec!["**/*.md"]);
309    }
310
311    #[test]
312    fn unknown_parser_errors() {
313        let dir = TempDir::new().unwrap();
314        fs::write(
315            dir.path().join("drft.toml"),
316            "[graphs.x]\nparser = \"markdwn\"\n",
317        )
318        .unwrap();
319        let err = Config::load(dir.path()).unwrap_err().to_string();
320        assert!(err.contains("unknown parser"), "got: {err}");
321    }
322
323    #[test]
324    fn parser_fs_value_errors() {
325        // `fs` is a provider, not a parser, so it's not a valid parser value.
326        let dir = TempDir::new().unwrap();
327        fs::write(
328            dir.path().join("drft.toml"),
329            "[graphs.x]\nparser = \"fs\"\n",
330        )
331        .unwrap();
332        assert!(Config::load(dir.path()).is_err());
333    }
334
335    #[test]
336    fn reserved_graph_name_fs_errors() {
337        // Naming a graph `fs` would clobber the implicit base graph's @fs block.
338        let dir = TempDir::new().unwrap();
339        fs::write(
340            dir.path().join("drft.toml"),
341            "[graphs.fs]\nparser = \"markdown\"\n",
342        )
343        .unwrap();
344        let err = Config::load(dir.path()).unwrap_err().to_string();
345        assert!(err.contains("reserved"), "got: {err}");
346    }
347
348    #[test]
349    fn invalid_graph_name_errors() {
350        // Leading underscore is reserved.
351        let dir = TempDir::new().unwrap();
352        fs::write(
353            dir.path().join("drft.toml"),
354            "[graphs._internal]\nparser = \"markdown\"\n",
355        )
356        .unwrap();
357        assert!(Config::load(dir.path()).is_err());
358    }
359
360    #[test]
361    fn loads_rule_severity_and_ignore() {
362        let dir = TempDir::new().unwrap();
363        fs::write(
364            dir.path().join("drft.toml"),
365            "[rules]\nstale-node = \"error\"\n\n[rules.detached-node]\nignore = [\"README.md\"]\n",
366        )
367        .unwrap();
368        let config = Config::load(dir.path()).unwrap();
369        assert_eq!(config.rules["stale-node"].severity, RuleSeverity::Error);
370        assert!(config.is_rule_ignored("detached-node", "README.md"));
371        assert!(!config.is_rule_ignored("detached-node", "other.md"));
372    }
373
374    #[test]
375    fn global_rule_ignore_applies_to_every_rule() {
376        let dir = TempDir::new().unwrap();
377        fs::write(
378            dir.path().join("drft.toml"),
379            "[rules]\nignore = [\"vendor/**\"]\n\n[rules.stale-node]\nseverity = \"error\"\n",
380        )
381        .unwrap();
382        let config = Config::load(dir.path()).unwrap();
383        // The flattened per-rule entry still parses alongside the global ignore.
384        assert_eq!(config.rules["stale-node"].severity, RuleSeverity::Error);
385        // Global ignore hits a configured rule and an unconfigured one alike.
386        assert!(config.is_rule_ignored("stale-node", "vendor/x.md"));
387        assert!(config.is_rule_ignored("unresolved-edge", "vendor/x.md"));
388        // It does not touch paths outside the group.
389        assert!(!config.is_rule_ignored("stale-node", "yours.md"));
390    }
391
392    #[test]
393    fn invalid_toml_errors() {
394        let dir = TempDir::new().unwrap();
395        fs::write(dir.path().join("drft.toml"), "not valid toml {{{{").unwrap();
396        assert!(Config::load(dir.path()).is_err());
397    }
398}