use anyhow::{Context, anyhow, bail};
use glob::glob as fs_glob;
use globset::{GlobBuilder, GlobSet};
use rust_llm_tidy_lint::check::LINT_CODES;
use serde::Deserialize;
use std::collections::{BTreeMap, HashSet};
use std::path::{Path, PathBuf};
pub const KNOWN_FIX_OPS: &[&str] = &["tables", "fences", "links", "reorder", "vis", "lints"];
#[derive(Debug)]
pub struct CompiledConfig {
config_dir: PathBuf,
exclude_files_set: GlobSet,
include_groups: Vec<CompiledRuleGroup>,
exclude_groups: Vec<CompiledRuleGroup>,
post_process: Vec<PostProcessStep>,
links: Option<LinkConfig>,
}
#[derive(Debug, Deserialize, Default)]
#[serde(deny_unknown_fields)] pub struct Config {
#[serde(default)]
pub include: Vec<RuleGroup>,
#[serde(default)]
pub exclude: Vec<RuleGroup>,
#[serde(default)]
pub exclude_files: Vec<String>,
#[serde(default)]
pub post_process: Vec<PostProcessStep>,
#[serde(default)]
pub links: Option<LinkConfig>,
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct FilePolicy {
pub skip: bool,
pub enabled: Option<HashSet<String>>,
pub disabled: HashSet<String>,
}
#[derive(Debug)]
struct CompiledRuleGroup {
set: GlobSet,
rules: Vec<String>,
}
#[derive(Debug, Deserialize, Default, Clone)]
#[serde(deny_unknown_fields)] pub struct LinkConfig {
#[serde(default = "default_one")]
pub min_occurrences: usize,
#[serde(default)]
pub by_extension: BTreeMap<String, usize>,
}
#[derive(Debug, Deserialize, Clone)]
#[serde(deny_unknown_fields)] pub struct PostProcessStep {
pub command: String,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub extensions: Vec<String>,
}
#[derive(Debug, Deserialize, Default, Clone)]
#[serde(deny_unknown_fields)] pub struct RuleGroup {
#[serde(default)]
pub paths: Vec<String>,
#[serde(default)]
pub rules: Vec<String>,
}
impl CompiledConfig {
pub fn post_process_steps(&self) -> &[PostProcessStep] {
&self.post_process
}
pub fn links_min_occurrences_for(&self, ext: &str) -> usize {
match &self.links {
None => 1,
Some(links) => links
.by_extension
.get(ext)
.copied()
.unwrap_or(links.min_occurrences),
}
}
#[cfg(test)]
pub fn config_dir_canonical_for_test(&self) -> &Path {
&self.config_dir
}
pub fn policy_for(&self, file: &Path) -> FilePolicy {
let Ok(canon) = file.canonicalize() else {
return FilePolicy::default();
};
let Some(rel) = canon.strip_prefix(&self.config_dir).ok() else {
return FilePolicy::default();
};
let rel_str = rel.to_string_lossy();
let mut policy = FilePolicy::default();
if self.exclude_files_set.is_match(&*rel_str) {
policy.skip = true;
}
let matched_include: HashSet<String> = self
.include_groups
.iter()
.filter(|g| g.set.is_match(&*rel_str))
.flat_map(|g| g.rules.iter().cloned())
.collect();
let matched_exclude: HashSet<String> = self
.exclude_groups
.iter()
.filter(|g| g.set.is_match(&*rel_str))
.flat_map(|g| g.rules.iter().cloned())
.collect();
if !self.include_groups.is_empty() {
policy.enabled = Some(matched_include);
} else {
policy.disabled = matched_exclude;
policy.enabled = None;
}
policy
}
}
pub fn discover_config_path(arg: Option<&Path>, no_config: bool) -> Option<PathBuf> {
if no_config {
return None;
}
if let Some(p) = arg {
return Some(p.to_path_buf());
}
let cwd = std::env::current_dir().ok()?;
let mut dir: &Path = &cwd;
loop {
let candidate = dir.join(".rust-llm-tidy.yml");
if candidate.is_file() {
return Some(candidate);
}
if dir.join(".git").exists() {
return None;
}
dir = dir.parent()?;
}
}
pub fn load_and_compile(path: &Path) -> anyhow::Result<CompiledConfig> {
let raw = std::fs::read_to_string(path)
.with_context(|| format!("failed to read config {}", path.display()))?;
let config: Config = serde_yml::from_str(&raw)
.with_context(|| format!("failed to parse YAML config {}", path.display()))?;
let config_parent = path
.parent()
.with_context(|| format!("config path {} has no parent", path.display()))?;
let config_dir = if config_parent.as_os_str().is_empty() {
Path::new(".")
} else {
config_parent
}
.canonicalize()
.with_context(|| format!("failed to canonicalize config dir {}", path.display()))?;
if !config.include.is_empty() && !config.exclude.is_empty() {
bail!("cannot use `include` (whitelist) and `exclude` (blacklist) together; pick one");
}
if let Some(links) = &config.links {
if links.min_occurrences < 1 {
bail!(
"links.min_occurrences must be >= 1, got {}",
links.min_occurrences
);
}
for (ext, &count) in &links.by_extension {
if count < 1 {
bail!("links.by_extension.{ext} must be >= 1, got {count}");
}
}
}
let valid = known_rules();
let mut include_groups: Vec<CompiledRuleGroup> = Vec::with_capacity(config.include.len());
for rule in &config.include {
for r in &rule.rules {
if !valid.contains(&r.as_str()) {
bail!(
"unknown rule `{r}` in include.rules; valid rules: {}",
valid.join(", ")
);
}
}
let paths = if rule.paths.is_empty() {
vec!["**".to_string()]
} else {
rule.paths.clone()
};
let set = compile_glob_set(&paths, &config_dir)?;
include_groups.push(CompiledRuleGroup {
set,
rules: rule.rules.clone(),
});
}
let mut exclude_groups: Vec<CompiledRuleGroup> = Vec::with_capacity(config.exclude.len());
for rule in &config.exclude {
for r in &rule.rules {
if !valid.contains(&r.as_str()) {
bail!(
"unknown rule `{r}` in exclude.rules; valid rules: {}",
valid.join(", ")
);
}
}
let paths = if rule.paths.is_empty() {
vec!["**".to_string()]
} else {
rule.paths.clone()
};
let set = compile_glob_set(&paths, &config_dir)?;
exclude_groups.push(CompiledRuleGroup {
set,
rules: rule.rules.clone(),
});
}
let exclude_files_set = compile_glob_set(&config.exclude_files, &config_dir)?;
for pat in &config.exclude_files {
check_pattern_matches(&config_dir, pat)?;
}
for group in &config.include {
for pat in &group.paths {
check_pattern_matches(&config_dir, pat)?;
}
}
for group in &config.exclude {
for pat in &group.paths {
check_pattern_matches(&config_dir, pat)?;
}
}
Ok(CompiledConfig {
config_dir,
exclude_files_set,
include_groups,
exclude_groups,
post_process: config.post_process,
links: config.links,
})
}
pub fn known_rules() -> Vec<&'static str> {
let mut rules: Vec<&'static str> = LINT_CODES.to_vec();
rules.extend_from_slice(KNOWN_FIX_OPS);
rules
}
fn check_pattern_matches(config_dir: &Path, pattern: &str) -> anyhow::Result<()> {
let full = config_dir.join(pattern);
let full_str = full.to_string_lossy().into_owned();
let mut matches = fs_glob(&full_str)
.map_err(|e| anyhow!("invalid glob pattern `{pattern}`: {e}"))?
.filter_map(Result::ok);
if matches.next().is_none() {
bail!(
"config pattern `{pattern}` matched no files under {}",
config_dir.display()
);
}
Ok(())
}
fn compile_glob_set(patterns: &[String], _config_dir: &Path) -> anyhow::Result<GlobSet> {
let mut builder = GlobSet::builder();
for p in patterns {
let g = GlobBuilder::new(p)
.literal_separator(true)
.build()
.with_context(|| format!("invalid glob pattern `{p}`"))?;
builder.add(g);
}
builder
.build()
.map_err(|e| anyhow!("failed to build glob set: {e}"))
}
fn default_one() -> usize {
1
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
static COMPILE_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
fn compile(yaml: &str, files: &[(&str, &str)]) -> CompiledConfig {
let dir = std::env::temp_dir().join(format!(
"rlt-cfg-unit-{}-{}",
std::process::id(),
COMPILE_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed,),
));
std::fs::create_dir_all(&dir).unwrap();
for (name, body) in files {
let p = dir.join(name);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
let mut f = std::fs::File::create(&p).unwrap();
f.write_all(body.as_bytes()).unwrap();
}
let cfg_path = dir.join(".rust-llm-tidy.yml");
std::fs::write(&cfg_path, yaml).unwrap();
let compiled = load_and_compile(&cfg_path).expect("config should compile");
compiled
}
#[test]
fn empty_config_compiles_to_no_op() {
let cc = compile(
"exclude_files: []\n",
&[("src/lib.rs", "pub fn example() {}\n")],
);
let dir = cc.config_dir_canonical_for_test();
let policy = cc.policy_for(&dir.join("src").join("lib.rs"));
assert!(!policy.skip);
assert!(policy.disabled.is_empty());
assert_eq!(policy.enabled, None);
}
#[test]
fn bad_glob_syntax_is_rejected() {
let dir = std::env::temp_dir().join(format!("rlt-cfg-bad-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("a.rs"), "pub fn x() {}\n").unwrap();
let cfg_path = dir.join(".rust-llm-tidy.yml");
std::fs::write(&cfg_path, "exclude_files:\n - \"[unclosed\"\n").unwrap();
let err = load_and_compile(&cfg_path).unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("invalid glob pattern") || msg.contains("glob"),
"bad glob syntax should surface as an error: {msg}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn unknown_rule_is_rejected() {
let dir = std::env::temp_dir().join(format!("rlt-cfg-rule-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("lib.rs"), "pub fn x() {}\n").unwrap();
let cfg_path = dir.join(".rust-llm-tidy.yml");
std::fs::write(
&cfg_path,
"exclude:\n - paths: [\"lib.rs\"]\n rules: [\"BOGUS\"]\n",
)
.unwrap();
let err = load_and_compile(&cfg_path).unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("unknown rule") && msg.contains("BOGUS"),
"unknown rule should be reported: {msg}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn non_matching_pattern_is_rejected() {
let dir = std::env::temp_dir().join(format!("rlt-cfg-nomatch-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let cfg_path = dir.join(".rust-llm-tidy.yml");
std::fs::write(&cfg_path, "exclude_files:\n - \"nope/**\"\n").unwrap();
let err = load_and_compile(&cfg_path).unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("matched no files"),
"non-matching pattern should be reported: {msg}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn policy_for_matches_relative_path() {
let cc = compile(
"exclude_files:\n - \"src/lib.rs\"\nexclude:\n - paths: [\"src/lib.rs\"]\n rules: [\"links\"]\n",
&[("src/lib.rs", "pub fn example() {}\n")],
);
let dir = cc.config_dir_canonical_for_test();
let lib = dir.join("src").join("lib.rs");
let policy = cc.policy_for(&lib);
assert!(policy.skip, "exclude_files should mark the file skipped");
assert!(
policy.disabled.contains("links"),
"exclude should disable `links`: {policy:?}"
);
}
#[test]
fn file_outside_config_dir_returns_empty_policy() {
let cc = compile(
"exclude_files:\n - \"**\"\n",
&[("src/lib.rs", "pub fn example() {}\n")],
);
let outside_dir =
std::env::temp_dir().join(format!("rlt-cfg-outside-dir-{}", std::process::id()));
std::fs::create_dir_all(&outside_dir).unwrap();
let outside = outside_dir.join("outside.rs");
std::fs::write(&outside, "pub fn x() {}\n").unwrap();
let policy = cc.policy_for(&outside);
assert!(!policy.skip);
assert!(policy.disabled.is_empty());
let _ = std::fs::remove_dir_all(&outside_dir);
}
#[test]
fn literal_separator_star_does_not_cross_slash() {
let cc = compile(
"exclude_files:\n - \"*.rs\"\n",
&[
("top.rs", "pub fn top() {}\n"),
("sub/nested.rs", "pub fn nested() {}\n"),
],
);
let dir = cc.config_dir_canonical_for_test();
let top = dir.join("top.rs");
let nested = dir.join("sub").join("nested.rs");
assert!(
cc.policy_for(&top).skip,
"*.rs should match a top-level .rs file"
);
assert!(
!cc.policy_for(&nested).skip,
"*.rs must NOT cross / and match a nested file"
);
}
#[test]
fn known_rules_lists_every_code_and_op() {
let rules = known_rules();
for code in [
"DOC001", "DOC002", "DOC003", "DOC004", "DOC005", "DOC006", "TEST001",
] {
assert!(rules.iter().any(|r| *r == code), "missing lint code {code}");
}
for op in ["tables", "fences", "links", "reorder", "vis", "lints"] {
assert!(rules.iter().any(|r| *r == op), "missing fix/operation {op}");
}
}
#[test]
fn absent_links_defaults_threshold_to_one_for_any_extension() {
let cc = compile("exclude_files: []\n", &[("src/lib.rs", "fn x() {}\n")]);
for ext in ["rs", "md", "py"] {
assert_eq!(
cc.links_min_occurrences_for(ext),
1,
"absent `links` must hoist at threshold 1 for {ext}"
);
}
}
#[test]
fn global_min_occurrences_applies_to_all_extensions() {
let cc = compile("links:\n min_occurrences: 2\n", &[]);
for ext in ["rs", "md"] {
assert_eq!(
cc.links_min_occurrences_for(ext),
2,
"global `min_occurrences: 2` must apply to {ext}"
);
}
}
#[test]
fn by_extension_overrides_only_the_named_extension() {
let cc = compile(
"links:\n min_occurrences: 4\n by_extension:\n rs: 3\n",
&[],
);
assert_eq!(cc.links_min_occurrences_for("rs"), 3, "rs override wins");
assert_eq!(
cc.links_min_occurrences_for("md"),
4,
"md falls back to the global threshold"
);
}
#[test]
fn by_extension_without_global_falls_back_to_one() {
let cc = compile("links:\n by_extension:\n rs: 3\n", &[]);
assert_eq!(cc.links_min_occurrences_for("rs"), 3);
assert_eq!(cc.links_min_occurrences_for("md"), 1);
}
#[test]
fn unknown_extension_keys_are_accepted_and_stored() {
let cc = compile("links:\n by_extension:\n py: 2\n go: 3\n", &[]);
assert_eq!(cc.links_min_occurrences_for("py"), 2);
assert_eq!(cc.links_min_occurrences_for("go"), 3);
assert_eq!(cc.links_min_occurrences_for("rs"), 1, "unlisted falls back");
}
#[test]
fn min_occurrences_zero_is_rejected() {
let dir = std::env::temp_dir().join(format!("rlt-cfg-min0-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let cfg_path = dir.join(".rust-llm-tidy.yml");
std::fs::write(&cfg_path, "links:\n min_occurrences: 0\n").unwrap();
let err = load_and_compile(&cfg_path).unwrap_err();
assert!(
format!("{err:#}").contains("links.min_occurrences must be >= 1"),
"zero threshold must be rejected: {err:#}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn by_extension_zero_is_rejected() {
let dir = std::env::temp_dir().join(format!("rlt-cfg-bext0-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let cfg_path = dir.join(".rust-llm-tidy.yml");
std::fs::write(&cfg_path, "links:\n by_extension:\n rs: 0\n").unwrap();
let err = load_and_compile(&cfg_path).unwrap_err();
assert!(
format!("{err:#}").contains("links.by_extension.rs must be >= 1"),
"zero per-extension threshold must be rejected: {err:#}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn non_integer_links_value_is_rejected() {
let dir = std::env::temp_dir().join(format!("rlt-cfg-nonint-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let cfg_path = dir.join(".rust-llm-tidy.yml");
std::fs::write(&cfg_path, "links:\n min_occurrences: many\n").unwrap();
let err = load_and_compile(&cfg_path).unwrap_err();
assert!(
format!("{err:#}").contains("failed to parse YAML config"),
"non-integer threshold must fail at parse: {err:#}"
);
let _ = std::fs::remove_dir_all(&dir);
}
}