Skip to main content

agentlint_core/
lib.rs

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    /// Per-rule severity override from config.
13    #[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    /// Suppress specific rules for paths whose string representation ends with
25    /// `path`. An empty `rules` vec suppresses all rules for matching paths.
26    #[derive(Debug, Clone)]
27    pub struct IgnoreEntry {
28        pub path: String,
29        pub rules: Vec<String>,
30    }
31}
32
33// ---------------------------------------------------------------------------
34// Difficulty
35// ---------------------------------------------------------------------------
36
37/// Controls which rules fire. Rules at or below the configured difficulty are
38/// reported; rules above are silently suppressed.
39///
40/// Ordered: `Easy` < `Normal` < `Hard` < `Painful`.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
42pub enum Difficulty {
43    /// Definite breakage only: invalid JSON, missing shebang, empty files,
44    /// credential exposure.
45    Easy,
46    /// Common anti-patterns with clear fixes: naive string matching on raw
47    /// stdin, missing JSON parsing in hooks.
48    Normal,
49    /// Breakage + operational problems: hook leaks, dangerous settings,
50    /// missing required fields.
51    #[default]
52    Hard,
53    /// Everything: best-practice style, stale allows, broad permissions,
54    /// naive patterns.
55    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// ---------------------------------------------------------------------------
85// RunConfig
86// ---------------------------------------------------------------------------
87
88/// Configuration passed to the runner; controls filtering and output behaviour.
89#[derive(Debug, Clone)]
90pub struct RunConfig {
91    /// Only report diagnostics whose difficulty is ≤ this level.
92    /// Default: `Hard`.
93    pub difficulty: Difficulty,
94    /// Per-rule severity overrides. Keys are rule IDs; values control whether
95    /// the rule is suppressed (`Off`) or its severity is rewritten.
96    pub rule_overrides: HashMap<String, RuleOverride>,
97    /// Path-scoped ignore entries. Diagnostics whose path suffix matches and
98    /// whose rule appears in `rules` (or all rules when `rules` is empty) are
99    /// suppressed.
100    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// ---------------------------------------------------------------------------
114// Diagnostic
115// ---------------------------------------------------------------------------
116
117#[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    /// Rule identifier in `<validator>/<category>/<slug>` form. Empty string
140    /// means the rule is unclassified (always shown regardless of difficulty).
141    pub rule: &'static str,
142    /// Difficulty tier that gates this diagnostic.
143    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    /// Set the rule ID and difficulty tier for this diagnostic.
182    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
212// ---------------------------------------------------------------------------
213// Validator trait
214// ---------------------------------------------------------------------------
215
216pub trait Validator: Send + Sync {
217    /// File glob patterns this validator claims (e.g. `.claude/agents/**/*.md`).
218    fn patterns(&self) -> &[&str];
219
220    /// Validate `src` (the file contents) for `path`. Returns all diagnostics.
221    fn validate(&self, path: &Path, src: &str) -> Vec<Diagnostic>;
222
223    /// Cross-file validation called once after all per-file passes. Receives
224    /// only the files claimed by this validator. Default implementation returns
225    /// no diagnostics; override for rules that require global state (e.g.
226    /// duplicate-name detection).
227    fn validate_batch(&self, _files: &[(PathBuf, String)]) -> Vec<Diagnostic> {
228        vec![]
229    }
230}
231
232// ---------------------------------------------------------------------------
233// Output format
234// ---------------------------------------------------------------------------
235
236#[derive(Debug, Clone, Copy, PartialEq, Eq)]
237pub enum OutputFormat {
238    Gnu,
239    Json,
240    Pretty,
241}
242
243// ---------------------------------------------------------------------------
244// Runner
245// ---------------------------------------------------------------------------
246
247pub struct RunResult {
248    pub diagnostics: Vec<Diagnostic>,
249    pub files_checked: usize,
250}
251
252/// Pure domain runner: dispatch `files` (already-loaded path+content pairs) to
253/// matching validators and collect diagnostics.
254///
255/// This is the hexagonal core — it has no filesystem dependency. Infrastructure
256/// callers (see [`run`]) are responsible for discovery and I/O.
257pub 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    // Collect all files first so we can run cross-file batch checks after.
266    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    // Cross-file batch checks: pass each validator the subset of files it claims.
280    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    // 1. Difficulty filter — unclassified diagnostics (rule="") always show.
298    diagnostics.retain(|d| d.rule.is_empty() || d.difficulty <= config.difficulty);
299
300    // 2. Ignore filter — path suffix + rule match.
301    diagnostics.retain(|d| {
302        if d.rule.is_empty() {
303            return true; // unclassified always passes
304        }
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    // 3. Override filter — rewrite or suppress severity.
317    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
336/// Infrastructure convenience: walk `roots`, read each file, then delegate to
337/// [`run_on`]. Only files claimed by at least one validator are read; binary
338/// and unrecognised files are silently skipped. Read errors on claimed files
339/// are surfaced as [`Diagnostic::error`] entries rather than panicking.
340pub 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            // Silently skip binary files — only text files are lintable.
360            String::from_utf8(bytes).ok().map(|src| (path, src))
361        })
362        .collect();
363
364    let mut result = run_on(files, validators, config);
365    // Prepend read errors so they appear before validation diagnostics.
366    read_errors.extend(result.diagnostics);
367    result.diagnostics = read_errors;
368    result
369}
370
371/// Directory names that are never walked (build artifacts, VCS, package caches).
372const 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    // Build candidate strings: every component-suffix of the path, so that
406    // patterns like `.claude/agents/**/*.md` match both relative paths
407    // (`.claude/agents/foo.md`) and absolute paths (`/repo/.claude/agents/foo.md`).
408    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
430/// Minimal glob matching: supports `**`, `*`, and literal segments.
431fn 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            // Check for `**`
441            if pat.get(1) == Some(&b'*') {
442                let rest_pat = pat.get(2..).unwrap_or(b"");
443                // Skip leading `/` after `**`
444                let rest_pat = rest_pat.strip_prefix(b"/").unwrap_or(rest_pat);
445                // Try matching rest_pat against every suffix of s
446                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                // `*` matches anything except `/`
455                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
477// ---------------------------------------------------------------------------
478// Output helpers
479// ---------------------------------------------------------------------------
480
481pub fn format_gnu(diagnostics: &[Diagnostic]) -> String {
482    diagnostics
483        .iter()
484        .map(|d| d.gnu_format())
485        .collect::<Vec<_>>()
486        .join("\n")
487}
488
489/// Pretty-print diagnostics grouped by file with ANSI colour.
490///
491/// Pass `color = false` when stdout is not a TTY.
492pub fn format_pretty(diagnostics: &[Diagnostic], color: bool) -> String {
493    use std::collections::BTreeMap;
494
495    // ANSI helpers — empty strings when color is off.
496    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    // Group by path, preserving insertion order via BTreeMap (sorts paths).
504    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    // Try to strip cwd prefix for shorter paths.
513    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        // File header.
532        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    // Summary line.
560    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// ---------------------------------------------------------------------------
599// Tests
600// ---------------------------------------------------------------------------
601
602#[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    // ---------------------------------------------------------------------------
632    // Filtering logic tests
633    // ---------------------------------------------------------------------------
634
635    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        // Use run_on with a fixed file set — simulate by calling filtering inline.
649        // We pass no validators since we supply pre-built diagnostics; instead we
650        // replicate the filter logic by running run_on on an empty file set and
651        // verifying the filter path directly via a minimal Validator shim.
652        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()); // default = Hard
673        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        // Unclassified should survive even with an empty-rule ignore for its path
807        // because we skip ignore+override for rule="" diagnostics.
808        let result = run_filters(diags, config);
809        assert_eq!(result.len(), 1);
810    }
811
812    #[test]
813    fn filter_order_difficulty_before_ignore() {
814        // painful diagnostic — no ignore needed, dropped by difficulty alone
815        let diags = vec![painful_warning(
816            ".claude/settings.json",
817            "claude/settings/broad-read",
818        )];
819        let config = RunConfig {
820            difficulty: Difficulty::Hard,
821            // No ignore entries — if difficulty filter works, this is never reached
822            ..RunConfig::default()
823        };
824        assert!(run_filters(diags, config).is_empty());
825    }
826}