use std::collections::HashMap;
use std::path::{Path, PathBuf};
#[cfg(feature = "test-utils")]
pub mod testing;
#[cfg(feature = "config")]
pub mod config;
pub use config_types::{IgnoreEntry, RuleOverride};
mod config_types {
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "config", derive(serde::Deserialize))]
pub enum RuleOverride {
#[cfg_attr(feature = "config", serde(rename = "error"))]
Error,
#[cfg_attr(feature = "config", serde(rename = "warning"))]
Warning,
#[cfg_attr(feature = "config", serde(rename = "off"))]
Off,
}
#[derive(Debug, Clone)]
pub struct IgnoreEntry {
pub path: String,
pub rules: Vec<String>,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
pub enum Difficulty {
Easy,
Normal,
#[default]
Hard,
Painful,
}
impl std::fmt::Display for Difficulty {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Difficulty::Easy => write!(f, "easy"),
Difficulty::Normal => write!(f, "normal"),
Difficulty::Hard => write!(f, "hard"),
Difficulty::Painful => write!(f, "painful"),
}
}
}
impl std::str::FromStr for Difficulty {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"easy" => Ok(Difficulty::Easy),
"normal" => Ok(Difficulty::Normal),
"hard" => Ok(Difficulty::Hard),
"painful" => Ok(Difficulty::Painful),
other => Err(format!(
"unknown difficulty '{other}'; expected easy, normal, hard, or painful"
)),
}
}
}
#[derive(Debug, Clone)]
pub struct RunConfig {
pub difficulty: Difficulty,
pub rule_overrides: HashMap<String, RuleOverride>,
pub ignores: Vec<IgnoreEntry>,
}
impl Default for RunConfig {
fn default() -> Self {
Self {
difficulty: Difficulty::Hard,
rule_overrides: HashMap::new(),
ignores: Vec::new(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Severity {
Error,
Warning,
}
impl std::fmt::Display for Severity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Severity::Error => write!(f, "error"),
Severity::Warning => write!(f, "warning"),
}
}
}
#[derive(Debug, Clone)]
pub struct Diagnostic {
pub path: PathBuf,
pub line: usize,
pub col: usize,
pub severity: Severity,
pub message: String,
pub rule: &'static str,
pub difficulty: Difficulty,
}
impl Diagnostic {
pub fn error(
path: impl Into<PathBuf>,
line: usize,
col: usize,
message: impl Into<String>,
) -> Self {
Self {
path: path.into(),
line,
col,
severity: Severity::Error,
message: message.into(),
rule: "",
difficulty: Difficulty::Easy,
}
}
pub fn warning(
path: impl Into<PathBuf>,
line: usize,
col: usize,
message: impl Into<String>,
) -> Self {
Self {
path: path.into(),
line,
col,
severity: Severity::Warning,
message: message.into(),
rule: "",
difficulty: Difficulty::Easy,
}
}
pub fn with_rule(mut self, rule: &'static str, difficulty: Difficulty) -> Self {
self.rule = rule;
self.difficulty = difficulty;
self
}
pub fn gnu_format(&self) -> String {
if self.rule.is_empty() {
format!(
"{}:{}:{}: {}: {}",
self.path.display(),
self.line,
self.col,
self.severity,
self.message,
)
} else {
format!(
"{}:{}:{}: {}[{}]: {}",
self.path.display(),
self.line,
self.col,
self.severity,
self.rule,
self.message,
)
}
}
}
pub trait Validator: Send + Sync {
fn patterns(&self) -> &[&str];
fn validate(&self, path: &Path, src: &str) -> Vec<Diagnostic>;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OutputFormat {
Gnu,
Json,
Pretty,
}
pub struct RunResult {
pub diagnostics: Vec<Diagnostic>,
pub files_checked: usize,
}
pub fn run_on(
files: impl IntoIterator<Item = (PathBuf, String)>,
validators: &[Box<dyn Validator>],
config: &RunConfig,
) -> RunResult {
let mut diagnostics = Vec::new();
let mut files_checked = 0;
for (path, src) in files {
let matched = find_validators(&path, validators);
if matched.is_empty() {
continue;
}
files_checked += 1;
for validator in matched {
diagnostics.extend(validator.validate(&path, &src));
}
}
diagnostics.retain(|d| d.rule.is_empty() || d.difficulty <= config.difficulty);
diagnostics.retain(|d| {
if d.rule.is_empty() {
return true; }
let path_str = d.path.to_string_lossy();
for entry in &config.ignores {
let matches_path = path_str.ends_with(&entry.path)
|| path_str.ends_with(&entry.path.replace('/', std::path::MAIN_SEPARATOR_STR));
if matches_path && (entry.rules.is_empty() || entry.rules.iter().any(|r| r == d.rule)) {
return false;
}
}
true
});
let mut kept = Vec::with_capacity(diagnostics.len());
for mut d in diagnostics {
if !d.rule.is_empty() {
match config.rule_overrides.get(d.rule) {
Some(RuleOverride::Off) => continue,
Some(RuleOverride::Error) => d.severity = Severity::Error,
Some(RuleOverride::Warning) => d.severity = Severity::Warning,
None => {}
}
}
kept.push(d);
}
RunResult {
diagnostics: kept,
files_checked,
}
}
pub fn run(roots: &[PathBuf], validators: &[Box<dyn Validator>], config: &RunConfig) -> RunResult {
let mut read_errors: Vec<Diagnostic> = Vec::new();
let files: Vec<(PathBuf, String)> = collect_paths(roots)
.into_iter()
.filter(|path| !find_validators(path, validators).is_empty())
.filter_map(|path| {
let bytes = match std::fs::read(&path) {
Ok(b) => b,
Err(e) => {
read_errors.push(Diagnostic::error(
&path,
1,
1,
format!("could not read file: {e}"),
));
return None;
}
};
String::from_utf8(bytes).ok().map(|src| (path, src))
})
.collect();
let mut result = run_on(files, validators, config);
read_errors.extend(result.diagnostics);
result.diagnostics = read_errors;
result
}
const SKIP_DIRS: &[&str] = &["target", ".git", "node_modules", "plugins", ".maestro"];
fn collect_paths(roots: &[PathBuf]) -> Vec<PathBuf> {
let mut out = Vec::new();
for root in roots {
if root.is_file() {
out.push(root.clone());
} else if root.is_dir() {
for entry in walkdir::WalkDir::new(root)
.follow_links(false)
.into_iter()
.filter_entry(|e| {
if e.file_type().is_dir() {
let name = e.file_name().to_string_lossy();
!SKIP_DIRS.iter().any(|skip| *skip == name.as_ref())
} else {
true
}
})
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_file())
{
out.push(entry.into_path());
}
}
}
out
}
fn find_validators<'a>(
path: &Path,
validators: &'a [Box<dyn Validator>],
) -> Vec<&'a dyn Validator> {
let comps: Vec<_> = path.components().collect();
let suffixes: Vec<String> = (0..comps.len())
.map(|i| {
comps[i..]
.iter()
.collect::<PathBuf>()
.to_string_lossy()
.into_owned()
})
.collect();
validators
.iter()
.filter(|v| {
v.patterns()
.iter()
.any(|p| suffixes.iter().any(|s| glob_match(p, s)))
})
.map(|v| v.as_ref())
.collect()
}
fn glob_match(pattern: &str, path: &str) -> bool {
glob_match_inner(pattern.as_bytes(), path.as_bytes())
}
fn glob_match_inner(pat: &[u8], s: &[u8]) -> bool {
match (pat.first(), s.first()) {
(None, None) => true,
(None, Some(_)) => false,
(Some(b'*'), _) => {
if pat.get(1) == Some(&b'*') {
let rest_pat = pat.get(2..).unwrap_or(b"");
let rest_pat = rest_pat.strip_prefix(b"/").unwrap_or(rest_pat);
for i in 0..=s.len() {
if glob_match_inner(rest_pat, &s[i..]) {
return true;
}
}
false
} else {
let rest_pat = &pat[1..];
for i in 0..=s.len() {
if s[..i].contains(&b'/') {
break;
}
if glob_match_inner(rest_pat, &s[i..]) {
return true;
}
}
false
}
}
(Some(&pc), Some(&sc)) => {
if pc == sc {
glob_match_inner(&pat[1..], &s[1..])
} else {
false
}
}
(Some(_), None) => false,
}
}
pub fn format_gnu(diagnostics: &[Diagnostic]) -> String {
diagnostics
.iter()
.map(|d| d.gnu_format())
.collect::<Vec<_>>()
.join("\n")
}
pub fn format_pretty(diagnostics: &[Diagnostic], color: bool) -> String {
use std::collections::BTreeMap;
let bold = if color { "\x1b[1m" } else { "" };
let dim = if color { "\x1b[2m" } else { "" };
let red = if color { "\x1b[31m" } else { "" };
let yellow = if color { "\x1b[33m" } else { "" };
let cyan = if color { "\x1b[36m" } else { "" };
let reset = if color { "\x1b[0m" } else { "" };
let mut by_file: BTreeMap<String, Vec<&Diagnostic>> = BTreeMap::new();
for d in diagnostics {
by_file
.entry(d.path.display().to_string())
.or_default()
.push(d);
}
let cwd = std::env::current_dir()
.ok()
.map(|p| p.display().to_string() + "/");
let shorten = |p: &str| -> String {
if let Some(ref prefix) = cwd
&& let Some(rel) = p.strip_prefix(prefix.as_str())
{
return rel.to_string();
}
p.to_string()
};
let mut out = String::new();
let mut total_errors: usize = 0;
let mut total_warnings: usize = 0;
for (path, diags) in &by_file {
out.push_str(&format!("{bold}{cyan}{}{reset}\n", shorten(path)));
for d in diags {
let (sev_color, sev_label) = match d.severity {
Severity::Error => (red, "error"),
Severity::Warning => (yellow, "warning"),
};
let rule_hint = if d.rule.is_empty() {
String::new()
} else {
format!(" {dim}[{}]{reset}", d.rule)
};
out.push_str(&format!(
" {sev_color}{bold}{sev_label}{reset} {}{rule_hint}\n",
d.message,
));
match d.severity {
Severity::Error => total_errors += 1,
Severity::Warning => total_warnings += 1,
}
}
out.push('\n');
}
match (total_errors, total_warnings) {
(0, 0) => {}
(e, 0) => out.push_str(&format!(
"{red}{bold}✖ {e} error{}{reset}\n",
if e == 1 { "" } else { "s" }
)),
(0, w) => out.push_str(&format!(
"{yellow}{bold}⚠ {w} warning{}{reset}\n",
if w == 1 { "" } else { "s" }
)),
(e, w) => out.push_str(&format!(
"{red}{bold}✖ {e} error{}{reset} {yellow}{bold}⚠ {w} warning{}{reset}\n",
if e == 1 { "" } else { "s" },
if w == 1 { "" } else { "s" },
)),
}
out
}
pub fn format_json(diagnostics: &[Diagnostic]) -> String {
let entries: Vec<serde_json::Value> = diagnostics
.iter()
.map(|d| {
serde_json::json!({
"path": d.path.display().to_string(),
"line": d.line,
"col": d.col,
"severity": d.severity.to_string(),
"rule": d.rule,
"difficulty": d.difficulty.to_string(),
"message": d.message,
})
})
.collect();
serde_json::to_string_pretty(&entries).unwrap_or_else(|_| "[]".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn glob_literal() {
assert!(glob_match("AGENTS.md", "AGENTS.md"));
assert!(!glob_match("AGENTS.md", "agents.md"));
}
#[test]
fn glob_star() {
assert!(glob_match("*.md", "README.md"));
assert!(!glob_match("*.md", "src/README.md"));
}
#[test]
fn glob_double_star() {
assert!(glob_match(
".claude/agents/**/*.md",
".claude/agents/foo/bar.md"
));
assert!(glob_match(
".claude/agents/**/*.md",
".claude/agents/bar.md"
));
}
fn easy_error(path: &str, rule: &'static str) -> Diagnostic {
Diagnostic::error(PathBuf::from(path), 1, 1, "msg").with_rule(rule, Difficulty::Easy)
}
fn painful_warning(path: &str, rule: &'static str) -> Diagnostic {
Diagnostic::warning(PathBuf::from(path), 1, 1, "msg").with_rule(rule, Difficulty::Painful)
}
fn unclassified(path: &str) -> Diagnostic {
Diagnostic::error(PathBuf::from(path), 1, 1, "unclassified")
}
fn run_filters(diagnostics: Vec<Diagnostic>, config: RunConfig) -> Vec<Diagnostic> {
struct Shim(Vec<Diagnostic>);
impl Validator for Shim {
fn patterns(&self) -> &[&str] {
&["__shim__"]
}
fn validate(&self, _: &Path, _: &str) -> Vec<Diagnostic> {
self.0.clone()
}
}
let files = vec![(PathBuf::from("__shim__"), String::new())];
let validators: Vec<Box<dyn Validator>> = vec![Box::new(Shim(diagnostics))];
run_on(files, &validators, &config).diagnostics
}
#[test]
fn difficulty_filter_drops_painful_at_hard() {
let diags = vec![painful_warning(
".claude/settings.json",
"claude/settings/broad-read",
)];
let result = run_filters(diags, RunConfig::default()); assert!(result.is_empty());
}
#[test]
fn difficulty_filter_passes_painful_at_painful() {
let diags = vec![painful_warning(
".claude/settings.json",
"claude/settings/broad-read",
)];
let result = run_filters(
diags,
RunConfig {
difficulty: Difficulty::Painful,
..RunConfig::default()
},
);
assert_eq!(result.len(), 1);
}
#[test]
fn ignore_filter_suppresses_matching_rule_for_matching_path() {
let diags = vec![easy_error(
".claude/settings.local.json",
"claude/settings/broad-read",
)];
let config = RunConfig {
ignores: vec![IgnoreEntry {
path: ".claude/settings.local.json".into(),
rules: vec!["claude/settings/broad-read".into()],
}],
..RunConfig::default()
};
assert!(run_filters(diags, config).is_empty());
}
#[test]
fn ignore_filter_empty_rules_suppresses_all_for_path() {
let diags = vec![
easy_error(".claude/settings.local.json", "claude/settings/broad-read"),
easy_error(
".claude/settings.local.json",
"claude/settings/sshpass-credential",
),
];
let config = RunConfig {
ignores: vec![IgnoreEntry {
path: ".claude/settings.local.json".into(),
rules: vec![],
}],
..RunConfig::default()
};
assert!(run_filters(diags, config).is_empty());
}
#[test]
fn ignore_filter_does_not_suppress_different_path() {
let diags = vec![easy_error(
".claude/settings.json",
"claude/settings/broad-read",
)];
let config = RunConfig {
ignores: vec![IgnoreEntry {
path: ".claude/settings.local.json".into(),
rules: vec!["claude/settings/broad-read".into()],
}],
..RunConfig::default()
};
assert_eq!(run_filters(diags, config).len(), 1);
}
#[test]
fn override_off_drops_diagnostic() {
let diags = vec![easy_error(
".claude/settings.json",
"claude/settings/unknown-key",
)];
let config = RunConfig {
rule_overrides: [("claude/settings/unknown-key".into(), RuleOverride::Off)]
.into_iter()
.collect(),
..RunConfig::default()
};
assert!(run_filters(diags, config).is_empty());
}
#[test]
fn override_warning_demotes_error() {
let diags = vec![easy_error(
".claude/settings.json",
"claude/settings/unknown-key",
)];
let config = RunConfig {
rule_overrides: [("claude/settings/unknown-key".into(), RuleOverride::Warning)]
.into_iter()
.collect(),
..RunConfig::default()
};
let result = run_filters(diags, config);
assert_eq!(result.len(), 1);
assert_eq!(result[0].severity, Severity::Warning);
}
#[test]
fn override_error_promotes_warning() {
let diags = vec![
Diagnostic::warning(PathBuf::from(".claude/settings.json"), 1, 1, "msg")
.with_rule("claude/settings/skip-dangerous-mode", Difficulty::Hard),
];
let config = RunConfig {
rule_overrides: [(
"claude/settings/skip-dangerous-mode".into(),
RuleOverride::Error,
)]
.into_iter()
.collect(),
..RunConfig::default()
};
let result = run_filters(diags, config);
assert_eq!(result.len(), 1);
assert_eq!(result[0].severity, Severity::Error);
}
#[test]
fn unclassified_passes_all_filters() {
let diags = vec![unclassified("some/path")];
let config = RunConfig {
difficulty: Difficulty::Easy,
rule_overrides: [("".into(), RuleOverride::Off)].into_iter().collect(),
ignores: vec![IgnoreEntry {
path: "some/path".into(),
rules: vec![],
}],
};
let result = run_filters(diags, config);
assert_eq!(result.len(), 1);
}
#[test]
fn filter_order_difficulty_before_ignore() {
let diags = vec![painful_warning(
".claude/settings.json",
"claude/settings/broad-read",
)];
let config = RunConfig {
difficulty: Difficulty::Hard,
..RunConfig::default()
};
assert!(run_filters(diags, config).is_empty());
}
}