use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::path::{Path, PathBuf};
use glob::Pattern;
use serde::Deserialize;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("failed to read {path}: {source}")]
Read {
path: PathBuf,
source: std::io::Error,
},
#[error("failed to parse {path}: {source}")]
Parse {
path: PathBuf,
source: toml::de::Error,
},
#[error("invalid config in {path}: {message}")]
Invalid {
path: PathBuf,
message: String,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum PredicatePolicy {
#[default]
Optional,
Required,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum BarePathPolicy {
#[default]
Warn,
Deny,
Disabled,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum AdmonitionPolicy {
#[default]
Portable,
Github,
Gitlab,
Disabled,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum CodeBlockLanguagePolicy {
Hint,
Warn,
Deny,
#[default]
Disabled,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum StaleReferencePolicy {
Hint,
#[default]
Warn,
Deny,
Disabled,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ConnectivityPolicy {
#[default]
Off,
NoOrphans,
NoIslands,
Reachable,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FragmentAlgorithm {
Github,
Gitlab,
Vscode,
}
#[allow(
clippy::struct_excessive_bools,
reason = "each bool is an independent on/off toggle for a distinct opt-in diagnostic, not a state machine"
)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Policy {
pub predicates: PredicatePolicy,
pub backlinks: bool,
pub bare_paths: BarePathPolicy,
pub stale_references: StaleReferencePolicy,
pub fragments: Option<FragmentAlgorithm>,
pub admonitions: AdmonitionPolicy,
pub code_block_language: CodeBlockLanguagePolicy,
pub multiple_h1: bool,
pub skipped_heading_level: bool,
pub image_empty_alt: bool,
pub connectivity: ConnectivityPolicy,
pub roots: Vec<PathBuf>,
}
impl Default for Policy {
fn default() -> Self {
Self {
predicates: PredicatePolicy::Optional,
backlinks: true,
bare_paths: BarePathPolicy::Warn,
stale_references: StaleReferencePolicy::default(),
fragments: None,
admonitions: AdmonitionPolicy::default(),
code_block_language: CodeBlockLanguagePolicy::default(),
multiple_h1: false,
skipped_heading_level: false,
image_empty_alt: false,
connectivity: ConnectivityPolicy::default(),
roots: vec![PathBuf::from("README.md")],
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StaleReferenceOverride {
Level(StaleReferencePolicy),
Expect(usize),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BarePathOverride {
Level(BarePathPolicy),
Expect(usize),
}
#[derive(Debug, Clone)]
pub struct Override {
pub paths: Vec<Pattern>,
pub raw_paths: Vec<String>,
pub stale_references: Option<StaleReferenceOverride>,
pub bare_paths: Option<BarePathOverride>,
pub hint: Option<String>,
}
impl Override {
#[must_use]
pub fn matches(&self, rel_path: &Path) -> bool {
self.paths.iter().any(|p| p.matches_path(rel_path))
}
#[must_use]
pub fn label(&self) -> String {
self.raw_paths.join(", ")
}
#[must_use]
pub fn hint_suffix(&self) -> String {
self.hint
.as_deref()
.map_or_else(String::new, |h| format!(" — {h}"))
}
}
#[derive(Debug, Clone)]
pub struct Config {
pub predicates: BTreeMap<String, String>,
pub policy: Policy,
pub format_command: Option<String>,
pub external: BTreeMap<String, PathBuf>,
pub overrides: Vec<Override>,
pub artifacts: BTreeSet<String>,
}
impl Default for Config {
fn default() -> Self {
Self {
predicates: default_predicates(),
policy: Policy::default(),
format_command: None,
external: BTreeMap::new(),
overrides: Vec::new(),
artifacts: BTreeSet::new(),
}
}
}
impl Config {
pub fn load(start: &Path) -> Result<Self, ConfigError> {
let Some(path) = find_config_file(start) else {
return Ok(Self::default());
};
let contents = std::fs::read_to_string(&path).map_err(|e| ConfigError::Read {
path: path.clone(),
source: e,
})?;
let raw: RawConfig = toml::from_str(&contents).map_err(|e| ConfigError::Parse {
path: path.clone(),
source: e,
})?;
Self::from_raw(raw, path)
}
fn from_raw(raw: RawConfig, path: PathBuf) -> Result<Self, ConfigError> {
let mut config = Self::default();
if let Some(predicates) = raw.predicates {
for (forward, inverse) in &predicates {
if inverse.is_empty() {
return Err(ConfigError::Invalid {
path,
message: format!("predicate '{forward}' has an empty inverse"),
});
}
}
for (forward, inverse) in predicates {
config.predicates.insert(forward, inverse);
}
}
if let Some(policy) = raw.policy {
apply_policy(&mut config.policy, policy, &path)?;
}
if let Some(format) = raw.format {
config.format_command = format.command;
}
if let Some(external) = raw.external {
let base_dir = path.parent().unwrap_or_else(|| Path::new(""));
for (alias, dir) in external {
config
.external
.insert(alias, resolve_alias_dir(&dir, base_dir));
}
}
if let Some(overrides) = raw.overrides {
for raw_override in overrides {
config.overrides.push(parse_override(raw_override, &path)?);
}
}
if let Some(graph) = raw.graph
&& let Some(artifacts) = graph.artifacts
{
config.artifacts = artifacts.into_iter().collect();
}
Ok(config)
}
pub fn is_known_forward(&self, predicate: &str) -> bool {
self.predicates.contains_key(predicate)
}
pub fn is_known_inverse(&self, predicate: &str) -> bool {
self.predicates.values().any(|v| v == predicate)
}
pub fn inverse_of(&self, forward: &str) -> Option<&str> {
self.predicates.get(forward).map(String::as_str)
}
pub fn forward_of(&self, inverse: &str) -> Option<&str> {
self.predicates
.iter()
.find(|(_, v)| v.as_str() == inverse)
.map(|(k, _)| k.as_str())
}
pub fn is_known_predicate(&self, predicate: &str) -> bool {
self.is_known_forward(predicate) || self.is_known_inverse(predicate)
}
pub fn opposite_of(&self, predicate: &str) -> Option<&str> {
self.inverse_of(predicate)
.or_else(|| self.forward_of(predicate))
}
#[must_use]
pub fn effective_policy(&self, rel_path: &Path) -> Policy {
let mut policy = self.policy.clone();
for ov in &self.overrides {
if !ov.matches(rel_path) {
continue;
}
match ov.stale_references {
Some(StaleReferenceOverride::Level(level)) => policy.stale_references = level,
Some(StaleReferenceOverride::Expect(_)) => {
policy.stale_references = self.policy.stale_references;
}
None => {}
}
match ov.bare_paths {
Some(BarePathOverride::Level(level)) => policy.bare_paths = level,
Some(BarePathOverride::Expect(_)) => {
policy.bare_paths = self.policy.bare_paths;
}
None => {}
}
}
policy
}
}
#[derive(Debug, Deserialize)]
struct RawConfig {
predicates: Option<HashMap<String, String>>,
policy: Option<RawPolicy>,
format: Option<RawFormat>,
external: Option<HashMap<String, String>>,
#[serde(rename = "override")]
overrides: Option<Vec<RawOverride>>,
graph: Option<RawGraph>,
}
#[derive(Debug, Deserialize)]
struct RawFormat {
command: Option<String>,
}
#[derive(Debug, Deserialize)]
struct RawGraph {
artifacts: Option<Vec<String>>,
}
#[derive(Debug, Deserialize)]
struct RawOverride {
paths: Vec<String>,
stale_references: Option<RawOverrideLint>,
bare_paths: Option<RawOverrideLint>,
hint: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum RawOverrideLint {
Level(String),
Expect {
expect: usize,
},
}
#[derive(Debug, Deserialize)]
struct RawPolicy {
predicates: Option<String>,
backlinks: Option<bool>,
bare_paths: Option<String>,
stale_references: Option<String>,
fragments: Option<String>,
admonitions: Option<String>,
code_block_language: Option<String>,
multiple_h1: Option<bool>,
skipped_heading_level: Option<bool>,
image_empty_alt: Option<bool>,
connectivity: Option<String>,
roots: Option<Vec<String>>,
}
fn default_predicates() -> BTreeMap<String, String> {
BTreeMap::from([
("amends".into(), "amended_by".into()),
("blocks".into(), "blocked_by".into()),
("depends_on".into(), "dependency_of".into()),
("implements".into(), "implemented_by".into()),
("imports".into(), "imported_by".into()),
("references".into(), "referenced_by".into()),
("supersedes".into(), "superseded_by".into()),
])
}
fn resolve_alias_dir(dir: &str, base_dir: &Path) -> PathBuf {
if let Some(rest) = dir.strip_prefix("~/") {
return std::env::home_dir().map_or_else(|| PathBuf::from(dir), |home| home.join(rest));
}
if dir == "~" {
return std::env::home_dir().unwrap_or_else(|| PathBuf::from(dir));
}
let path = Path::new(dir);
if path.is_absolute() {
path.to_path_buf()
} else {
base_dir.join(path)
}
}
fn find_config_file(start: &Path) -> Option<PathBuf> {
let dir = if start.is_file() {
start.parent()?
} else {
start
};
let mut current = dir;
loop {
let candidate = current.join(".lattice.toml");
if candidate.is_file() {
return Some(candidate);
}
if current.join(".git").exists() {
return None;
}
current = current.parent()?;
}
}
fn apply_policy(policy: &mut Policy, raw: RawPolicy, path: &Path) -> Result<(), ConfigError> {
let invalid = |message: String| ConfigError::Invalid {
path: path.to_path_buf(),
message,
};
if let Some(ref value) = raw.predicates {
policy.predicates = parse_predicate_policy(value).ok_or_else(|| {
invalid(format!(
"unknown predicates policy '{value}': expected 'optional' or 'required'"
))
})?;
}
if let Some(backlinks) = raw.backlinks {
policy.backlinks = backlinks;
}
if let Some(ref value) = raw.bare_paths {
policy.bare_paths = parse_bare_path_policy(value).ok_or_else(|| {
invalid(format!(
"unknown bare_paths policy '{value}': expected 'warn', 'deny', or 'disabled'"
))
})?;
}
if let Some(ref value) = raw.stale_references {
policy.stale_references = parse_stale_reference_policy(value).ok_or_else(|| {
invalid(format!(
"unknown stale_references policy '{value}': expected 'hint', 'warn', 'deny', or 'disabled'"
))
})?;
}
if let Some(ref value) = raw.fragments {
policy.fragments = Some(parse_fragment_algorithm(value).ok_or_else(|| {
invalid(format!(
"unknown fragments algorithm '{value}': expected 'github', 'gitlab', or 'vscode'"
))
})?);
}
if let Some(ref value) = raw.admonitions {
policy.admonitions = parse_admonition_policy(value).ok_or_else(|| {
invalid(format!(
"unknown admonitions policy '{value}': expected 'portable', 'github', 'gitlab', or 'disabled'"
))
})?;
}
if let Some(ref value) = raw.code_block_language {
policy.code_block_language = parse_code_block_language_policy(value).ok_or_else(|| {
invalid(format!(
"unknown code_block_language policy '{value}': expected 'hint', 'warn', 'deny', or 'disabled'"
))
})?;
}
if let Some(multiple_h1) = raw.multiple_h1 {
policy.multiple_h1 = multiple_h1;
}
if let Some(skipped_heading_level) = raw.skipped_heading_level {
policy.skipped_heading_level = skipped_heading_level;
}
if let Some(image_empty_alt) = raw.image_empty_alt {
policy.image_empty_alt = image_empty_alt;
}
if let Some(ref value) = raw.connectivity {
policy.connectivity = parse_connectivity_policy(value).ok_or_else(|| {
invalid(format!(
"unknown connectivity policy '{value}': expected 'off', 'no-orphans', 'no-islands', or 'reachable'"
))
})?;
}
if let Some(roots) = raw.roots {
policy.roots = roots.iter().map(PathBuf::from).collect();
}
Ok(())
}
fn parse_override(raw: RawOverride, path: &Path) -> Result<Override, ConfigError> {
let invalid = |message: String| ConfigError::Invalid {
path: path.to_path_buf(),
message,
};
if raw.paths.is_empty() {
return Err(invalid(
"an [[override]] entry must list at least one path glob".to_string(),
));
}
let mut paths = Vec::with_capacity(raw.paths.len());
for glob in &raw.paths {
let pattern = Pattern::new(glob)
.map_err(|e| invalid(format!("invalid override glob '{glob}': {e}")))?;
paths.push(pattern);
}
let stale_references = raw
.stale_references
.map(|lint| parse_stale_reference_override(lint, &invalid))
.transpose()?;
let bare_paths = raw
.bare_paths
.map(|lint| parse_bare_path_override(lint, &invalid))
.transpose()?;
if stale_references.is_none() && bare_paths.is_none() {
return Err(invalid(format!(
"[[override]] entry for '{}' sets neither stale_references nor bare_paths",
raw.paths.join(", ")
)));
}
Ok(Override {
paths,
raw_paths: raw.paths,
stale_references,
bare_paths,
hint: raw.hint,
})
}
fn parse_stale_reference_override(
raw: RawOverrideLint,
invalid: &impl Fn(String) -> ConfigError,
) -> Result<StaleReferenceOverride, ConfigError> {
match raw {
RawOverrideLint::Level(value) => parse_stale_reference_policy(&value)
.map(StaleReferenceOverride::Level)
.ok_or_else(|| {
invalid(format!(
"unknown override stale_references level '{value}': expected 'hint', 'warn', 'deny', 'disabled', or {{ expect = N }}"
))
}),
RawOverrideLint::Expect { expect } => {
check_expect(expect, "stale_references", invalid)?;
Ok(StaleReferenceOverride::Expect(expect))
}
}
}
fn parse_bare_path_override(
raw: RawOverrideLint,
invalid: &impl Fn(String) -> ConfigError,
) -> Result<BarePathOverride, ConfigError> {
match raw {
RawOverrideLint::Level(value) => parse_bare_path_policy(&value)
.map(BarePathOverride::Level)
.ok_or_else(|| {
invalid(format!(
"unknown override bare_paths level '{value}': expected 'warn', 'deny', 'disabled', or {{ expect = N }}"
))
}),
RawOverrideLint::Expect { expect } => {
check_expect(expect, "bare_paths", invalid)?;
Ok(BarePathOverride::Expect(expect))
}
}
}
fn check_expect(
expect: usize,
lint: &str,
invalid: &impl Fn(String) -> ConfigError,
) -> Result<(), ConfigError> {
if expect == 0 {
return Err(invalid(format!(
"override {lint} {{ expect = 0 }} must be at least 1"
)));
}
Ok(())
}
fn parse_predicate_policy(s: &str) -> Option<PredicatePolicy> {
match s {
"optional" => Some(PredicatePolicy::Optional),
"required" => Some(PredicatePolicy::Required),
_ => None,
}
}
fn parse_bare_path_policy(s: &str) -> Option<BarePathPolicy> {
match s {
"warn" => Some(BarePathPolicy::Warn),
"deny" => Some(BarePathPolicy::Deny),
"disabled" => Some(BarePathPolicy::Disabled),
_ => None,
}
}
fn parse_stale_reference_policy(s: &str) -> Option<StaleReferencePolicy> {
match s {
"hint" => Some(StaleReferencePolicy::Hint),
"warn" => Some(StaleReferencePolicy::Warn),
"deny" => Some(StaleReferencePolicy::Deny),
"disabled" => Some(StaleReferencePolicy::Disabled),
_ => None,
}
}
fn parse_fragment_algorithm(s: &str) -> Option<FragmentAlgorithm> {
match s {
"github" => Some(FragmentAlgorithm::Github),
"gitlab" => Some(FragmentAlgorithm::Gitlab),
"vscode" => Some(FragmentAlgorithm::Vscode),
_ => None,
}
}
fn parse_admonition_policy(s: &str) -> Option<AdmonitionPolicy> {
match s {
"portable" => Some(AdmonitionPolicy::Portable),
"github" => Some(AdmonitionPolicy::Github),
"gitlab" => Some(AdmonitionPolicy::Gitlab),
"disabled" => Some(AdmonitionPolicy::Disabled),
_ => None,
}
}
fn parse_code_block_language_policy(s: &str) -> Option<CodeBlockLanguagePolicy> {
match s {
"hint" => Some(CodeBlockLanguagePolicy::Hint),
"warn" => Some(CodeBlockLanguagePolicy::Warn),
"deny" => Some(CodeBlockLanguagePolicy::Deny),
"disabled" => Some(CodeBlockLanguagePolicy::Disabled),
_ => None,
}
}
fn parse_connectivity_policy(s: &str) -> Option<ConnectivityPolicy> {
match s {
"off" => Some(ConnectivityPolicy::Off),
"no-orphans" => Some(ConnectivityPolicy::NoOrphans),
"no-islands" => Some(ConnectivityPolicy::NoIslands),
"reachable" => Some(ConnectivityPolicy::Reachable),
_ => None,
}
}
#[cfg(test)]
#[allow(clippy::expect_used, reason = "tests use expect for clarity")]
mod tests {
use std::fs;
use super::*;
fn temp_dir_with(toml_content: Option<&str>) -> tempfile::TempDir {
let dir = tempfile::tempdir().expect("create temp dir");
if let Some(content) = toml_content {
fs::write(dir.path().join(".lattice.toml"), content).expect("write .lattice.toml");
}
dir
}
#[test]
fn defaults_when_no_config() {
let dir = temp_dir_with(None);
fs::create_dir(dir.path().join(".git")).expect("create .git");
let config = Config::load(dir.path()).expect("load should succeed");
assert_eq!(
config.predicates.len(),
7,
"should have 7 default predicates"
);
assert_eq!(
config.inverse_of("supersedes"),
Some("superseded_by"),
"supersedes → superseded_by"
);
assert_eq!(
config.inverse_of("references"),
Some("referenced_by"),
"references → referenced_by"
);
assert_eq!(
config.policy.predicates,
PredicatePolicy::Optional,
"default predicate policy"
);
assert!(config.policy.backlinks, "default backlinks enabled");
assert_eq!(
config.policy.bare_paths,
BarePathPolicy::Warn,
"default bare_paths"
);
assert_eq!(
config.policy.stale_references,
StaleReferencePolicy::Warn,
"default stale_references is warn (issue 028)"
);
assert!(
config.policy.fragments.is_none(),
"default fragments tries all"
);
}
#[test]
fn custom_predicates_merge_with_defaults() {
let dir = temp_dir_with(Some(
r#"
[predicates]
supersedes = "replaced_by"
tracks = "tracked_by"
"#,
));
let config = Config::load(dir.path()).expect("load should succeed");
assert_eq!(
config.inverse_of("supersedes"),
Some("replaced_by"),
"supersedes overridden"
);
assert_eq!(
config.inverse_of("tracks"),
Some("tracked_by"),
"new predicate added"
);
assert_eq!(
config.inverse_of("implements"),
Some("implemented_by"),
"default implements preserved"
);
assert_eq!(
config.inverse_of("references"),
Some("referenced_by"),
"default references preserved"
);
assert!(
config.predicates.len() >= 8,
"at least 8 predicates (7 defaults + 1 new)"
);
}
#[test]
fn partial_policy_override() {
let dir = temp_dir_with(Some(
r#"
[policy]
predicates = "required"
bare_paths = "deny"
"#,
));
let config = Config::load(dir.path()).expect("load should succeed");
assert_eq!(
config.policy.predicates,
PredicatePolicy::Required,
"predicates overridden"
);
assert_eq!(
config.policy.bare_paths,
BarePathPolicy::Deny,
"bare_paths overridden"
);
assert!(config.policy.backlinks, "backlinks default preserved");
assert!(
config.policy.fragments.is_none(),
"fragments default preserved"
);
}
#[test]
fn full_policy_override() {
let dir = temp_dir_with(Some(
r#"
[policy]
predicates = "required"
backlinks = false
bare_paths = "disabled"
fragments = "gitlab"
"#,
));
let config = Config::load(dir.path()).expect("load should succeed");
assert_eq!(
config.policy.predicates,
PredicatePolicy::Required,
"predicates"
);
assert!(!config.policy.backlinks, "backlinks disabled");
assert_eq!(
config.policy.bare_paths,
BarePathPolicy::Disabled,
"bare_paths"
);
assert_eq!(
config.policy.fragments,
Some(FragmentAlgorithm::Gitlab),
"fragments"
);
}
#[test]
fn all_fragment_algorithms() {
for (input, expected) in [
("github", FragmentAlgorithm::Github),
("gitlab", FragmentAlgorithm::Gitlab),
("vscode", FragmentAlgorithm::Vscode),
] {
let dir = temp_dir_with(Some(&format!("[policy]\nfragments = \"{input}\"")));
let config = Config::load(dir.path()).expect("load should succeed");
assert_eq!(
config.policy.fragments,
Some(expected),
"fragment algorithm for '{input}'"
);
}
}
#[test]
fn empty_config_returns_defaults() {
let dir = temp_dir_with(Some(""));
let config = Config::load(dir.path()).expect("load should succeed");
assert_eq!(config.predicates.len(), 7, "defaults preserved");
assert_eq!(
config.policy.predicates,
PredicatePolicy::Optional,
"default policy"
);
}
#[test]
fn walks_up_to_find_config() {
let dir = temp_dir_with(Some("[policy]\npredicates = \"required\""));
let subdir = dir.path().join("a").join("b").join("c");
fs::create_dir_all(&subdir).expect("create subdirs");
let config = Config::load(&subdir).expect("load should succeed");
assert_eq!(
config.policy.predicates,
PredicatePolicy::Required,
"found config from parent"
);
}
#[test]
fn stops_at_git_root() {
let dir = tempfile::tempdir().expect("create temp dir");
fs::write(
dir.path().join(".lattice.toml"),
"[policy]\npredicates = \"required\"",
)
.expect("write config");
let project = dir.path().join("project");
fs::create_dir(&project).expect("create project dir");
fs::create_dir(project.join(".git")).expect("create .git");
let config = Config::load(&project).expect("load should succeed");
assert_eq!(
config.policy.predicates,
PredicatePolicy::Optional,
"should use defaults"
);
}
#[test]
fn config_at_git_root_is_found() {
let dir = tempfile::tempdir().expect("create temp dir");
fs::create_dir(dir.path().join(".git")).expect("create .git");
fs::write(
dir.path().join(".lattice.toml"),
"[policy]\npredicates = \"required\"",
)
.expect("write config");
let subdir = dir.path().join("docs");
fs::create_dir(&subdir).expect("create docs dir");
let config = Config::load(&subdir).expect("load should succeed");
assert_eq!(
config.policy.predicates,
PredicatePolicy::Required,
"config at git root should be found"
);
}
#[test]
fn load_from_file_path() {
let dir = temp_dir_with(Some("[policy]\nbare_paths = \"deny\""));
let file = dir.path().join("doc.md");
fs::write(&file, "# Hello").expect("write file");
let config = Config::load(&file).expect("load should succeed");
assert_eq!(
config.policy.bare_paths,
BarePathPolicy::Deny,
"found config when starting from a file"
);
}
#[test]
fn invalid_predicate_policy() {
let dir = temp_dir_with(Some("[policy]\npredicates = \"always\""));
let err = Config::load(dir.path()).expect_err("should fail");
let msg = err.to_string();
assert!(msg.contains("always"), "mentions bad value: {msg}");
assert!(
msg.contains("optional") && msg.contains("required"),
"lists valid options: {msg}"
);
}
#[test]
fn invalid_bare_paths_policy() {
let dir = temp_dir_with(Some("[policy]\nbare_paths = \"error\""));
let err = Config::load(dir.path()).expect_err("should fail");
let msg = err.to_string();
assert!(msg.contains("error"), "mentions bad value: {msg}");
}
#[test]
fn invalid_fragments_algorithm() {
let dir = temp_dir_with(Some("[policy]\nfragments = \"bitbucket\""));
let err = Config::load(dir.path()).expect_err("should fail");
let msg = err.to_string();
assert!(msg.contains("bitbucket"), "mentions bad value: {msg}");
}
#[test]
fn convention_flags_default_false() {
let dir = temp_dir_with(None);
fs::create_dir(dir.path().join(".git")).expect("create .git");
let config = Config::load(dir.path()).expect("load should succeed");
assert!(
!config.policy.multiple_h1,
"multiple_h1 defaults off (decision 009)"
);
assert!(
!config.policy.skipped_heading_level,
"skipped_heading_level defaults off (decision 009)"
);
assert!(
!config.policy.image_empty_alt,
"image_empty_alt defaults off (decision 009)"
);
}
#[test]
fn convention_flags_parse_true() {
let dir = temp_dir_with(Some(
r"
[policy]
multiple_h1 = true
skipped_heading_level = true
image_empty_alt = true
",
));
let config = Config::load(dir.path()).expect("load should succeed");
assert!(
config.policy.multiple_h1,
"multiple_h1 = true enables the check"
);
assert!(
config.policy.skipped_heading_level,
"skipped_heading_level = true enables the check"
);
assert!(
config.policy.image_empty_alt,
"image_empty_alt = true enables the check"
);
}
#[test]
fn code_block_language_defaults_disabled() {
let dir = temp_dir_with(None);
fs::create_dir(dir.path().join(".git")).expect("create .git");
let config = Config::load(dir.path()).expect("load should succeed");
assert_eq!(
config.policy.code_block_language,
CodeBlockLanguagePolicy::Disabled,
"code_block_language defaults to disabled (decision 009)"
);
}
#[test]
fn code_block_language_parses_hint() {
let dir = temp_dir_with(Some("[policy]\ncode_block_language = \"hint\""));
let config = Config::load(dir.path()).expect("load should succeed");
assert_eq!(
config.policy.code_block_language,
CodeBlockLanguagePolicy::Hint,
"code_block_language = \"hint\" enables the hint"
);
}
#[test]
fn stale_references_defaults_warn() {
let dir = temp_dir_with(None);
fs::create_dir(dir.path().join(".git")).expect("create .git");
let config = Config::load(dir.path()).expect("load should succeed");
assert_eq!(
config.policy.stale_references,
StaleReferencePolicy::Warn,
"stale_references defaults to warn (issue 028)"
);
}
#[test]
fn stale_references_levels_parse() {
for (value, expected) in [
("hint", StaleReferencePolicy::Hint),
("warn", StaleReferencePolicy::Warn),
("deny", StaleReferencePolicy::Deny),
("disabled", StaleReferencePolicy::Disabled),
] {
let dir = temp_dir_with(Some(&format!("[policy]\nstale_references = \"{value}\"")));
let config = Config::load(dir.path()).expect("load should succeed");
assert_eq!(
config.policy.stale_references, expected,
"stale_references = {value:?} parses"
);
}
}
#[test]
fn invalid_stale_references_policy() {
let dir = temp_dir_with(Some("[policy]\nstale_references = \"error\""));
let err = Config::load(dir.path()).expect_err("should fail");
let msg = err.to_string();
assert!(msg.contains("error"), "mentions bad value: {msg}");
assert!(
msg.contains("hint") && msg.contains("disabled"),
"lists valid options: {msg}"
);
}
#[test]
fn external_defaults_empty() {
let dir = temp_dir_with(None);
fs::create_dir(dir.path().join(".git")).expect("create .git");
let config = Config::load(dir.path()).expect("load should succeed");
assert!(
config.external.is_empty(),
"no [external] table means no aliases (the exempt floor)"
);
}
#[test]
fn external_relative_alias_resolves_against_config_dir() {
let dir = temp_dir_with(Some("[external]\nCatenary = \"../Catenary\""));
let config = Config::load(dir.path()).expect("load should succeed");
assert_eq!(
config.external.get("Catenary"),
Some(&dir.path().join("../Catenary")),
"relative alias resolves against the config file's directory"
);
}
#[test]
fn external_absolute_alias_parses_verbatim() {
let dir = temp_dir_with(Some("[external]\nCatenary = \"/srv/Catenary\""));
let config = Config::load(dir.path()).expect("load should succeed");
assert_eq!(
config.external.get("Catenary"),
Some(&PathBuf::from("/srv/Catenary")),
"an absolute alias value is taken verbatim"
);
}
#[test]
fn external_home_alias_parses() {
let dir = temp_dir_with(Some("[external]\nCatenary = \"~/Projects/Catenary\""));
let config = Config::load(dir.path()).expect("load should succeed");
let resolved = config
.external
.get("Catenary")
.expect("home-relative alias parses");
if let Some(home) = std::env::home_dir() {
assert_eq!(
resolved,
&home.join("Projects/Catenary"),
"`~/` expands against the home directory"
);
} else {
assert_eq!(
resolved,
&PathBuf::from("~/Projects/Catenary"),
"with no home directory the `~` is left literal (resolves to absent → exempt)"
);
}
}
#[test]
fn external_table_round_trips_multiple_aliases() {
let dir = temp_dir_with(Some(
"[external]\nCatenary = \"../Catenary\"\nHedgeMaze = \"/opt/HedgeMaze\"",
));
let config = Config::load(dir.path()).expect("load should succeed");
assert_eq!(config.external.len(), 2, "both aliases round-trip");
assert_eq!(
config.external.get("Catenary"),
Some(&dir.path().join("../Catenary")),
"relative alias preserved"
);
assert_eq!(
config.external.get("HedgeMaze"),
Some(&PathBuf::from("/opt/HedgeMaze")),
"absolute alias preserved"
);
}
#[test]
fn connectivity_defaults() {
let dir = temp_dir_with(None);
fs::create_dir(dir.path().join(".git")).expect("create .git");
let config = Config::load(dir.path()).expect("load should succeed");
assert_eq!(
config.policy.connectivity,
ConnectivityPolicy::Off,
"connectivity defaults off"
);
assert_eq!(
config.policy.roots,
vec![PathBuf::from("README.md")],
"roots default to the workspace-root README"
);
}
#[test]
fn connectivity_levels_parse() {
for (value, expected) in [
("no-orphans", ConnectivityPolicy::NoOrphans),
("no-islands", ConnectivityPolicy::NoIslands),
("reachable", ConnectivityPolicy::Reachable),
("off", ConnectivityPolicy::Off),
] {
let dir = temp_dir_with(Some(&format!("[policy]\nconnectivity = \"{value}\"")));
let config = Config::load(dir.path()).expect("load should succeed");
assert_eq!(
config.policy.connectivity, expected,
"connectivity = {value:?} parses"
);
}
}
#[test]
fn connectivity_custom_roots_parse() {
let dir = temp_dir_with(Some(
"[policy]\nconnectivity = \"reachable\"\nroots = [\"docs/index.md\", \"CONTRIBUTING.md\"]",
));
let config = Config::load(dir.path()).expect("load should succeed");
assert_eq!(
config.policy.roots,
vec![
PathBuf::from("docs/index.md"),
PathBuf::from("CONTRIBUTING.md"),
],
"custom roots override the default"
);
}
#[test]
fn invalid_connectivity_policy() {
let dir = temp_dir_with(Some("[policy]\nconnectivity = \"weak\""));
let err = Config::load(dir.path()).expect_err("should fail");
let msg = err.to_string();
assert!(msg.contains("weak"), "mentions bad value: {msg}");
assert!(
msg.contains("no-orphans") && msg.contains("reachable"),
"lists valid options: {msg}"
);
}
#[test]
fn empty_inverse_predicate() {
let dir = temp_dir_with(Some("[predicates]\nfoo = \"\""));
let err = Config::load(dir.path()).expect_err("should fail");
let msg = err.to_string();
assert!(msg.contains("foo"), "mentions the predicate: {msg}");
assert!(msg.contains("empty"), "says empty: {msg}");
}
#[test]
fn malformed_toml() {
let dir = temp_dir_with(Some("this is not valid toml [[["));
let err = Config::load(dir.path()).expect_err("should fail");
assert!(
matches!(err, ConfigError::Parse { .. }),
"should be a parse error"
);
}
#[test]
fn vocabulary_lookups() {
let config = Config::default();
assert!(
config.is_known_forward("supersedes"),
"supersedes is forward"
);
assert!(
!config.is_known_forward("superseded_by"),
"superseded_by is not forward"
);
assert!(
config.is_known_inverse("superseded_by"),
"superseded_by is inverse"
);
assert!(
!config.is_known_inverse("supersedes"),
"supersedes is not inverse"
);
assert_eq!(
config.inverse_of("supersedes"),
Some("superseded_by"),
"inverse lookup"
);
assert_eq!(
config.inverse_of("unknown"),
None,
"unknown forward returns None"
);
}
#[test]
fn known_predicate_accepts_either_direction() {
let config = Config::default();
assert!(
config.is_known_predicate("supersedes"),
"forward member is known"
);
assert!(
config.is_known_predicate("superseded_by"),
"inverse member is known"
);
assert!(
!config.is_known_predicate("invented"),
"a string in neither direction is unknown"
);
}
#[test]
fn opposite_of_maps_both_directions() {
let config = Config::default();
assert_eq!(
config.opposite_of("supersedes"),
Some("superseded_by"),
"forward maps to its inverse"
);
assert_eq!(
config.opposite_of("superseded_by"),
Some("supersedes"),
"inverse maps to its forward"
);
assert_eq!(
config.opposite_of("references"),
Some("referenced_by"),
"the default predicate still derives referenced_by"
);
assert_eq!(
config.opposite_of("invented"),
None,
"neither direction returns None"
);
}
#[test]
fn no_override_table_means_no_overrides() {
let dir = temp_dir_with(None);
fs::create_dir(dir.path().join(".git")).expect("create .git");
let config = Config::load(dir.path()).expect("load should succeed");
assert!(
config.overrides.is_empty(),
"no [[override]] table means no overrides"
);
}
#[test]
fn override_level_string_parses() {
let dir = temp_dir_with(Some(
"[[override]]\npaths = [\"archive/**\", \"*_bak.md\"]\nstale_references = \"disabled\"\nhint = \"frozen docs\"\n",
));
let config = Config::load(dir.path()).expect("load should succeed");
assert_eq!(config.overrides.len(), 1, "one override entry parses");
let ov = &config.overrides[0];
assert_eq!(
ov.stale_references,
Some(StaleReferenceOverride::Level(
StaleReferencePolicy::Disabled
)),
"a level string parses to a Level override"
);
assert_eq!(ov.bare_paths, None, "bare_paths unset on this entry");
assert_eq!(
ov.hint.as_deref(),
Some("frozen docs"),
"the optional hint round-trips"
);
assert_eq!(
ov.raw_paths,
vec!["archive/**".to_string(), "*_bak.md".to_string()],
"the raw globs round-trip for the label"
);
}
#[test]
fn override_expect_table_parses() {
let dir = temp_dir_with(Some(
"[[override]]\npaths = [\"tickets/sweep/**\"]\nstale_references = { expect = 40 }\n",
));
let config = Config::load(dir.path()).expect("load should succeed");
assert_eq!(
config.overrides[0].stale_references,
Some(StaleReferenceOverride::Expect(40)),
"an inline {{ expect = N }} table parses to an Expect override"
);
}
#[test]
fn override_raise_level_parses() {
let dir = temp_dir_with(Some(
"[[override]]\npaths = [\"strict/**\"]\nbare_paths = \"deny\"\n",
));
let config = Config::load(dir.path()).expect("load should succeed");
assert_eq!(
config.overrides[0].bare_paths,
Some(BarePathOverride::Level(BarePathPolicy::Deny)),
"a raise to deny parses"
);
}
#[test]
fn override_glob_matches_workspace_relative_paths() {
let dir = temp_dir_with(Some(
"[[override]]\npaths = [\"archive/**\", \"*_bak.md\"]\nstale_references = \"disabled\"\n",
));
let config = Config::load(dir.path()).expect("load should succeed");
let ov = &config.overrides[0];
assert!(
ov.matches(Path::new("archive/old/cli.md")),
"a nested path under archive/ matches archive/**"
);
assert!(
ov.matches(Path::new("notes_bak.md")),
"a top-level *_bak.md path matches"
);
assert!(
!ov.matches(Path::new("archived/x.md")),
"a sibling sharing a name prefix must not match archive/**"
);
assert!(
!ov.matches(Path::new("docs/live.md")),
"an unrelated path must not match"
);
}
#[test]
fn effective_policy_applies_matching_level_override() {
let dir = temp_dir_with(Some(
"[[override]]\npaths = [\"archive/**\"]\nstale_references = \"disabled\"\n",
));
let config = Config::load(dir.path()).expect("load should succeed");
assert_eq!(
config
.effective_policy(Path::new("archive/old.md"))
.stale_references,
StaleReferencePolicy::Disabled,
"a matching file resolves the lint to the override level"
);
assert_eq!(
config
.effective_policy(Path::new("docs/live.md"))
.stale_references,
StaleReferencePolicy::Warn,
"a non-matching file keeps the repo-wide level"
);
}
#[test]
fn effective_policy_expect_keeps_base_level() {
let dir = temp_dir_with(Some(
"[[override]]\npaths = [\"sweep/**\"]\nstale_references = { expect = 5 }\n",
));
let config = Config::load(dir.path()).expect("load should succeed");
assert_eq!(
config
.effective_policy(Path::new("sweep/audit.md"))
.stale_references,
StaleReferencePolicy::Warn,
"an expect override leaves the per-file level at its base value"
);
}
#[test]
fn effective_policy_last_match_wins() {
let dir = temp_dir_with(Some(
"[[override]]\npaths = [\"x/**\"]\nbare_paths = \"disabled\"\n\n[[override]]\npaths = [\"x/strict/**\"]\nbare_paths = \"deny\"\n",
));
let config = Config::load(dir.path()).expect("load should succeed");
assert_eq!(
config
.effective_policy(Path::new("x/strict/a.md"))
.bare_paths,
BarePathPolicy::Deny,
"the last matching entry's level wins for an overlapping file"
);
assert_eq!(
config.effective_policy(Path::new("x/other.md")).bare_paths,
BarePathPolicy::Disabled,
"a file matched only by the first entry keeps that entry's level"
);
}
#[test]
fn effective_policy_later_expect_resets_earlier_level() {
let dir = temp_dir_with(Some(
"[[override]]\npaths = [\"a/**\"]\nstale_references = \"disabled\"\n\n[[override]]\npaths = [\"a/**\"]\nstale_references = { expect = 3 }\n",
));
let config = Config::load(dir.path()).expect("load should succeed");
assert_eq!(
config
.effective_policy(Path::new("a/x.md"))
.stale_references,
StaleReferencePolicy::Warn,
"a later expect entry resets the lint to its base level (the earlier freeze loses)"
);
}
#[test]
fn override_with_no_paths_is_invalid() {
let dir = temp_dir_with(Some(
"[[override]]\npaths = []\nstale_references = \"disabled\"\n",
));
let err = Config::load(dir.path()).expect_err("empty paths should fail");
assert!(
err.to_string().contains("at least one path glob"),
"the error names the empty-paths problem: {err}"
);
}
#[test]
fn override_naming_no_lint_is_invalid() {
let dir = temp_dir_with(Some("[[override]]\npaths = [\"x/**\"]\nhint = \"oops\"\n"));
let err = Config::load(dir.path()).expect_err("an override naming no lint should fail");
assert!(
err.to_string()
.contains("neither stale_references nor bare_paths"),
"the error names the no-lint problem: {err}"
);
}
#[test]
fn override_invalid_glob_is_reported() {
let dir = temp_dir_with(Some(
"[[override]]\npaths = [\"a/[\"]\nstale_references = \"disabled\"\n",
));
let err = Config::load(dir.path()).expect_err("a malformed glob should fail");
assert!(
err.to_string().contains("invalid override glob"),
"the error names the bad glob: {err}"
);
}
#[test]
fn override_invalid_level_is_reported() {
let dir = temp_dir_with(Some(
"[[override]]\npaths = [\"x/**\"]\nstale_references = \"loud\"\n",
));
let err = Config::load(dir.path()).expect_err("a bad level should fail");
let msg = err.to_string();
assert!(msg.contains("loud"), "mentions the bad value: {msg}");
assert!(
msg.contains("expect = N"),
"the error names the expect alternative: {msg}"
);
}
#[test]
fn override_expect_zero_is_invalid() {
let dir = temp_dir_with(Some(
"[[override]]\npaths = [\"x/**\"]\nbare_paths = { expect = 0 }\n",
));
let err = Config::load(dir.path()).expect_err("expect = 0 should fail");
assert!(
err.to_string().contains("at least 1"),
"the error names the expect>=1 rule: {err}"
);
}
#[test]
fn artifacts_default_empty() {
let dir = temp_dir_with(None);
fs::create_dir(dir.path().join(".git")).expect("create .git");
let config = Config::load(dir.path()).expect("load should succeed");
assert!(
config.artifacts.is_empty(),
"no [graph] table means no artifacts (the current behaviour)"
);
}
#[test]
fn artifacts_list_parses_into_a_set() {
let dir = temp_dir_with(Some(
"[graph]\nartifacts = [\"AGENTS.md\", \"CLAUDE.md\", \"GEMINI.md\", \"SKILL.md\"]\n",
));
let config = Config::load(dir.path()).expect("load should succeed");
assert_eq!(config.artifacts.len(), 4, "all four artifacts round-trip");
assert!(
config.artifacts.contains("AGENTS.md"),
"the glossary contains AGENTS.md"
);
assert!(
config.artifacts.contains("SKILL.md"),
"the glossary contains SKILL.md"
);
assert!(
!config.artifacts.contains("dir/AGENTS.md"),
"a path-qualified name is not a glossary member (exact match only)"
);
}
#[test]
fn artifacts_dedupe_in_the_set() {
let dir = temp_dir_with(Some(
"[graph]\nartifacts = [\"AGENTS.md\", \"AGENTS.md\", \"CLAUDE.md\"]\n",
));
let config = Config::load(dir.path()).expect("load should succeed");
assert_eq!(
config.artifacts.len(),
2,
"the duplicate AGENTS.md collapses to one set member"
);
}
}