use anyhow::{Context, Result};
use globset::{GlobBuilder, GlobSet, GlobSetBuilder};
use serde::Deserialize;
use std::collections::{BTreeMap, HashMap};
use std::path::Path;
pub fn compile_globs(patterns: &[String]) -> Result<Option<GlobSet>> {
if patterns.is_empty() {
return Ok(None);
}
let mut builder = GlobSetBuilder::new();
for pattern in patterns {
builder.add(GlobBuilder::new(pattern).literal_separator(true).build()?);
}
Ok(Some(builder.build()?))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum RuleSeverity {
Error,
Warn,
Off,
}
#[derive(Debug, Clone)]
pub struct GraphConfig {
pub files: Vec<String>,
pub parser: String,
pub keys: Option<Vec<String>>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawGraph {
files: Option<Vec<String>>,
parser: String,
keys: Option<Vec<String>>,
}
#[derive(Debug, Clone)]
pub struct RuleConfig {
pub severity: RuleSeverity,
ignore_compiled: Option<GlobSet>,
}
impl RuleConfig {
fn new(severity: RuleSeverity, ignore: Vec<String>) -> Result<Self> {
let ignore_compiled = compile_globs(&ignore).context("failed to compile ignore globs")?;
Ok(Self {
severity,
ignore_compiled,
})
}
pub fn is_path_ignored(&self, path: &str) -> bool {
self.ignore_compiled
.as_ref()
.is_some_and(|set| set.is_match(path))
}
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum RawRuleValue {
Severity(RuleSeverity),
Table {
#[serde(default = "default_warn")]
severity: RuleSeverity,
#[serde(default)]
ignore: Vec<String>,
#[serde(flatten)]
unknown: BTreeMap<String, toml::Value>,
},
}
const RULE_TABLE_FIELDS: &str = "`severity` or `ignore`";
fn default_warn() -> RuleSeverity {
RuleSeverity::Warn
}
#[derive(Debug, Deserialize, Default)]
struct RawRules {
#[serde(default)]
ignore: Vec<String>,
#[serde(flatten)]
rules: HashMap<String, RawRuleValue>,
}
#[derive(Debug, Clone)]
pub struct Config {
pub ignore: Vec<String>,
pub graphs: BTreeMap<String, GraphConfig>,
pub rules: HashMap<String, RuleConfig>,
rule_ignore: Option<GlobSet>,
pub config_dir: Option<std::path::PathBuf>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
struct RawConfig {
ignore: Option<Vec<String>>,
graphs: Option<HashMap<String, RawGraph>>,
rules: Option<RawRules>,
}
const BUILTIN_RULES: &[&str] = &[
"stale-node",
"stale-edge",
"new-edge",
"removed-edge",
"removed-node",
"unresolved-edge",
"detached-node",
];
const KNOWN_PARSERS: &[&str] = &["markdown", "frontmatter"];
const PARSERS_WITH_KEYS: &[&str] = &["frontmatter"];
const RESERVED_GRAPH_NAMES: &[&str] = &["fs"];
const DEFAULT_FILES: &str = "**/*.md";
impl Config {
pub fn defaults() -> Self {
Config {
ignore: Vec::new(),
graphs: BTreeMap::new(),
rules: HashMap::new(),
rule_ignore: None,
config_dir: None,
}
}
pub fn load(root: &Path) -> Result<Self> {
let config_path = match Self::find_config(root) {
Some(p) => p,
None => anyhow::bail!("no drft.toml found (run `drft init` to create one)"),
};
let content = std::fs::read_to_string(&config_path)
.with_context(|| format!("failed to read {}", config_path.display()))?;
let raw: RawConfig = toml::from_str(&content)
.with_context(|| format!("failed to parse {}", config_path.display()))?;
let mut config = Self::defaults();
config.config_dir = config_path.parent().map(|p| p.to_path_buf());
if let Some(ignore) = raw.ignore {
config.ignore = ignore;
}
if let Some(raw_graphs) = raw.graphs {
for (name, raw) in raw_graphs {
crate::model::validate_label(&name)
.map_err(|e| anyhow::anyhow!("invalid graph name in drft.toml: {e}"))?;
if RESERVED_GRAPH_NAMES.contains(&name.as_str()) {
anyhow::bail!("graph name \"{name}\" is reserved (the implicit base graph)");
}
if !KNOWN_PARSERS.contains(&raw.parser.as_str()) {
anyhow::bail!(
"unknown parser \"{}\" for graph \"{name}\" (known: {})",
raw.parser,
KNOWN_PARSERS.join(", ")
);
}
if raw.keys.is_some() && !PARSERS_WITH_KEYS.contains(&raw.parser.as_str()) {
anyhow::bail!(
"`keys` is not supported by the \"{}\" parser in graph \"{name}\" (supported: {})",
raw.parser,
PARSERS_WITH_KEYS.join(", ")
);
}
if raw.keys.as_ref().is_some_and(Vec::is_empty) {
anyhow::bail!(
"`keys` is empty in graph \"{name}\" — the graph would track nothing (omit it for shape detection)"
);
}
config.graphs.insert(
name,
GraphConfig {
files: raw.files.unwrap_or_else(|| vec![DEFAULT_FILES.to_string()]),
parser: raw.parser,
keys: raw.keys,
},
);
}
}
if let Some(raw_rules) = raw.rules {
config.rule_ignore = compile_globs(&raw_rules.ignore)
.context("failed to compile [rules].ignore globs")?;
for (name, value) in raw_rules.rules {
let rule_config = match value {
RawRuleValue::Severity(severity) => RuleConfig::new(severity, Vec::new())?,
RawRuleValue::Table {
severity,
ignore,
unknown,
} => {
if let Some(key) = unknown.keys().next() {
anyhow::bail!(
"failed to parse {}: unknown field `{key}` in rules.{name}, expected {RULE_TABLE_FIELDS}",
config_path.display()
);
}
RuleConfig::new(severity, ignore)
.with_context(|| format!("invalid globs in rules.{name}"))?
}
};
if !BUILTIN_RULES.contains(&name.as_str()) {
eprintln!("warn: unknown rule \"{name}\" in drft.toml (ignored)");
}
config.rules.insert(name, rule_config);
}
}
Ok(config)
}
fn find_config(root: &Path) -> Option<std::path::PathBuf> {
let candidate = root.join("drft.toml");
candidate.exists().then_some(candidate)
}
pub fn ignore_patterns(&self) -> &[String] {
&self.ignore
}
pub fn is_rule_ignored(&self, rule: &str, path: &str) -> bool {
self.rule_ignore
.as_ref()
.is_some_and(|set| set.is_match(path))
|| self
.rules
.get(rule)
.is_some_and(|r| r.is_path_ignored(path))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn errors_when_no_config() {
let dir = TempDir::new().unwrap();
let result = Config::load(dir.path());
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("no drft.toml found")
);
}
#[test]
fn defaults_have_no_graphs() {
assert!(Config::defaults().graphs.is_empty());
}
#[test]
fn loads_ignore() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("drft.toml"), "ignore = [\"target/**\"]\n").unwrap();
let config = Config::load(dir.path()).unwrap();
assert_eq!(config.ignore, vec!["target/**"]);
}
#[test]
fn declares_graphs() {
let dir = TempDir::new().unwrap();
fs::write(
dir.path().join("drft.toml"),
"[graphs.docs]\nparser = \"markdown\"\nfiles = [\"docs/**/*.md\"]\n",
)
.unwrap();
let config = Config::load(dir.path()).unwrap();
assert_eq!(config.graphs.len(), 1);
assert_eq!(config.graphs["docs"].parser, "markdown");
assert_eq!(config.graphs["docs"].files, vec!["docs/**/*.md"]);
}
#[test]
fn files_defaults_to_markdown_when_omitted() {
let dir = TempDir::new().unwrap();
fs::write(
dir.path().join("drft.toml"),
"[graphs.markdown]\nparser = \"markdown\"\n",
)
.unwrap();
let config = Config::load(dir.path()).unwrap();
assert_eq!(config.graphs["markdown"].files, vec!["**/*.md"]);
}
#[test]
fn unknown_parser_errors() {
let dir = TempDir::new().unwrap();
fs::write(
dir.path().join("drft.toml"),
"[graphs.x]\nparser = \"markdwn\"\n",
)
.unwrap();
let err = Config::load(dir.path()).unwrap_err().to_string();
assert!(err.contains("unknown parser"), "got: {err}");
}
#[test]
fn parser_fs_value_errors() {
let dir = TempDir::new().unwrap();
fs::write(
dir.path().join("drft.toml"),
"[graphs.x]\nparser = \"fs\"\n",
)
.unwrap();
assert!(Config::load(dir.path()).is_err());
}
#[test]
fn reserved_graph_name_fs_errors() {
let dir = TempDir::new().unwrap();
fs::write(
dir.path().join("drft.toml"),
"[graphs.fs]\nparser = \"markdown\"\n",
)
.unwrap();
let err = Config::load(dir.path()).unwrap_err().to_string();
assert!(err.contains("reserved"), "got: {err}");
}
#[test]
fn invalid_graph_name_errors() {
let dir = TempDir::new().unwrap();
fs::write(
dir.path().join("drft.toml"),
"[graphs._internal]\nparser = \"markdown\"\n",
)
.unwrap();
assert!(Config::load(dir.path()).is_err());
}
#[test]
fn loads_rule_severity_and_ignore() {
let dir = TempDir::new().unwrap();
fs::write(
dir.path().join("drft.toml"),
"[rules]\nstale-node = \"error\"\n\n[rules.detached-node]\nignore = [\"README.md\"]\n",
)
.unwrap();
let config = Config::load(dir.path()).unwrap();
assert_eq!(config.rules["stale-node"].severity, RuleSeverity::Error);
assert!(config.is_rule_ignored("detached-node", "README.md"));
assert!(!config.is_rule_ignored("detached-node", "other.md"));
}
#[test]
fn global_rule_ignore_applies_to_every_rule() {
let dir = TempDir::new().unwrap();
fs::write(
dir.path().join("drft.toml"),
"[rules]\nignore = [\"vendor/**\"]\n\n[rules.stale-node]\nseverity = \"error\"\n",
)
.unwrap();
let config = Config::load(dir.path()).unwrap();
assert_eq!(config.rules["stale-node"].severity, RuleSeverity::Error);
assert!(config.is_rule_ignored("stale-node", "vendor/x.md"));
assert!(config.is_rule_ignored("unresolved-edge", "vendor/x.md"));
assert!(!config.is_rule_ignored("stale-node", "yours.md"));
}
#[test]
fn unknown_graph_key_errors() {
let dir = TempDir::new().unwrap();
fs::write(
dir.path().join("drft.toml"),
"[graphs.x]\nparser = \"frontmatter\"\ninclude_keys = [\"sources\"]\n",
)
.unwrap();
let err = format!("{:#}", Config::load(dir.path()).unwrap_err());
assert!(err.contains("unknown field `include_keys`"), "got: {err}");
assert!(err.contains("files"), "expected set not named: {err}");
}
#[test]
fn frontmatter_graph_accepts_keys() {
let dir = TempDir::new().unwrap();
fs::write(
dir.path().join("drft.toml"),
"[graphs.fm]\nparser = \"frontmatter\"\nkeys = [\"sources\"]\n",
)
.unwrap();
let config = Config::load(dir.path()).unwrap();
assert_eq!(
config.graphs["fm"].keys.as_deref(),
Some(&["sources".to_string()][..])
);
}
#[test]
fn keys_omitted_is_shape_detection() {
let dir = TempDir::new().unwrap();
fs::write(
dir.path().join("drft.toml"),
"[graphs.fm]\nparser = \"frontmatter\"\n",
)
.unwrap();
assert!(
Config::load(dir.path()).unwrap().graphs["fm"]
.keys
.is_none()
);
}
#[test]
fn keys_on_markdown_parser_errors() {
let dir = TempDir::new().unwrap();
fs::write(
dir.path().join("drft.toml"),
"[graphs.md]\nparser = \"markdown\"\nkeys = [\"sources\"]\n",
)
.unwrap();
let err = format!("{:#}", Config::load(dir.path()).unwrap_err());
assert!(
err.contains("not supported by the \"markdown\" parser"),
"got: {err}"
);
}
#[test]
fn empty_keys_errors() {
let dir = TempDir::new().unwrap();
fs::write(
dir.path().join("drft.toml"),
"[graphs.fm]\nparser = \"frontmatter\"\nkeys = []\n",
)
.unwrap();
let err = format!("{:#}", Config::load(dir.path()).unwrap_err());
assert!(err.contains("track nothing"), "got: {err}");
}
#[test]
fn unknown_top_level_key_errors() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("drft.toml"), "ignores = [\"target/**\"]\n").unwrap();
let err = format!("{:#}", Config::load(dir.path()).unwrap_err());
assert!(err.contains("unknown field `ignores`"), "got: {err}");
}
#[test]
fn unknown_rule_table_key_errors() {
let dir = TempDir::new().unwrap();
fs::write(
dir.path().join("drft.toml"),
"[rules.stale-node]\nseverty = \"error\"\n",
)
.unwrap();
let err = format!("{:#}", Config::load(dir.path()).unwrap_err());
assert!(err.contains("unknown field `severty`"), "got: {err}");
assert!(err.contains("rules.stale-node"), "got: {err}");
}
#[test]
fn known_rule_table_keys_still_parse() {
let dir = TempDir::new().unwrap();
fs::write(
dir.path().join("drft.toml"),
"[rules.detached-node]\nseverity = \"error\"\nignore = [\"README.md\"]\n",
)
.unwrap();
let config = Config::load(dir.path()).unwrap();
assert_eq!(config.rules["detached-node"].severity, RuleSeverity::Error);
assert!(config.is_rule_ignored("detached-node", "README.md"));
}
#[test]
fn invalid_toml_errors() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("drft.toml"), "not valid toml {{{{").unwrap();
assert!(Config::load(dir.path()).is_err());
}
}