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, Clone)]
93pub struct Config {
94 pub ignore: Vec<String>,
97 pub graphs: BTreeMap<String, GraphConfig>,
99 pub rules: HashMap<String, RuleConfig>,
100 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
112const 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
123const KNOWN_PARSERS: &[&str] = &["markdown", "frontmatter"];
126
127const RESERVED_GRAPH_NAMES: &[&str] = &["fs"];
130
131const DEFAULT_FILES: &str = "**/*.md";
133
134impl Config {
135 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 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 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 pub fn ignore_patterns(&self) -> &[String] {
217 &self.ignore
218 }
219
220 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 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 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 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 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}