1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4#[cfg(feature = "test-utils")]
5pub mod testing;
6
7#[cfg(feature = "config")]
8pub mod config;
9pub use config_types::{IgnoreEntry, RuleOverride};
10
11mod config_types {
12 #[derive(Debug, Clone, PartialEq, Eq)]
14 #[cfg_attr(feature = "config", derive(serde::Deserialize))]
15 pub enum RuleOverride {
16 #[cfg_attr(feature = "config", serde(rename = "error"))]
17 Error,
18 #[cfg_attr(feature = "config", serde(rename = "warning"))]
19 Warning,
20 #[cfg_attr(feature = "config", serde(rename = "off"))]
21 Off,
22 }
23
24 #[derive(Debug, Clone)]
27 pub struct IgnoreEntry {
28 pub path: String,
29 pub rules: Vec<String>,
30 }
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
42pub enum Difficulty {
43 Easy,
46 Normal,
49 #[default]
52 Hard,
53 Painful,
56}
57
58impl std::fmt::Display for Difficulty {
59 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60 match self {
61 Difficulty::Easy => write!(f, "easy"),
62 Difficulty::Normal => write!(f, "normal"),
63 Difficulty::Hard => write!(f, "hard"),
64 Difficulty::Painful => write!(f, "painful"),
65 }
66 }
67}
68
69impl std::str::FromStr for Difficulty {
70 type Err = String;
71 fn from_str(s: &str) -> Result<Self, Self::Err> {
72 match s {
73 "easy" => Ok(Difficulty::Easy),
74 "normal" => Ok(Difficulty::Normal),
75 "hard" => Ok(Difficulty::Hard),
76 "painful" => Ok(Difficulty::Painful),
77 other => Err(format!(
78 "unknown difficulty '{other}'; expected easy, normal, hard, or painful"
79 )),
80 }
81 }
82}
83
84#[derive(Debug, Clone)]
90pub struct RunConfig {
91 pub difficulty: Difficulty,
94 pub rule_overrides: HashMap<String, RuleOverride>,
97 pub ignores: Vec<IgnoreEntry>,
101}
102
103impl Default for RunConfig {
104 fn default() -> Self {
105 Self {
106 difficulty: Difficulty::Hard,
107 rule_overrides: HashMap::new(),
108 ignores: Vec::new(),
109 }
110 }
111}
112
113#[derive(Debug, Clone, PartialEq, Eq)]
118pub enum Severity {
119 Error,
120 Warning,
121}
122
123impl std::fmt::Display for Severity {
124 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
125 match self {
126 Severity::Error => write!(f, "error"),
127 Severity::Warning => write!(f, "warning"),
128 }
129 }
130}
131
132#[derive(Debug, Clone)]
133pub struct Diagnostic {
134 pub path: PathBuf,
135 pub line: usize,
136 pub col: usize,
137 pub severity: Severity,
138 pub message: String,
139 pub rule: &'static str,
142 pub difficulty: Difficulty,
144}
145
146impl Diagnostic {
147 pub fn error(
148 path: impl Into<PathBuf>,
149 line: usize,
150 col: usize,
151 message: impl Into<String>,
152 ) -> Self {
153 Self {
154 path: path.into(),
155 line,
156 col,
157 severity: Severity::Error,
158 message: message.into(),
159 rule: "",
160 difficulty: Difficulty::Easy,
161 }
162 }
163
164 pub fn warning(
165 path: impl Into<PathBuf>,
166 line: usize,
167 col: usize,
168 message: impl Into<String>,
169 ) -> Self {
170 Self {
171 path: path.into(),
172 line,
173 col,
174 severity: Severity::Warning,
175 message: message.into(),
176 rule: "",
177 difficulty: Difficulty::Easy,
178 }
179 }
180
181 pub fn with_rule(mut self, rule: &'static str, difficulty: Difficulty) -> Self {
183 self.rule = rule;
184 self.difficulty = difficulty;
185 self
186 }
187
188 pub fn gnu_format(&self) -> String {
189 if self.rule.is_empty() {
190 format!(
191 "{}:{}:{}: {}: {}",
192 self.path.display(),
193 self.line,
194 self.col,
195 self.severity,
196 self.message,
197 )
198 } else {
199 format!(
200 "{}:{}:{}: {}[{}]: {}",
201 self.path.display(),
202 self.line,
203 self.col,
204 self.severity,
205 self.rule,
206 self.message,
207 )
208 }
209 }
210}
211
212pub trait Validator: Send + Sync {
217 fn patterns(&self) -> &[&str];
219
220 fn validate(&self, path: &Path, src: &str) -> Vec<Diagnostic>;
222
223 fn validate_batch(&self, _files: &[(PathBuf, String)]) -> Vec<Diagnostic> {
228 vec![]
229 }
230}
231
232#[derive(Debug, Clone, Copy, PartialEq, Eq)]
237pub enum OutputFormat {
238 Gnu,
239 Json,
240 Pretty,
241}
242
243pub struct RunResult {
248 pub diagnostics: Vec<Diagnostic>,
249 pub files_checked: usize,
250}
251
252pub fn run_on(
258 files: impl IntoIterator<Item = (PathBuf, String)>,
259 validators: &[Box<dyn Validator>],
260 config: &RunConfig,
261) -> RunResult {
262 let mut diagnostics = Vec::new();
263 let mut files_checked = 0;
264
265 let all_files: Vec<(PathBuf, String)> = files.into_iter().collect();
267
268 for (path, src) in &all_files {
269 let matched = find_validators(path, validators);
270 if matched.is_empty() {
271 continue;
272 }
273 files_checked += 1;
274 for validator in matched {
275 diagnostics.extend(validator.validate(path, src));
276 }
277 }
278
279 for validator in validators {
281 let claimed: Vec<(PathBuf, String)> = all_files
282 .iter()
283 .filter(|(path, _)| {
284 !find_validators(path, validators).is_empty()
285 && find_validators(path, validators).iter().any(|v| {
286 std::ptr::eq(
287 *v as *const dyn Validator,
288 validator.as_ref() as *const dyn Validator,
289 )
290 })
291 })
292 .cloned()
293 .collect();
294 diagnostics.extend(validator.validate_batch(&claimed));
295 }
296
297 diagnostics.retain(|d| d.rule.is_empty() || d.difficulty <= config.difficulty);
299
300 diagnostics.retain(|d| {
302 if d.rule.is_empty() {
303 return true; }
305 let path_str = d.path.to_string_lossy();
306 for entry in &config.ignores {
307 let matches_path = path_str.ends_with(&entry.path)
308 || path_str.ends_with(&entry.path.replace('/', std::path::MAIN_SEPARATOR_STR));
309 if matches_path && (entry.rules.is_empty() || entry.rules.iter().any(|r| r == d.rule)) {
310 return false;
311 }
312 }
313 true
314 });
315
316 let mut kept = Vec::with_capacity(diagnostics.len());
318 for mut d in diagnostics {
319 if !d.rule.is_empty() {
320 match config.rule_overrides.get(d.rule) {
321 Some(RuleOverride::Off) => continue,
322 Some(RuleOverride::Error) => d.severity = Severity::Error,
323 Some(RuleOverride::Warning) => d.severity = Severity::Warning,
324 None => {}
325 }
326 }
327 kept.push(d);
328 }
329
330 RunResult {
331 diagnostics: kept,
332 files_checked,
333 }
334}
335
336pub fn run(roots: &[PathBuf], validators: &[Box<dyn Validator>], config: &RunConfig) -> RunResult {
341 let mut read_errors: Vec<Diagnostic> = Vec::new();
342
343 let files: Vec<(PathBuf, String)> = collect_paths(roots)
344 .into_iter()
345 .filter(|path| !find_validators(path, validators).is_empty())
346 .filter_map(|path| {
347 let bytes = match std::fs::read(&path) {
348 Ok(b) => b,
349 Err(e) => {
350 read_errors.push(Diagnostic::error(
351 &path,
352 1,
353 1,
354 format!("could not read file: {e}"),
355 ));
356 return None;
357 }
358 };
359 String::from_utf8(bytes).ok().map(|src| (path, src))
361 })
362 .collect();
363
364 let mut result = run_on(files, validators, config);
365 read_errors.extend(result.diagnostics);
367 result.diagnostics = read_errors;
368 result
369}
370
371const SKIP_DIRS: &[&str] = &["target", ".git", "node_modules", "plugins", ".maestro"];
373
374fn collect_paths(roots: &[PathBuf]) -> Vec<PathBuf> {
375 let mut out = Vec::new();
376 for root in roots {
377 if root.is_file() {
378 out.push(root.clone());
379 } else if root.is_dir() {
380 for entry in walkdir::WalkDir::new(root)
381 .follow_links(false)
382 .into_iter()
383 .filter_entry(|e| {
384 if e.file_type().is_dir() {
385 let name = e.file_name().to_string_lossy();
386 !SKIP_DIRS.iter().any(|skip| *skip == name.as_ref())
387 } else {
388 true
389 }
390 })
391 .filter_map(|e| e.ok())
392 .filter(|e| e.file_type().is_file())
393 {
394 out.push(entry.into_path());
395 }
396 }
397 }
398 out
399}
400
401fn find_validators<'a>(
402 path: &Path,
403 validators: &'a [Box<dyn Validator>],
404) -> Vec<&'a dyn Validator> {
405 let comps: Vec<_> = path.components().collect();
409 let suffixes: Vec<String> = (0..comps.len())
410 .map(|i| {
411 comps[i..]
412 .iter()
413 .collect::<PathBuf>()
414 .to_string_lossy()
415 .into_owned()
416 })
417 .collect();
418
419 validators
420 .iter()
421 .filter(|v| {
422 v.patterns()
423 .iter()
424 .any(|p| suffixes.iter().any(|s| glob_match(p, s)))
425 })
426 .map(|v| v.as_ref())
427 .collect()
428}
429
430fn glob_match(pattern: &str, path: &str) -> bool {
432 glob_match_inner(pattern.as_bytes(), path.as_bytes())
433}
434
435fn glob_match_inner(pat: &[u8], s: &[u8]) -> bool {
436 match (pat.first(), s.first()) {
437 (None, None) => true,
438 (None, Some(_)) => false,
439 (Some(b'*'), _) => {
440 if pat.get(1) == Some(&b'*') {
442 let rest_pat = pat.get(2..).unwrap_or(b"");
443 let rest_pat = rest_pat.strip_prefix(b"/").unwrap_or(rest_pat);
445 for i in 0..=s.len() {
447 if glob_match_inner(rest_pat, &s[i..]) {
448 return true;
449 }
450 }
451 false
452 } else {
453 let rest_pat = &pat[1..];
454 for i in 0..=s.len() {
456 if s[..i].contains(&b'/') {
457 break;
458 }
459 if glob_match_inner(rest_pat, &s[i..]) {
460 return true;
461 }
462 }
463 false
464 }
465 }
466 (Some(&pc), Some(&sc)) => {
467 if pc == sc {
468 glob_match_inner(&pat[1..], &s[1..])
469 } else {
470 false
471 }
472 }
473 (Some(_), None) => false,
474 }
475}
476
477pub fn format_gnu(diagnostics: &[Diagnostic]) -> String {
482 diagnostics
483 .iter()
484 .map(|d| d.gnu_format())
485 .collect::<Vec<_>>()
486 .join("\n")
487}
488
489pub fn format_pretty(diagnostics: &[Diagnostic], color: bool) -> String {
493 use std::collections::BTreeMap;
494
495 let bold = if color { "\x1b[1m" } else { "" };
497 let dim = if color { "\x1b[2m" } else { "" };
498 let red = if color { "\x1b[31m" } else { "" };
499 let yellow = if color { "\x1b[33m" } else { "" };
500 let cyan = if color { "\x1b[36m" } else { "" };
501 let reset = if color { "\x1b[0m" } else { "" };
502
503 let mut by_file: BTreeMap<String, Vec<&Diagnostic>> = BTreeMap::new();
505 for d in diagnostics {
506 by_file
507 .entry(d.path.display().to_string())
508 .or_default()
509 .push(d);
510 }
511
512 let cwd = std::env::current_dir()
514 .ok()
515 .map(|p| p.display().to_string() + "/");
516
517 let shorten = |p: &str| -> String {
518 if let Some(ref prefix) = cwd
519 && let Some(rel) = p.strip_prefix(prefix.as_str())
520 {
521 return rel.to_string();
522 }
523 p.to_string()
524 };
525
526 let mut out = String::new();
527 let mut total_errors: usize = 0;
528 let mut total_warnings: usize = 0;
529
530 for (path, diags) in &by_file {
531 out.push_str(&format!("{bold}{cyan}{}{reset}\n", shorten(path)));
533
534 for d in diags {
535 let (sev_color, sev_label) = match d.severity {
536 Severity::Error => (red, "error"),
537 Severity::Warning => (yellow, "warning"),
538 };
539
540 let rule_hint = if d.rule.is_empty() {
541 String::new()
542 } else {
543 format!(" {dim}[{}]{reset}", d.rule)
544 };
545
546 out.push_str(&format!(
547 " {sev_color}{bold}{sev_label}{reset} {}{rule_hint}\n",
548 d.message,
549 ));
550
551 match d.severity {
552 Severity::Error => total_errors += 1,
553 Severity::Warning => total_warnings += 1,
554 }
555 }
556 out.push('\n');
557 }
558
559 match (total_errors, total_warnings) {
561 (0, 0) => {}
562 (e, 0) => out.push_str(&format!(
563 "{red}{bold}✖ {e} error{}{reset}\n",
564 if e == 1 { "" } else { "s" }
565 )),
566 (0, w) => out.push_str(&format!(
567 "{yellow}{bold}⚠ {w} warning{}{reset}\n",
568 if w == 1 { "" } else { "s" }
569 )),
570 (e, w) => out.push_str(&format!(
571 "{red}{bold}✖ {e} error{}{reset} {yellow}{bold}⚠ {w} warning{}{reset}\n",
572 if e == 1 { "" } else { "s" },
573 if w == 1 { "" } else { "s" },
574 )),
575 }
576
577 out
578}
579
580pub fn format_json(diagnostics: &[Diagnostic]) -> String {
581 let entries: Vec<serde_json::Value> = diagnostics
582 .iter()
583 .map(|d| {
584 serde_json::json!({
585 "path": d.path.display().to_string(),
586 "line": d.line,
587 "col": d.col,
588 "severity": d.severity.to_string(),
589 "rule": d.rule,
590 "difficulty": d.difficulty.to_string(),
591 "message": d.message,
592 })
593 })
594 .collect();
595 serde_json::to_string_pretty(&entries).unwrap_or_else(|_| "[]".to_string())
596}
597
598#[cfg(test)]
603mod tests {
604 use super::*;
605 use std::path::PathBuf;
606
607 #[test]
608 fn glob_literal() {
609 assert!(glob_match("AGENTS.md", "AGENTS.md"));
610 assert!(!glob_match("AGENTS.md", "agents.md"));
611 }
612
613 #[test]
614 fn glob_star() {
615 assert!(glob_match("*.md", "README.md"));
616 assert!(!glob_match("*.md", "src/README.md"));
617 }
618
619 #[test]
620 fn glob_double_star() {
621 assert!(glob_match(
622 ".claude/agents/**/*.md",
623 ".claude/agents/foo/bar.md"
624 ));
625 assert!(glob_match(
626 ".claude/agents/**/*.md",
627 ".claude/agents/bar.md"
628 ));
629 }
630
631 fn easy_error(path: &str, rule: &'static str) -> Diagnostic {
636 Diagnostic::error(PathBuf::from(path), 1, 1, "msg").with_rule(rule, Difficulty::Easy)
637 }
638
639 fn painful_warning(path: &str, rule: &'static str) -> Diagnostic {
640 Diagnostic::warning(PathBuf::from(path), 1, 1, "msg").with_rule(rule, Difficulty::Painful)
641 }
642
643 fn unclassified(path: &str) -> Diagnostic {
644 Diagnostic::error(PathBuf::from(path), 1, 1, "unclassified")
645 }
646
647 fn run_filters(diagnostics: Vec<Diagnostic>, config: RunConfig) -> Vec<Diagnostic> {
648 struct Shim(Vec<Diagnostic>);
653 impl Validator for Shim {
654 fn patterns(&self) -> &[&str] {
655 &["__shim__"]
656 }
657 fn validate(&self, _: &Path, _: &str) -> Vec<Diagnostic> {
658 self.0.clone()
659 }
660 }
661 let files = vec![(PathBuf::from("__shim__"), String::new())];
662 let validators: Vec<Box<dyn Validator>> = vec![Box::new(Shim(diagnostics))];
663 run_on(files, &validators, &config).diagnostics
664 }
665
666 #[test]
667 fn difficulty_filter_drops_painful_at_hard() {
668 let diags = vec![painful_warning(
669 ".claude/settings.json",
670 "claude/settings/broad-read",
671 )];
672 let result = run_filters(diags, RunConfig::default()); assert!(result.is_empty());
674 }
675
676 #[test]
677 fn difficulty_filter_passes_painful_at_painful() {
678 let diags = vec![painful_warning(
679 ".claude/settings.json",
680 "claude/settings/broad-read",
681 )];
682 let result = run_filters(
683 diags,
684 RunConfig {
685 difficulty: Difficulty::Painful,
686 ..RunConfig::default()
687 },
688 );
689 assert_eq!(result.len(), 1);
690 }
691
692 #[test]
693 fn ignore_filter_suppresses_matching_rule_for_matching_path() {
694 let diags = vec![easy_error(
695 ".claude/settings.local.json",
696 "claude/settings/broad-read",
697 )];
698 let config = RunConfig {
699 ignores: vec![IgnoreEntry {
700 path: ".claude/settings.local.json".into(),
701 rules: vec!["claude/settings/broad-read".into()],
702 }],
703 ..RunConfig::default()
704 };
705 assert!(run_filters(diags, config).is_empty());
706 }
707
708 #[test]
709 fn ignore_filter_empty_rules_suppresses_all_for_path() {
710 let diags = vec![
711 easy_error(".claude/settings.local.json", "claude/settings/broad-read"),
712 easy_error(
713 ".claude/settings.local.json",
714 "claude/settings/sshpass-credential",
715 ),
716 ];
717 let config = RunConfig {
718 ignores: vec![IgnoreEntry {
719 path: ".claude/settings.local.json".into(),
720 rules: vec![],
721 }],
722 ..RunConfig::default()
723 };
724 assert!(run_filters(diags, config).is_empty());
725 }
726
727 #[test]
728 fn ignore_filter_does_not_suppress_different_path() {
729 let diags = vec![easy_error(
730 ".claude/settings.json",
731 "claude/settings/broad-read",
732 )];
733 let config = RunConfig {
734 ignores: vec![IgnoreEntry {
735 path: ".claude/settings.local.json".into(),
736 rules: vec!["claude/settings/broad-read".into()],
737 }],
738 ..RunConfig::default()
739 };
740 assert_eq!(run_filters(diags, config).len(), 1);
741 }
742
743 #[test]
744 fn override_off_drops_diagnostic() {
745 let diags = vec![easy_error(
746 ".claude/settings.json",
747 "claude/settings/unknown-key",
748 )];
749 let config = RunConfig {
750 rule_overrides: [("claude/settings/unknown-key".into(), RuleOverride::Off)]
751 .into_iter()
752 .collect(),
753 ..RunConfig::default()
754 };
755 assert!(run_filters(diags, config).is_empty());
756 }
757
758 #[test]
759 fn override_warning_demotes_error() {
760 let diags = vec![easy_error(
761 ".claude/settings.json",
762 "claude/settings/unknown-key",
763 )];
764 let config = RunConfig {
765 rule_overrides: [("claude/settings/unknown-key".into(), RuleOverride::Warning)]
766 .into_iter()
767 .collect(),
768 ..RunConfig::default()
769 };
770 let result = run_filters(diags, config);
771 assert_eq!(result.len(), 1);
772 assert_eq!(result[0].severity, Severity::Warning);
773 }
774
775 #[test]
776 fn override_error_promotes_warning() {
777 let diags = vec![
778 Diagnostic::warning(PathBuf::from(".claude/settings.json"), 1, 1, "msg")
779 .with_rule("claude/settings/skip-dangerous-mode", Difficulty::Hard),
780 ];
781 let config = RunConfig {
782 rule_overrides: [(
783 "claude/settings/skip-dangerous-mode".into(),
784 RuleOverride::Error,
785 )]
786 .into_iter()
787 .collect(),
788 ..RunConfig::default()
789 };
790 let result = run_filters(diags, config);
791 assert_eq!(result.len(), 1);
792 assert_eq!(result[0].severity, Severity::Error);
793 }
794
795 #[test]
796 fn unclassified_passes_all_filters() {
797 let diags = vec![unclassified("some/path")];
798 let config = RunConfig {
799 difficulty: Difficulty::Easy,
800 rule_overrides: [("".into(), RuleOverride::Off)].into_iter().collect(),
801 ignores: vec![IgnoreEntry {
802 path: "some/path".into(),
803 rules: vec![],
804 }],
805 };
806 let result = run_filters(diags, config);
809 assert_eq!(result.len(), 1);
810 }
811
812 #[test]
813 fn filter_order_difficulty_before_ignore() {
814 let diags = vec![painful_warning(
816 ".claude/settings.json",
817 "claude/settings/broad-read",
818 )];
819 let config = RunConfig {
820 difficulty: Difficulty::Hard,
821 ..RunConfig::default()
823 };
824 assert!(run_filters(diags, config).is_empty());
825 }
826}