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// ── Config ─────────────────────────────────────────────────────
91
92#[derive(Debug, Clone)]
93pub struct Config {
94    /// Glob patterns the `fs` walk removes from the graph (also respects
95    /// `.gitignore`).
96    pub ignore: Vec<String>,
97    /// Configured graphs, keyed by name. `fs` is implicit and always built.
98    pub graphs: BTreeMap<String, GraphConfig>,
99    pub rules: HashMap<String, RuleConfig>,
100    /// Directory containing the `drft.toml` this config was loaded from.
101    pub config_dir: Option<std::path::PathBuf>,
102}
103
104#[derive(Debug, Deserialize)]
105#[serde(rename_all = "kebab-case")]
106struct RawConfig {
107    ignore: Option<Vec<String>>,
108    graphs: Option<HashMap<String, RawGraph>>,
109    rules: Option<HashMap<String, RawRuleValue>>,
110}
111
112/// Names of all built-in rules (for unknown-rule warnings).
113const BUILTIN_RULES: &[&str] = &[
114    "stale-node",
115    "stale-edge",
116    "new-edge",
117    "removed-edge",
118    "removed-node",
119    "unresolved-edge",
120    "detached-node",
121];
122
123/// Parsers a graph may declare. `fs` is the implicit base graph (a provider, not
124/// a parser) and is intentionally absent — `parser = "fs"` is rejected.
125const KNOWN_PARSERS: &[&str] = &["markdown", "frontmatter"];
126
127/// Graph names reserved for drft's implicit graphs. Declaring one would collide
128/// with the core `@fs` namespace at compose and overwrite its `type`/`hash`.
129const RESERVED_GRAPH_NAMES: &[&str] = &["fs"];
130
131/// A graph's `files` scope defaults to markdown when omitted.
132const DEFAULT_FILES: &str = "**/*.md";
133
134impl Config {
135    /// The base config: no graphs (the `drft.toml` declares the full set), no
136    /// ignores, every rule at `warn`. `fs` is always built regardless.
137    pub fn defaults() -> Self {
138        Config {
139            ignore: Vec::new(),
140            graphs: BTreeMap::new(),
141            rules: HashMap::new(),
142            config_dir: None,
143        }
144    }
145
146    pub fn load(root: &Path) -> Result<Self> {
147        let config_path = match Self::find_config(root) {
148            Some(p) => p,
149            None => anyhow::bail!("no drft.toml found (run `drft init` to create one)"),
150        };
151
152        let content = std::fs::read_to_string(&config_path)
153            .with_context(|| format!("failed to read {}", config_path.display()))?;
154        let raw: RawConfig = toml::from_str(&content)
155            .with_context(|| format!("failed to parse {}", config_path.display()))?;
156
157        let mut config = Self::defaults();
158        config.config_dir = config_path.parent().map(|p| p.to_path_buf());
159
160        if let Some(ignore) = raw.ignore {
161            config.ignore = ignore;
162        }
163
164        // The drft.toml declares the full graph set — there are no defaults.
165        if let Some(raw_graphs) = raw.graphs {
166            for (name, raw) in raw_graphs {
167                crate::model::validate_label(&name)
168                    .map_err(|e| anyhow::anyhow!("invalid graph name in drft.toml: {e}"))?;
169                if RESERVED_GRAPH_NAMES.contains(&name.as_str()) {
170                    anyhow::bail!("graph name \"{name}\" is reserved (the implicit base graph)");
171                }
172                if !KNOWN_PARSERS.contains(&raw.parser.as_str()) {
173                    anyhow::bail!(
174                        "unknown parser \"{}\" for graph \"{name}\" (known: {})",
175                        raw.parser,
176                        KNOWN_PARSERS.join(", ")
177                    );
178                }
179                config.graphs.insert(
180                    name,
181                    GraphConfig {
182                        files: raw.files.unwrap_or_else(|| vec![DEFAULT_FILES.to_string()]),
183                        parser: raw.parser,
184                    },
185                );
186            }
187        }
188
189        if let Some(raw_rules) = raw.rules {
190            for (name, value) in raw_rules {
191                let rule_config = match value {
192                    RawRuleValue::Severity(severity) => RuleConfig::new(severity, Vec::new())?,
193                    RawRuleValue::Table { severity, ignore } => {
194                        RuleConfig::new(severity, ignore)
195                            .with_context(|| format!("invalid globs in rules.{name}"))?
196                    }
197                };
198                if !BUILTIN_RULES.contains(&name.as_str()) {
199                    eprintln!("warn: unknown rule \"{name}\" in drft.toml (ignored)");
200                }
201                config.rules.insert(name, rule_config);
202            }
203        }
204
205        Ok(config)
206    }
207
208    /// Find `drft.toml` in `root`. No directory walking — if it's not here, the
209    /// caller falls back to defaults or errors.
210    fn find_config(root: &Path) -> Option<std::path::PathBuf> {
211        let candidate = root.join("drft.toml");
212        candidate.exists().then_some(candidate)
213    }
214
215    /// Glob patterns the `fs` walk removes from the graph.
216    pub fn ignore_patterns(&self) -> &[String] {
217        &self.ignore
218    }
219
220    /// Whether `path` is ignored for `rule`.
221    pub fn is_rule_ignored(&self, rule: &str, path: &str) -> bool {
222        self.rules
223            .get(rule)
224            .is_some_and(|r| r.is_path_ignored(path))
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231    use std::fs;
232    use tempfile::TempDir;
233
234    #[test]
235    fn errors_when_no_config() {
236        let dir = TempDir::new().unwrap();
237        let result = Config::load(dir.path());
238        assert!(result.is_err());
239        assert!(
240            result
241                .unwrap_err()
242                .to_string()
243                .contains("no drft.toml found")
244        );
245    }
246
247    #[test]
248    fn defaults_have_no_graphs() {
249        // No runtime defaults — the drft.toml declares the full set.
250        assert!(Config::defaults().graphs.is_empty());
251    }
252
253    #[test]
254    fn loads_ignore() {
255        let dir = TempDir::new().unwrap();
256        fs::write(dir.path().join("drft.toml"), "ignore = [\"target/**\"]\n").unwrap();
257        let config = Config::load(dir.path()).unwrap();
258        assert_eq!(config.ignore, vec!["target/**"]);
259    }
260
261    #[test]
262    fn declares_graphs() {
263        let dir = TempDir::new().unwrap();
264        fs::write(
265            dir.path().join("drft.toml"),
266            "[graphs.docs]\nparser = \"markdown\"\nfiles = [\"docs/**/*.md\"]\n",
267        )
268        .unwrap();
269        let config = Config::load(dir.path()).unwrap();
270        assert_eq!(config.graphs.len(), 1);
271        assert_eq!(config.graphs["docs"].parser, "markdown");
272        assert_eq!(config.graphs["docs"].files, vec!["docs/**/*.md"]);
273    }
274
275    #[test]
276    fn files_defaults_to_markdown_when_omitted() {
277        let dir = TempDir::new().unwrap();
278        fs::write(
279            dir.path().join("drft.toml"),
280            "[graphs.markdown]\nparser = \"markdown\"\n",
281        )
282        .unwrap();
283        let config = Config::load(dir.path()).unwrap();
284        assert_eq!(config.graphs["markdown"].files, vec!["**/*.md"]);
285    }
286
287    #[test]
288    fn unknown_parser_errors() {
289        let dir = TempDir::new().unwrap();
290        fs::write(
291            dir.path().join("drft.toml"),
292            "[graphs.x]\nparser = \"markdwn\"\n",
293        )
294        .unwrap();
295        let err = Config::load(dir.path()).unwrap_err().to_string();
296        assert!(err.contains("unknown parser"), "got: {err}");
297    }
298
299    #[test]
300    fn parser_fs_value_errors() {
301        // `fs` is a provider, not a parser, so it's not a valid parser value.
302        let dir = TempDir::new().unwrap();
303        fs::write(
304            dir.path().join("drft.toml"),
305            "[graphs.x]\nparser = \"fs\"\n",
306        )
307        .unwrap();
308        assert!(Config::load(dir.path()).is_err());
309    }
310
311    #[test]
312    fn reserved_graph_name_fs_errors() {
313        // Naming a graph `fs` would clobber the implicit base graph's @fs block.
314        let dir = TempDir::new().unwrap();
315        fs::write(
316            dir.path().join("drft.toml"),
317            "[graphs.fs]\nparser = \"markdown\"\n",
318        )
319        .unwrap();
320        let err = Config::load(dir.path()).unwrap_err().to_string();
321        assert!(err.contains("reserved"), "got: {err}");
322    }
323
324    #[test]
325    fn invalid_graph_name_errors() {
326        // Leading underscore is reserved.
327        let dir = TempDir::new().unwrap();
328        fs::write(
329            dir.path().join("drft.toml"),
330            "[graphs._internal]\nparser = \"markdown\"\n",
331        )
332        .unwrap();
333        assert!(Config::load(dir.path()).is_err());
334    }
335
336    #[test]
337    fn loads_rule_severity_and_ignore() {
338        let dir = TempDir::new().unwrap();
339        fs::write(
340            dir.path().join("drft.toml"),
341            "[rules]\nstale-node = \"error\"\n\n[rules.detached-node]\nignore = [\"README.md\"]\n",
342        )
343        .unwrap();
344        let config = Config::load(dir.path()).unwrap();
345        assert_eq!(config.rules["stale-node"].severity, RuleSeverity::Error);
346        assert!(config.is_rule_ignored("detached-node", "README.md"));
347        assert!(!config.is_rule_ignored("detached-node", "other.md"));
348    }
349
350    #[test]
351    fn invalid_toml_errors() {
352        let dir = TempDir::new().unwrap();
353        fs::write(dir.path().join("drft.toml"), "not valid toml {{{{").unwrap();
354        assert!(Config::load(dir.path()).is_err());
355    }
356}