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
224// ---------------------------------------------------------------------------
225// Output format
226// ---------------------------------------------------------------------------
227
228#[derive(Debug, Clone, Copy, PartialEq, Eq)]
229pub enum OutputFormat {
230    Gnu,
231    Json,
232    Pretty,
233}
234
235// ---------------------------------------------------------------------------
236// Runner
237// ---------------------------------------------------------------------------
238
239pub struct RunResult {
240    pub diagnostics: Vec<Diagnostic>,
241    pub files_checked: usize,
242}
243
244/// Pure domain runner: dispatch `files` (already-loaded path+content pairs) to
245/// matching validators and collect diagnostics.
246///
247/// This is the hexagonal core — it has no filesystem dependency. Infrastructure
248/// callers (see [`run`]) are responsible for discovery and I/O.
249pub 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    // 1. Difficulty filter — unclassified diagnostics (rule="") always show.
269    diagnostics.retain(|d| d.rule.is_empty() || d.difficulty <= config.difficulty);
270
271    // 2. Ignore filter — path suffix + rule match.
272    diagnostics.retain(|d| {
273        if d.rule.is_empty() {
274            return true; // unclassified always passes
275        }
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    // 3. Override filter — rewrite or suppress severity.
288    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
307/// Infrastructure convenience: walk `roots`, read each file, then delegate to
308/// [`run_on`]. Only files claimed by at least one validator are read; binary
309/// and unrecognised files are silently skipped. Read errors on claimed files
310/// are surfaced as [`Diagnostic::error`] entries rather than panicking.
311pub 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            // Silently skip binary files — only text files are lintable.
331            String::from_utf8(bytes).ok().map(|src| (path, src))
332        })
333        .collect();
334
335    let mut result = run_on(files, validators, config);
336    // Prepend read errors so they appear before validation diagnostics.
337    read_errors.extend(result.diagnostics);
338    result.diagnostics = read_errors;
339    result
340}
341
342/// Directory names that are never walked (build artifacts, VCS, package caches).
343const 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    // Build candidate strings: every component-suffix of the path, so that
377    // patterns like `.claude/agents/**/*.md` match both relative paths
378    // (`.claude/agents/foo.md`) and absolute paths (`/repo/.claude/agents/foo.md`).
379    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
401/// Minimal glob matching: supports `**`, `*`, and literal segments.
402fn 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            // Check for `**`
412            if pat.get(1) == Some(&b'*') {
413                let rest_pat = pat.get(2..).unwrap_or(b"");
414                // Skip leading `/` after `**`
415                let rest_pat = rest_pat.strip_prefix(b"/").unwrap_or(rest_pat);
416                // Try matching rest_pat against every suffix of s
417                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                // `*` matches anything except `/`
426                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
448// ---------------------------------------------------------------------------
449// Output helpers
450// ---------------------------------------------------------------------------
451
452pub fn format_gnu(diagnostics: &[Diagnostic]) -> String {
453    diagnostics
454        .iter()
455        .map(|d| d.gnu_format())
456        .collect::<Vec<_>>()
457        .join("\n")
458}
459
460/// Pretty-print diagnostics grouped by file with ANSI colour.
461///
462/// Pass `color = false` when stdout is not a TTY.
463pub fn format_pretty(diagnostics: &[Diagnostic], color: bool) -> String {
464    use std::collections::BTreeMap;
465
466    // ANSI helpers — empty strings when color is off.
467    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    // Group by path, preserving insertion order via BTreeMap (sorts paths).
475    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    // Try to strip cwd prefix for shorter paths.
484    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        // File header.
503        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    // Summary line.
531    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// ---------------------------------------------------------------------------
570// Tests
571// ---------------------------------------------------------------------------
572
573#[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    // ---------------------------------------------------------------------------
603    // Filtering logic tests
604    // ---------------------------------------------------------------------------
605
606    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        // Use run_on with a fixed file set — simulate by calling filtering inline.
620        // We pass no validators since we supply pre-built diagnostics; instead we
621        // replicate the filter logic by running run_on on an empty file set and
622        // verifying the filter path directly via a minimal Validator shim.
623        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()); // default = Hard
644        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        // Unclassified should survive even with an empty-rule ignore for its path
778        // because we skip ignore+override for rule="" diagnostics.
779        let result = run_filters(diags, config);
780        assert_eq!(result.len(), 1);
781    }
782
783    #[test]
784    fn filter_order_difficulty_before_ignore() {
785        // painful diagnostic — no ignore needed, dropped by difficulty alone
786        let diags = vec![painful_warning(
787            ".claude/settings.json",
788            "claude/settings/broad-read",
789        )];
790        let config = RunConfig {
791            difficulty: Difficulty::Hard,
792            // No ignore entries — if difficulty filter works, this is never reached
793            ..RunConfig::default()
794        };
795        assert!(run_filters(diags, config).is_empty());
796    }
797}