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
224#[derive(Debug, Clone, Copy, PartialEq, Eq)]
229pub enum OutputFormat {
230 Gnu,
231 Json,
232 Pretty,
233}
234
235pub struct RunResult {
240 pub diagnostics: Vec<Diagnostic>,
241 pub files_checked: usize,
242}
243
244pub fn run_on(
250 files: impl IntoIterator<Item = (PathBuf, String)>,
251 validators: &[Box<dyn Validator>],
252 config: &RunConfig,
253) -> RunResult {
254 let mut diagnostics = Vec::new();
255 let mut files_checked = 0;
256
257 for (path, src) in files {
258 let matched = find_validators(&path, validators);
259 if matched.is_empty() {
260 continue;
261 }
262 files_checked += 1;
263 for validator in matched {
264 diagnostics.extend(validator.validate(&path, &src));
265 }
266 }
267
268 diagnostics.retain(|d| d.rule.is_empty() || d.difficulty <= config.difficulty);
270
271 diagnostics.retain(|d| {
273 if d.rule.is_empty() {
274 return true; }
276 let path_str = d.path.to_string_lossy();
277 for entry in &config.ignores {
278 let matches_path = path_str.ends_with(&entry.path)
279 || path_str.ends_with(&entry.path.replace('/', std::path::MAIN_SEPARATOR_STR));
280 if matches_path && (entry.rules.is_empty() || entry.rules.iter().any(|r| r == d.rule)) {
281 return false;
282 }
283 }
284 true
285 });
286
287 let mut kept = Vec::with_capacity(diagnostics.len());
289 for mut d in diagnostics {
290 if !d.rule.is_empty() {
291 match config.rule_overrides.get(d.rule) {
292 Some(RuleOverride::Off) => continue,
293 Some(RuleOverride::Error) => d.severity = Severity::Error,
294 Some(RuleOverride::Warning) => d.severity = Severity::Warning,
295 None => {}
296 }
297 }
298 kept.push(d);
299 }
300
301 RunResult {
302 diagnostics: kept,
303 files_checked,
304 }
305}
306
307pub fn run(roots: &[PathBuf], validators: &[Box<dyn Validator>], config: &RunConfig) -> RunResult {
312 let mut read_errors: Vec<Diagnostic> = Vec::new();
313
314 let files: Vec<(PathBuf, String)> = collect_paths(roots)
315 .into_iter()
316 .filter(|path| !find_validators(path, validators).is_empty())
317 .filter_map(|path| {
318 let bytes = match std::fs::read(&path) {
319 Ok(b) => b,
320 Err(e) => {
321 read_errors.push(Diagnostic::error(
322 &path,
323 1,
324 1,
325 format!("could not read file: {e}"),
326 ));
327 return None;
328 }
329 };
330 String::from_utf8(bytes).ok().map(|src| (path, src))
332 })
333 .collect();
334
335 let mut result = run_on(files, validators, config);
336 read_errors.extend(result.diagnostics);
338 result.diagnostics = read_errors;
339 result
340}
341
342const SKIP_DIRS: &[&str] = &["target", ".git", "node_modules", "plugins", ".maestro"];
344
345fn collect_paths(roots: &[PathBuf]) -> Vec<PathBuf> {
346 let mut out = Vec::new();
347 for root in roots {
348 if root.is_file() {
349 out.push(root.clone());
350 } else if root.is_dir() {
351 for entry in walkdir::WalkDir::new(root)
352 .follow_links(false)
353 .into_iter()
354 .filter_entry(|e| {
355 if e.file_type().is_dir() {
356 let name = e.file_name().to_string_lossy();
357 !SKIP_DIRS.iter().any(|skip| *skip == name.as_ref())
358 } else {
359 true
360 }
361 })
362 .filter_map(|e| e.ok())
363 .filter(|e| e.file_type().is_file())
364 {
365 out.push(entry.into_path());
366 }
367 }
368 }
369 out
370}
371
372fn find_validators<'a>(
373 path: &Path,
374 validators: &'a [Box<dyn Validator>],
375) -> Vec<&'a dyn Validator> {
376 let comps: Vec<_> = path.components().collect();
380 let suffixes: Vec<String> = (0..comps.len())
381 .map(|i| {
382 comps[i..]
383 .iter()
384 .collect::<PathBuf>()
385 .to_string_lossy()
386 .into_owned()
387 })
388 .collect();
389
390 validators
391 .iter()
392 .filter(|v| {
393 v.patterns()
394 .iter()
395 .any(|p| suffixes.iter().any(|s| glob_match(p, s)))
396 })
397 .map(|v| v.as_ref())
398 .collect()
399}
400
401fn glob_match(pattern: &str, path: &str) -> bool {
403 glob_match_inner(pattern.as_bytes(), path.as_bytes())
404}
405
406fn glob_match_inner(pat: &[u8], s: &[u8]) -> bool {
407 match (pat.first(), s.first()) {
408 (None, None) => true,
409 (None, Some(_)) => false,
410 (Some(b'*'), _) => {
411 if pat.get(1) == Some(&b'*') {
413 let rest_pat = pat.get(2..).unwrap_or(b"");
414 let rest_pat = rest_pat.strip_prefix(b"/").unwrap_or(rest_pat);
416 for i in 0..=s.len() {
418 if glob_match_inner(rest_pat, &s[i..]) {
419 return true;
420 }
421 }
422 false
423 } else {
424 let rest_pat = &pat[1..];
425 for i in 0..=s.len() {
427 if s[..i].contains(&b'/') {
428 break;
429 }
430 if glob_match_inner(rest_pat, &s[i..]) {
431 return true;
432 }
433 }
434 false
435 }
436 }
437 (Some(&pc), Some(&sc)) => {
438 if pc == sc {
439 glob_match_inner(&pat[1..], &s[1..])
440 } else {
441 false
442 }
443 }
444 (Some(_), None) => false,
445 }
446}
447
448pub fn format_gnu(diagnostics: &[Diagnostic]) -> String {
453 diagnostics
454 .iter()
455 .map(|d| d.gnu_format())
456 .collect::<Vec<_>>()
457 .join("\n")
458}
459
460pub fn format_pretty(diagnostics: &[Diagnostic], color: bool) -> String {
464 use std::collections::BTreeMap;
465
466 let bold = if color { "\x1b[1m" } else { "" };
468 let dim = if color { "\x1b[2m" } else { "" };
469 let red = if color { "\x1b[31m" } else { "" };
470 let yellow = if color { "\x1b[33m" } else { "" };
471 let cyan = if color { "\x1b[36m" } else { "" };
472 let reset = if color { "\x1b[0m" } else { "" };
473
474 let mut by_file: BTreeMap<String, Vec<&Diagnostic>> = BTreeMap::new();
476 for d in diagnostics {
477 by_file
478 .entry(d.path.display().to_string())
479 .or_default()
480 .push(d);
481 }
482
483 let cwd = std::env::current_dir()
485 .ok()
486 .map(|p| p.display().to_string() + "/");
487
488 let shorten = |p: &str| -> String {
489 if let Some(ref prefix) = cwd
490 && let Some(rel) = p.strip_prefix(prefix.as_str())
491 {
492 return rel.to_string();
493 }
494 p.to_string()
495 };
496
497 let mut out = String::new();
498 let mut total_errors: usize = 0;
499 let mut total_warnings: usize = 0;
500
501 for (path, diags) in &by_file {
502 out.push_str(&format!("{bold}{cyan}{}{reset}\n", shorten(path)));
504
505 for d in diags {
506 let (sev_color, sev_label) = match d.severity {
507 Severity::Error => (red, "error"),
508 Severity::Warning => (yellow, "warning"),
509 };
510
511 let rule_hint = if d.rule.is_empty() {
512 String::new()
513 } else {
514 format!(" {dim}[{}]{reset}", d.rule)
515 };
516
517 out.push_str(&format!(
518 " {sev_color}{bold}{sev_label}{reset} {}{rule_hint}\n",
519 d.message,
520 ));
521
522 match d.severity {
523 Severity::Error => total_errors += 1,
524 Severity::Warning => total_warnings += 1,
525 }
526 }
527 out.push('\n');
528 }
529
530 match (total_errors, total_warnings) {
532 (0, 0) => {}
533 (e, 0) => out.push_str(&format!(
534 "{red}{bold}✖ {e} error{}{reset}\n",
535 if e == 1 { "" } else { "s" }
536 )),
537 (0, w) => out.push_str(&format!(
538 "{yellow}{bold}⚠ {w} warning{}{reset}\n",
539 if w == 1 { "" } else { "s" }
540 )),
541 (e, w) => out.push_str(&format!(
542 "{red}{bold}✖ {e} error{}{reset} {yellow}{bold}⚠ {w} warning{}{reset}\n",
543 if e == 1 { "" } else { "s" },
544 if w == 1 { "" } else { "s" },
545 )),
546 }
547
548 out
549}
550
551pub fn format_json(diagnostics: &[Diagnostic]) -> String {
552 let entries: Vec<serde_json::Value> = diagnostics
553 .iter()
554 .map(|d| {
555 serde_json::json!({
556 "path": d.path.display().to_string(),
557 "line": d.line,
558 "col": d.col,
559 "severity": d.severity.to_string(),
560 "rule": d.rule,
561 "difficulty": d.difficulty.to_string(),
562 "message": d.message,
563 })
564 })
565 .collect();
566 serde_json::to_string_pretty(&entries).unwrap_or_else(|_| "[]".to_string())
567}
568
569#[cfg(test)]
574mod tests {
575 use super::*;
576 use std::path::PathBuf;
577
578 #[test]
579 fn glob_literal() {
580 assert!(glob_match("AGENTS.md", "AGENTS.md"));
581 assert!(!glob_match("AGENTS.md", "agents.md"));
582 }
583
584 #[test]
585 fn glob_star() {
586 assert!(glob_match("*.md", "README.md"));
587 assert!(!glob_match("*.md", "src/README.md"));
588 }
589
590 #[test]
591 fn glob_double_star() {
592 assert!(glob_match(
593 ".claude/agents/**/*.md",
594 ".claude/agents/foo/bar.md"
595 ));
596 assert!(glob_match(
597 ".claude/agents/**/*.md",
598 ".claude/agents/bar.md"
599 ));
600 }
601
602 fn easy_error(path: &str, rule: &'static str) -> Diagnostic {
607 Diagnostic::error(PathBuf::from(path), 1, 1, "msg").with_rule(rule, Difficulty::Easy)
608 }
609
610 fn painful_warning(path: &str, rule: &'static str) -> Diagnostic {
611 Diagnostic::warning(PathBuf::from(path), 1, 1, "msg").with_rule(rule, Difficulty::Painful)
612 }
613
614 fn unclassified(path: &str) -> Diagnostic {
615 Diagnostic::error(PathBuf::from(path), 1, 1, "unclassified")
616 }
617
618 fn run_filters(diagnostics: Vec<Diagnostic>, config: RunConfig) -> Vec<Diagnostic> {
619 struct Shim(Vec<Diagnostic>);
624 impl Validator for Shim {
625 fn patterns(&self) -> &[&str] {
626 &["__shim__"]
627 }
628 fn validate(&self, _: &Path, _: &str) -> Vec<Diagnostic> {
629 self.0.clone()
630 }
631 }
632 let files = vec![(PathBuf::from("__shim__"), String::new())];
633 let validators: Vec<Box<dyn Validator>> = vec![Box::new(Shim(diagnostics))];
634 run_on(files, &validators, &config).diagnostics
635 }
636
637 #[test]
638 fn difficulty_filter_drops_painful_at_hard() {
639 let diags = vec![painful_warning(
640 ".claude/settings.json",
641 "claude/settings/broad-read",
642 )];
643 let result = run_filters(diags, RunConfig::default()); assert!(result.is_empty());
645 }
646
647 #[test]
648 fn difficulty_filter_passes_painful_at_painful() {
649 let diags = vec![painful_warning(
650 ".claude/settings.json",
651 "claude/settings/broad-read",
652 )];
653 let result = run_filters(
654 diags,
655 RunConfig {
656 difficulty: Difficulty::Painful,
657 ..RunConfig::default()
658 },
659 );
660 assert_eq!(result.len(), 1);
661 }
662
663 #[test]
664 fn ignore_filter_suppresses_matching_rule_for_matching_path() {
665 let diags = vec![easy_error(
666 ".claude/settings.local.json",
667 "claude/settings/broad-read",
668 )];
669 let config = RunConfig {
670 ignores: vec![IgnoreEntry {
671 path: ".claude/settings.local.json".into(),
672 rules: vec!["claude/settings/broad-read".into()],
673 }],
674 ..RunConfig::default()
675 };
676 assert!(run_filters(diags, config).is_empty());
677 }
678
679 #[test]
680 fn ignore_filter_empty_rules_suppresses_all_for_path() {
681 let diags = vec![
682 easy_error(".claude/settings.local.json", "claude/settings/broad-read"),
683 easy_error(
684 ".claude/settings.local.json",
685 "claude/settings/sshpass-credential",
686 ),
687 ];
688 let config = RunConfig {
689 ignores: vec![IgnoreEntry {
690 path: ".claude/settings.local.json".into(),
691 rules: vec![],
692 }],
693 ..RunConfig::default()
694 };
695 assert!(run_filters(diags, config).is_empty());
696 }
697
698 #[test]
699 fn ignore_filter_does_not_suppress_different_path() {
700 let diags = vec![easy_error(
701 ".claude/settings.json",
702 "claude/settings/broad-read",
703 )];
704 let config = RunConfig {
705 ignores: vec![IgnoreEntry {
706 path: ".claude/settings.local.json".into(),
707 rules: vec!["claude/settings/broad-read".into()],
708 }],
709 ..RunConfig::default()
710 };
711 assert_eq!(run_filters(diags, config).len(), 1);
712 }
713
714 #[test]
715 fn override_off_drops_diagnostic() {
716 let diags = vec![easy_error(
717 ".claude/settings.json",
718 "claude/settings/unknown-key",
719 )];
720 let config = RunConfig {
721 rule_overrides: [("claude/settings/unknown-key".into(), RuleOverride::Off)]
722 .into_iter()
723 .collect(),
724 ..RunConfig::default()
725 };
726 assert!(run_filters(diags, config).is_empty());
727 }
728
729 #[test]
730 fn override_warning_demotes_error() {
731 let diags = vec![easy_error(
732 ".claude/settings.json",
733 "claude/settings/unknown-key",
734 )];
735 let config = RunConfig {
736 rule_overrides: [("claude/settings/unknown-key".into(), RuleOverride::Warning)]
737 .into_iter()
738 .collect(),
739 ..RunConfig::default()
740 };
741 let result = run_filters(diags, config);
742 assert_eq!(result.len(), 1);
743 assert_eq!(result[0].severity, Severity::Warning);
744 }
745
746 #[test]
747 fn override_error_promotes_warning() {
748 let diags = vec![
749 Diagnostic::warning(PathBuf::from(".claude/settings.json"), 1, 1, "msg")
750 .with_rule("claude/settings/skip-dangerous-mode", Difficulty::Hard),
751 ];
752 let config = RunConfig {
753 rule_overrides: [(
754 "claude/settings/skip-dangerous-mode".into(),
755 RuleOverride::Error,
756 )]
757 .into_iter()
758 .collect(),
759 ..RunConfig::default()
760 };
761 let result = run_filters(diags, config);
762 assert_eq!(result.len(), 1);
763 assert_eq!(result[0].severity, Severity::Error);
764 }
765
766 #[test]
767 fn unclassified_passes_all_filters() {
768 let diags = vec![unclassified("some/path")];
769 let config = RunConfig {
770 difficulty: Difficulty::Easy,
771 rule_overrides: [("".into(), RuleOverride::Off)].into_iter().collect(),
772 ignores: vec![IgnoreEntry {
773 path: "some/path".into(),
774 rules: vec![],
775 }],
776 };
777 let result = run_filters(diags, config);
780 assert_eq!(result.len(), 1);
781 }
782
783 #[test]
784 fn filter_order_difficulty_before_ignore() {
785 let diags = vec![painful_warning(
787 ".claude/settings.json",
788 "claude/settings/broad-read",
789 )];
790 let config = RunConfig {
791 difficulty: Difficulty::Hard,
792 ..RunConfig::default()
794 };
795 assert!(run_filters(diags, config).is_empty());
796 }
797}