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}
39
40#[derive(Debug, Deserialize)]
41struct RawGraph {
42 files: Option<Vec<String>>,
43 parser: String,
44}
45
46#[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#[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#[derive(Debug, Deserialize, Default)]
94struct RawRules {
95 #[serde(default)]
96 ignore: Vec<String>,
97 #[serde(flatten)]
98 rules: HashMap<String, RawRuleValue>,
99}
100
101#[derive(Debug, Clone)]
104pub struct Config {
105 pub ignore: Vec<String>,
108 pub graphs: BTreeMap<String, GraphConfig>,
110 pub rules: HashMap<String, RuleConfig>,
111 rule_ignore: Option<GlobSet>,
115 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
127const 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
138const KNOWN_PARSERS: &[&str] = &["markdown", "frontmatter"];
141
142const RESERVED_GRAPH_NAMES: &[&str] = &["fs"];
145
146const DEFAULT_FILES: &str = "**/*.md";
148
149impl Config {
150 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 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 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 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 pub fn ignore_patterns(&self) -> &[String] {
237 &self.ignore
238 }
239
240 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 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 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 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 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 assert_eq!(config.rules["stale-node"].severity, RuleSeverity::Error);
385 assert!(config.is_rule_ignored("stale-node", "vendor/x.md"));
387 assert!(config.is_rule_ignored("unresolved-edge", "vendor/x.md"));
388 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}