Skip to main content

oxilean_lint/
lib.rs

1//! # OxiLean Lint -- Static Analysis and Lint Rules
2//!
3//! This crate provides a lint engine and a collection of lint rules for
4//! analyzing OxiLean source code.
5
6#![allow(dead_code)]
7#![warn(clippy::all)]
8#![allow(unused_imports)]
9#![allow(clippy::field_reassign_with_default)]
10#![allow(clippy::ptr_arg)]
11#![allow(clippy::derivable_impls)]
12#![allow(clippy::should_implement_trait)]
13#![allow(clippy::collapsible_if)]
14#![allow(clippy::single_match)]
15#![allow(clippy::needless_ifs)]
16#![allow(clippy::len_without_is_empty)]
17#![allow(clippy::new_without_default)]
18#![allow(clippy::inherent_to_string_shadow_display)]
19#![allow(clippy::type_complexity)]
20#![allow(clippy::manual_strip)]
21#![allow(clippy::bool_comparison)]
22#![allow(clippy::if_same_then_else)]
23#![allow(clippy::implicit_saturating_sub)]
24#![allow(clippy::int_plus_one)]
25#![allow(clippy::manual_map)]
26#![allow(clippy::needless_bool)]
27#![allow(clippy::clone_on_copy)]
28#![allow(clippy::manual_find)]
29#![allow(clippy::for_kv_map)]
30#![allow(clippy::enum_variant_names)]
31#![allow(clippy::manual_range_contains)]
32#![allow(clippy::to_string_in_format_args)]
33
34pub mod autofix;
35pub mod framework;
36pub mod ide_integration;
37pub mod plugin;
38pub mod rules;
39
40pub use framework::{
41    AutoFix, LintConfig, LintContext, LintDiagnostic, LintEngine, LintId, LintRegistry, LintRule,
42    LintSuppression, Severity,
43};
44pub use rules::{
45    DeadCodeRule, DeprecatedApiRule, DeprecatedTacticRule, LongProofRule, MissingDocRule,
46    MissingDocstringRule, NamingConventionRule, RedundantAssumptionRule, RedundantPatternRule,
47    SimplifiableExprRule, StyleRule, UnreachableCodeRule, UnusedHypothesisRule, UnusedImportRule,
48    UnusedVariableRule,
49};
50
51use std::fmt;
52
53// ============================================================
54// LintPass: a group of related rules
55// ============================================================
56
57/// A lint pass groups related lint rules that run together.
58#[derive(Clone, Debug)]
59pub struct LintPass {
60    /// Name of the pass.
61    pub name: String,
62    /// Lint IDs included in this pass.
63    pub lint_ids: Vec<LintId>,
64    /// Whether this pass is enabled by default.
65    pub enabled: bool,
66    /// Whether this pass can modify the AST (for fixes).
67    pub can_fix: bool,
68}
69
70impl LintPass {
71    /// Create a new lint pass.
72    pub fn new(name: &str) -> Self {
73        Self {
74            name: name.to_string(),
75            lint_ids: Vec::new(),
76            enabled: true,
77            can_fix: false,
78        }
79    }
80
81    /// Add a lint to this pass.
82    pub fn with_lint(mut self, id: &str) -> Self {
83        self.lint_ids.push(LintId::new(id));
84        self
85    }
86
87    /// Enable fix mode for this pass.
88    pub fn with_fixes(mut self) -> Self {
89        self.can_fix = true;
90        self
91    }
92
93    /// Disable this pass.
94    pub fn disabled(mut self) -> Self {
95        self.enabled = false;
96        self
97    }
98}
99
100// ============================================================
101// LintCategory: grouping lint rules
102// ============================================================
103
104/// Category of a lint rule.
105#[derive(Clone, Debug, PartialEq, Eq, Hash)]
106pub enum LintCategory {
107    /// Correctness: potential bugs.
108    Correctness,
109    /// Style: cosmetic/formatting issues.
110    Style,
111    /// Performance: potential inefficiencies.
112    Performance,
113    /// Complexity: overly complex code.
114    Complexity,
115    /// Deprecation: use of deprecated APIs.
116    Deprecation,
117    /// Documentation: missing or incorrect docs.
118    Documentation,
119    /// Naming: naming convention violations.
120    Naming,
121    /// Redundancy: redundant or dead code.
122    Redundancy,
123    /// Security: potential security issues.
124    Security,
125    /// Custom: user-defined lint category.
126    Custom(String),
127}
128
129impl fmt::Display for LintCategory {
130    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
131        match self {
132            LintCategory::Correctness => write!(f, "correctness"),
133            LintCategory::Style => write!(f, "style"),
134            LintCategory::Performance => write!(f, "performance"),
135            LintCategory::Complexity => write!(f, "complexity"),
136            LintCategory::Deprecation => write!(f, "deprecation"),
137            LintCategory::Documentation => write!(f, "documentation"),
138            LintCategory::Naming => write!(f, "naming"),
139            LintCategory::Redundancy => write!(f, "redundancy"),
140            LintCategory::Security => write!(f, "security"),
141            LintCategory::Custom(ref name) => write!(f, "custom:{}", name),
142        }
143    }
144}
145
146// ============================================================
147// LintMetadata: rule metadata record
148// ============================================================
149
150/// Metadata about a lint rule.
151#[derive(Clone, Debug)]
152pub struct LintMetadata {
153    /// Unique lint identifier.
154    pub id: LintId,
155    /// Category of the lint.
156    pub category: LintCategory,
157    /// Short one-line description.
158    pub summary: String,
159    /// Detailed explanation.
160    pub explanation: String,
161    /// Default severity.
162    pub severity: Severity,
163    /// Whether an auto-fix is available.
164    pub fixable: bool,
165    /// References (e.g., to Lean 4 documentation).
166    pub references: Vec<String>,
167}
168
169impl LintMetadata {
170    /// Create a new metadata record.
171    pub fn new(id: &str, category: LintCategory, summary: &str, severity: Severity) -> Self {
172        Self {
173            id: LintId::new(id),
174            category,
175            summary: summary.to_string(),
176            explanation: String::new(),
177            severity,
178            fixable: false,
179            references: Vec::new(),
180        }
181    }
182
183    /// Add an explanation.
184    pub fn with_explanation(mut self, text: &str) -> Self {
185        self.explanation = text.to_string();
186        self
187    }
188
189    /// Mark as fixable.
190    pub fn fixable(mut self) -> Self {
191        self.fixable = true;
192        self
193    }
194
195    /// Add a reference URL.
196    pub fn with_reference(mut self, url: &str) -> Self {
197        self.references.push(url.to_string());
198        self
199    }
200}
201
202// ============================================================
203// LintStats: aggregate statistics
204// ============================================================
205
206/// Aggregate statistics from a lint run.
207#[derive(Clone, Debug, Default)]
208pub struct LintStats {
209    /// Total diagnostics emitted.
210    pub total_diagnostics: u64,
211    /// Error-severity diagnostics.
212    pub errors: u64,
213    /// Warning-severity diagnostics.
214    pub warnings: u64,
215    /// Info-severity diagnostics.
216    pub infos: u64,
217    /// Hint-severity diagnostics.
218    pub hints: u64,
219    /// Number of declarations checked.
220    pub decls_checked: u64,
221    /// Number of auto-fixes applied.
222    pub fixes_applied: u64,
223    /// Number of suppressions honored.
224    pub suppressions_honored: u64,
225}
226
227impl LintStats {
228    /// Create zero-initialized stats.
229    pub fn new() -> Self {
230        Self::default()
231    }
232
233    /// Whether there are any error-severity diagnostics.
234    pub fn has_errors(&self) -> bool {
235        self.errors > 0
236    }
237
238    /// Whether the lint run was clean (no errors or warnings).
239    pub fn is_clean(&self) -> bool {
240        self.errors == 0 && self.warnings == 0
241    }
242
243    /// Add a diagnostic at the given severity.
244    pub fn record(&mut self, sev: Severity) {
245        self.total_diagnostics += 1;
246        match sev {
247            Severity::Error => self.errors += 1,
248            Severity::Warning => self.warnings += 1,
249            Severity::Info => self.infos += 1,
250            Severity::Hint => self.hints += 1,
251        }
252    }
253}
254
255impl fmt::Display for LintStats {
256    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
257        write!(
258            f,
259            "LintStats {{ total: {}, errors: {}, warnings: {}, infos: {}, hints: {} }}",
260            self.total_diagnostics, self.errors, self.warnings, self.infos, self.hints
261        )
262    }
263}
264
265// ============================================================
266// LintSuppressAnnotation: #[allow(lint_id)]
267// ============================================================
268
269/// A lint suppression annotation parsed from source.
270#[derive(Clone, Debug, PartialEq)]
271pub struct LintSuppressAnnotation {
272    /// IDs to suppress.
273    pub ids: Vec<LintId>,
274    /// Whether this annotation applies to the whole file.
275    pub is_file_level: bool,
276    /// Line number of the annotation (0-based).
277    pub line: usize,
278}
279
280impl LintSuppressAnnotation {
281    /// Create a single-lint suppression.
282    pub fn single(id: &str, line: usize) -> Self {
283        Self {
284            ids: vec![LintId::new(id)],
285            is_file_level: false,
286            line,
287        }
288    }
289
290    /// Create a file-level suppression.
291    pub fn file_level(ids: Vec<&str>) -> Self {
292        Self {
293            ids: ids.into_iter().map(LintId::new).collect(),
294            is_file_level: true,
295            line: 0,
296        }
297    }
298
299    /// Whether this suppression covers the given ID.
300    pub fn suppresses(&self, id: &LintId) -> bool {
301        self.ids.contains(id)
302    }
303}
304
305// ============================================================
306// LintReport: full result of a lint run
307// ============================================================
308
309/// The full result of running the lint engine on a source file.
310#[derive(Clone, Debug)]
311pub struct LintReport {
312    /// File that was linted.
313    pub filename: String,
314    /// All diagnostics emitted.
315    pub diagnostics: Vec<LintDiagnostic>,
316    /// Aggregate statistics.
317    pub stats: LintStats,
318    /// Whether auto-fixes were applied.
319    pub fixes_applied: bool,
320}
321
322impl LintReport {
323    /// Create an empty report.
324    pub fn empty(filename: &str) -> Self {
325        Self {
326            filename: filename.to_string(),
327            diagnostics: Vec::new(),
328            stats: LintStats::new(),
329            fixes_applied: false,
330        }
331    }
332
333    /// Add a diagnostic.
334    pub fn add_diagnostic(&mut self, diag: LintDiagnostic) {
335        self.stats.record(diag.severity);
336        self.diagnostics.push(diag);
337    }
338
339    /// Diagnostics at or above a given severity.
340    pub fn at_severity(&self, sev: Severity) -> Vec<&LintDiagnostic> {
341        self.diagnostics
342            .iter()
343            .filter(|d| d.severity <= sev)
344            .collect()
345    }
346
347    /// Whether the lint run was clean.
348    pub fn is_clean(&self) -> bool {
349        self.stats.is_clean()
350    }
351}
352
353impl fmt::Display for LintReport {
354    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
355        write!(
356            f,
357            "LintReport[{}] {{ {} diags, {} }}",
358            self.filename,
359            self.diagnostics.len(),
360            self.stats
361        )
362    }
363}
364
365// ============================================================
366// Tests
367// ============================================================
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372
373    #[test]
374    fn test_lint_pass_new() {
375        let pass = LintPass::new("style");
376        assert_eq!(pass.name, "style");
377        assert!(pass.enabled);
378        assert!(!pass.can_fix);
379    }
380
381    #[test]
382    fn test_lint_pass_with_lint() {
383        let pass = LintPass::new("unused").with_lint("unused_variable");
384        assert_eq!(pass.lint_ids.len(), 1);
385        assert_eq!(pass.lint_ids[0].as_str(), "unused_variable");
386    }
387
388    #[test]
389    fn test_lint_pass_disabled() {
390        let pass = LintPass::new("x").disabled();
391        assert!(!pass.enabled);
392    }
393
394    #[test]
395    fn test_lint_pass_with_fixes() {
396        let pass = LintPass::new("x").with_fixes();
397        assert!(pass.can_fix);
398    }
399
400    #[test]
401    fn test_lint_category_display() {
402        assert_eq!(format!("{}", LintCategory::Correctness), "correctness");
403        assert_eq!(format!("{}", LintCategory::Style), "style");
404        assert_eq!(format!("{}", LintCategory::Naming), "naming");
405    }
406
407    #[test]
408    fn test_lint_metadata_new() {
409        let meta = LintMetadata::new(
410            "unused_variable",
411            LintCategory::Redundancy,
412            "Unused variable",
413            Severity::Warning,
414        );
415        assert_eq!(meta.id.as_str(), "unused_variable");
416        assert_eq!(meta.severity, Severity::Warning);
417        assert!(!meta.fixable);
418    }
419
420    #[test]
421    fn test_lint_metadata_fixable() {
422        let meta = LintMetadata::new("x", LintCategory::Style, "X", Severity::Hint).fixable();
423        assert!(meta.fixable);
424    }
425
426    #[test]
427    fn test_lint_metadata_with_explanation() {
428        let meta = LintMetadata::new("x", LintCategory::Style, "X", Severity::Hint)
429            .with_explanation("More details here.");
430        assert!(!meta.explanation.is_empty());
431    }
432
433    #[test]
434    fn test_lint_stats_default() {
435        let s = LintStats::new();
436        assert!(!s.has_errors());
437        assert!(s.is_clean());
438        assert_eq!(s.total_diagnostics, 0);
439    }
440
441    #[test]
442    fn test_lint_stats_record_error() {
443        let mut s = LintStats::new();
444        s.record(Severity::Error);
445        assert!(s.has_errors());
446        assert!(!s.is_clean());
447        assert_eq!(s.errors, 1);
448    }
449
450    #[test]
451    fn test_lint_stats_record_warning() {
452        let mut s = LintStats::new();
453        s.record(Severity::Warning);
454        assert!(!s.has_errors());
455        assert!(!s.is_clean());
456        assert_eq!(s.warnings, 1);
457    }
458
459    #[test]
460    fn test_lint_stats_display() {
461        let mut s = LintStats::new();
462        s.record(Severity::Error);
463        s.record(Severity::Warning);
464        let text = format!("{}", s);
465        assert!(text.contains("total: 2"));
466        assert!(text.contains("errors: 1"));
467    }
468
469    #[test]
470    fn test_lint_suppress_annotation_single() {
471        let ann = LintSuppressAnnotation::single("unused_variable", 5);
472        assert_eq!(ann.ids.len(), 1);
473        assert_eq!(ann.line, 5);
474        assert!(!ann.is_file_level);
475    }
476
477    #[test]
478    fn test_lint_suppress_annotation_file_level() {
479        let ann = LintSuppressAnnotation::file_level(vec!["dead_code", "unused_import"]);
480        assert_eq!(ann.ids.len(), 2);
481        assert!(ann.is_file_level);
482    }
483
484    #[test]
485    fn test_lint_suppress_annotation_suppresses() {
486        let ann = LintSuppressAnnotation::single("unused_variable", 0);
487        assert!(ann.suppresses(&LintId::new("unused_variable")));
488        assert!(!ann.suppresses(&LintId::new("dead_code")));
489    }
490
491    #[test]
492    fn test_lint_report_empty() {
493        let r = LintReport::empty("foo.ox");
494        assert_eq!(r.filename, "foo.ox");
495        assert!(r.is_clean());
496        assert!(r.diagnostics.is_empty());
497    }
498
499    #[test]
500    fn test_lint_report_display() {
501        let r = LintReport::empty("bar.ox");
502        let s = format!("{}", r);
503        assert!(s.contains("bar.ox"));
504    }
505
506    #[test]
507    fn test_lint_id_matches_pattern_wildcard() {
508        let id = LintId::new("unused_variable");
509        assert!(id.matches_pattern("*"));
510        assert!(id.matches_pattern("unused_*"));
511        assert!(!id.matches_pattern("dead_*"));
512    }
513
514    #[test]
515    fn test_lint_stats_hints_and_infos() {
516        let mut s = LintStats::new();
517        s.record(Severity::Info);
518        s.record(Severity::Hint);
519        assert_eq!(s.infos, 1);
520        assert_eq!(s.hints, 1);
521        assert!(s.is_clean());
522    }
523
524    #[test]
525    fn test_lint_metadata_with_reference() {
526        let meta = LintMetadata::new("x", LintCategory::Correctness, "x", Severity::Error)
527            .with_reference("https://oxilean.org/lint/x");
528        assert_eq!(meta.references.len(), 1);
529    }
530}
531
532// ============================================================
533// LintRuleSet: a named group of LintIds
534// ============================================================
535
536/// A named set of lint rules to apply together.
537#[derive(Clone, Debug, Default)]
538pub struct LintRuleSet {
539    /// Set name.
540    pub name: String,
541    /// Rule IDs.
542    pub ids: Vec<LintId>,
543}
544
545impl LintRuleSet {
546    /// Create an empty set.
547    pub fn new(name: &str) -> Self {
548        Self {
549            name: name.to_string(),
550            ids: Vec::new(),
551        }
552    }
553
554    /// Add a rule.
555    pub fn add(&mut self, id: &str) {
556        self.ids.push(LintId::new(id));
557    }
558
559    /// Number of rules.
560    pub fn len(&self) -> usize {
561        self.ids.len()
562    }
563
564    /// Whether the set is empty.
565    pub fn is_empty(&self) -> bool {
566        self.ids.is_empty()
567    }
568
569    /// Whether the set contains a given ID.
570    pub fn contains(&self, id: &LintId) -> bool {
571        self.ids.contains(id)
572    }
573}
574
575impl std::fmt::Display for LintRuleSet {
576    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
577        write!(f, "LintRuleSet[{}]({} rules)", self.name, self.ids.len())
578    }
579}
580
581// ============================================================
582// LintLevel: configurable severity level
583// ============================================================
584
585/// Configurable level for a lint rule: deny, warn, allow.
586#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
587pub enum LintLevel {
588    /// Allow: suppress the lint.
589    Allow,
590    /// Warn: emit a warning.
591    Warn,
592    /// Deny: treat as error.
593    Deny,
594}
595
596impl std::fmt::Display for LintLevel {
597    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
598        match self {
599            LintLevel::Allow => write!(f, "allow"),
600            LintLevel::Warn => write!(f, "warn"),
601            LintLevel::Deny => write!(f, "deny"),
602        }
603    }
604}
605
606// ============================================================
607// Additional tests
608// ============================================================
609
610#[cfg(test)]
611mod extra_tests {
612    use super::*;
613
614    #[test]
615    fn test_lint_rule_set_new() {
616        let s = LintRuleSet::new("default");
617        assert_eq!(s.name, "default");
618        assert!(s.is_empty());
619    }
620
621    #[test]
622    fn test_lint_rule_set_add() {
623        let mut s = LintRuleSet::new("style");
624        s.add("unused_variable");
625        assert_eq!(s.len(), 1);
626        assert!(s.contains(&LintId::new("unused_variable")));
627    }
628
629    #[test]
630    fn test_lint_rule_set_contains_false() {
631        let s = LintRuleSet::new("x");
632        assert!(!s.contains(&LintId::new("nonexistent")));
633    }
634
635    #[test]
636    fn test_lint_rule_set_display() {
637        let mut s = LintRuleSet::new("perf");
638        s.add("redundant_clone");
639        let txt = format!("{}", s);
640        assert!(txt.contains("perf"));
641        assert!(txt.contains("1 rules"));
642    }
643
644    #[test]
645    fn test_lint_level_ordering() {
646        assert!(LintLevel::Deny > LintLevel::Warn);
647        assert!(LintLevel::Warn > LintLevel::Allow);
648    }
649
650    #[test]
651    fn test_lint_level_display() {
652        assert_eq!(format!("{}", LintLevel::Allow), "allow");
653        assert_eq!(format!("{}", LintLevel::Warn), "warn");
654        assert_eq!(format!("{}", LintLevel::Deny), "deny");
655    }
656
657    #[test]
658    fn test_lint_pass_multiple_lints() {
659        let pass = LintPass::new("all")
660            .with_lint("unused_variable")
661            .with_lint("dead_code")
662            .with_lint("unused_import");
663        assert_eq!(pass.lint_ids.len(), 3);
664    }
665
666    #[test]
667    fn test_lint_report_add_and_severity() {
668        use framework::Severity;
669        let r = LintReport::empty("test.ox");
670        // We can only call add_diagnostic, not construct LintDiagnostic directly here
671        // So just verify the empty state
672        assert!(r.is_clean());
673        let _ = r.at_severity(Severity::Error);
674    }
675
676    #[test]
677    fn test_lint_stats_multiple_records() {
678        use framework::Severity;
679        let mut s = LintStats::new();
680        s.record(Severity::Error);
681        s.record(Severity::Warning);
682        s.record(Severity::Info);
683        s.record(Severity::Hint);
684        assert_eq!(s.total_diagnostics, 4);
685        assert_eq!(s.errors, 1);
686        assert_eq!(s.warnings, 1);
687        assert_eq!(s.infos, 1);
688        assert_eq!(s.hints, 1);
689    }
690}
691
692// ============================================================
693// LintResult: result of running a lint rule on a declaration
694// ============================================================
695
696/// The result of running a single lint rule.
697#[derive(Clone, Debug, Default)]
698pub struct LintResult {
699    /// Diagnostics emitted.
700    pub diagnostics: Vec<LintDiagnostic>,
701}
702
703impl LintResult {
704    /// Create an empty result.
705    pub fn new() -> Self {
706        Self::default()
707    }
708
709    /// Add a diagnostic.
710    pub fn add(&mut self, diag: LintDiagnostic) {
711        self.diagnostics.push(diag);
712    }
713
714    /// Whether any diagnostics were emitted.
715    pub fn has_diagnostics(&self) -> bool {
716        !self.diagnostics.is_empty()
717    }
718
719    /// Number of diagnostics.
720    pub fn len(&self) -> usize {
721        self.diagnostics.len()
722    }
723
724    /// Whether there are no diagnostics.
725    pub fn is_empty(&self) -> bool {
726        self.diagnostics.is_empty()
727    }
728
729    /// Whether the result is clean (no diagnostics).
730    pub fn is_clean(&self) -> bool {
731        self.diagnostics.is_empty()
732    }
733
734    /// Diagnostics at or above a severity.
735    pub fn at_severity(&self, sev: Severity) -> Vec<&LintDiagnostic> {
736        self.diagnostics
737            .iter()
738            .filter(|d| d.severity <= sev)
739            .collect()
740    }
741
742    /// Merge another result into this one.
743    pub fn merge(&mut self, other: LintResult) {
744        self.diagnostics.extend(other.diagnostics);
745    }
746}
747
748impl std::fmt::Display for LintResult {
749    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
750        write!(f, "LintResult({} diagnostics)", self.diagnostics.len())
751    }
752}
753
754// ============================================================
755// LintConfigBuilder: builder for LintConfig
756// ============================================================
757
758/// Builder for `LintConfig`.
759pub struct LintConfigBuilder {
760    config: LintConfig,
761}
762
763impl LintConfigBuilder {
764    /// Start with default config.
765    pub fn new() -> Self {
766        Self {
767            config: LintConfig::default(),
768        }
769    }
770
771    /// Allow a lint by ID.
772    pub fn allow(mut self, id: &str) -> Self {
773        self.config.enabled.insert(LintId::new(id));
774        self
775    }
776
777    /// Deny a lint by ID.
778    pub fn deny(mut self, id: &str) -> Self {
779        self.config.disabled.insert(LintId::new(id));
780        self
781    }
782
783    /// Build the final config.
784    pub fn build(self) -> LintConfig {
785        self.config
786    }
787}
788
789impl Default for LintConfigBuilder {
790    fn default() -> Self {
791        Self::new()
792    }
793}
794
795// ============================================================
796// Additional tests
797// ============================================================
798
799#[cfg(test)]
800mod lint_result_tests {
801    use super::*;
802    use framework::{LintId, Severity};
803
804    fn mk_diag(sev: Severity) -> LintDiagnostic {
805        use framework::SourceRange;
806        LintDiagnostic::new(
807            LintId::new("test"),
808            sev,
809            "test message",
810            SourceRange::default(),
811        )
812    }
813
814    #[test]
815    fn test_lint_result_empty() {
816        let r = LintResult::new();
817        assert!(r.is_clean());
818        assert_eq!(r.len(), 0);
819    }
820
821    #[test]
822    fn test_lint_result_add() {
823        let mut r = LintResult::new();
824        r.add(mk_diag(Severity::Warning));
825        assert!(r.has_diagnostics());
826        assert_eq!(r.len(), 1);
827    }
828
829    #[test]
830    fn test_lint_result_at_severity() {
831        let mut r = LintResult::new();
832        r.add(mk_diag(Severity::Warning));
833        r.add(mk_diag(Severity::Error));
834        let errors = r.at_severity(Severity::Error);
835        assert_eq!(errors.len(), 1);
836    }
837
838    #[test]
839    fn test_lint_result_merge() {
840        let mut r1 = LintResult::new();
841        let mut r2 = LintResult::new();
842        r1.add(mk_diag(Severity::Warning));
843        r2.add(mk_diag(Severity::Error));
844        r1.merge(r2);
845        assert_eq!(r1.len(), 2);
846    }
847
848    #[test]
849    fn test_lint_result_display() {
850        let r = LintResult::new();
851        let s = format!("{}", r);
852        assert!(s.contains("LintResult"));
853    }
854
855    #[test]
856    fn test_lint_config_builder() {
857        let cfg = LintConfigBuilder::new()
858            .allow("dead_code")
859            .deny("unused_variable")
860            .build();
861        assert!(cfg.is_allowed(&LintId::new("dead_code")));
862        assert!(cfg.is_denied(&LintId::new("unused_variable")));
863    }
864
865    #[test]
866    fn test_lint_category_all_variants() {
867        let cats = vec![
868            LintCategory::Correctness,
869            LintCategory::Style,
870            LintCategory::Performance,
871            LintCategory::Complexity,
872            LintCategory::Deprecation,
873            LintCategory::Documentation,
874            LintCategory::Naming,
875            LintCategory::Redundancy,
876        ];
877        for cat in cats {
878            let s = format!("{}", cat);
879            assert!(!s.is_empty());
880        }
881    }
882
883    #[test]
884    fn test_lint_suppress_annotation_suppresses_false() {
885        let ann = LintSuppressAnnotation::single("unused_variable", 0);
886        assert!(!ann.suppresses(&LintId::new("dead_code")));
887    }
888
889    #[test]
890    fn test_lint_rule_set_add_multiple() {
891        let mut s = LintRuleSet::new("default");
892        for name in ["a", "b", "c", "d"] {
893            s.add(name);
894        }
895        assert_eq!(s.len(), 4);
896    }
897}
898
899// ── Lint filter ───────────────────────────────────────────────────────────────
900
901/// A filter that decides whether a diagnostic should be reported.
902#[derive(Clone, Debug, Default)]
903pub struct LintFilter {
904    /// Only report diagnostics whose id matches one of these patterns.
905    include_patterns: Vec<String>,
906    /// Suppress diagnostics whose id matches one of these patterns.
907    exclude_patterns: Vec<String>,
908    /// Minimum severity to report.
909    min_severity: Option<Severity>,
910}
911
912impl LintFilter {
913    /// Create an empty (pass-through) filter.
914    pub fn new() -> Self {
915        Self::default()
916    }
917
918    /// Add an include pattern (glob-style: `*` matches any suffix).
919    pub fn include(mut self, pattern: &str) -> Self {
920        self.include_patterns.push(pattern.to_string());
921        self
922    }
923
924    /// Add an exclude pattern.
925    pub fn exclude(mut self, pattern: &str) -> Self {
926        self.exclude_patterns.push(pattern.to_string());
927        self
928    }
929
930    /// Set a minimum severity.
931    pub fn min_severity(mut self, sev: Severity) -> Self {
932        self.min_severity = Some(sev);
933        self
934    }
935
936    /// Return true if a diagnostic passes this filter.
937    pub fn accepts(&self, diag: &LintDiagnostic) -> bool {
938        // Minimum severity check
939        if let Some(min) = &self.min_severity {
940            if diag.severity > *min {
941                return false;
942            }
943        }
944
945        // Include patterns (if any specified, must match at least one)
946        if !self.include_patterns.is_empty() {
947            let matched = self
948                .include_patterns
949                .iter()
950                .any(|p| diag.lint_id.matches_pattern(p));
951            if !matched {
952                return false;
953            }
954        }
955
956        // Exclude patterns
957        let excluded = self
958            .exclude_patterns
959            .iter()
960            .any(|p| diag.lint_id.matches_pattern(p));
961        !excluded
962    }
963
964    /// Apply the filter to a vec of diagnostics.
965    pub fn apply<'a>(&self, diags: &'a [LintDiagnostic]) -> Vec<&'a LintDiagnostic> {
966        diags.iter().filter(|d| self.accepts(d)).collect()
967    }
968}
969
970// ── Lint formatter ────────────────────────────────────────────────────────────
971
972/// Output format for lint diagnostics.
973#[derive(Clone, Copy, Debug, PartialEq, Eq)]
974pub enum LintOutputFormat {
975    /// Human-readable text (default).
976    Text,
977    /// JSON output.
978    Json,
979    /// GitHub Actions annotation format.
980    GitHubActions,
981    /// Count only (no per-diagnostic output).
982    Count,
983}
984
985impl std::fmt::Display for LintOutputFormat {
986    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
987        match self {
988            LintOutputFormat::Text => write!(f, "text"),
989            LintOutputFormat::Json => write!(f, "json"),
990            LintOutputFormat::GitHubActions => write!(f, "github-actions"),
991            LintOutputFormat::Count => write!(f, "count"),
992        }
993    }
994}
995
996impl LintOutputFormat {
997    /// Parse from a string, returning `None` if unrecognised.
998    pub fn parse(s: &str) -> Option<Self> {
999        match s {
1000            "text" => Some(LintOutputFormat::Text),
1001            "json" => Some(LintOutputFormat::Json),
1002            "github-actions" => Some(LintOutputFormat::GitHubActions),
1003            "count" => Some(LintOutputFormat::Count),
1004            _ => None,
1005        }
1006    }
1007}
1008
1009// ── Lint profile ──────────────────────────────────────────────────────────────
1010
1011/// A named configuration profile for the lint engine.
1012#[derive(Clone, Debug)]
1013pub struct LintProfile {
1014    /// Profile name (e.g., `"strict"`, `"pedantic"`, `"minimal"`).
1015    pub name: String,
1016    /// Rule sets activated by this profile.
1017    pub rule_sets: Vec<LintRuleSet>,
1018    /// Overridden levels for specific rules.
1019    pub overrides: Vec<(LintId, LintLevel)>,
1020}
1021
1022impl LintProfile {
1023    /// Create a new empty profile.
1024    pub fn new(name: &str) -> Self {
1025        Self {
1026            name: name.to_string(),
1027            rule_sets: Vec::new(),
1028            overrides: Vec::new(),
1029        }
1030    }
1031
1032    /// Add a rule set to this profile.
1033    pub fn with_rule_set(mut self, rs: LintRuleSet) -> Self {
1034        self.rule_sets.push(rs);
1035        self
1036    }
1037
1038    /// Add a rule-level override.
1039    pub fn with_override(mut self, id: &str, level: LintLevel) -> Self {
1040        self.overrides.push((LintId::new(id), level));
1041        self
1042    }
1043
1044    /// Return all lint IDs covered by this profile.
1045    pub fn all_ids(&self) -> Vec<&LintId> {
1046        self.rule_sets.iter().flat_map(|rs| rs.ids.iter()).collect()
1047    }
1048
1049    /// Return the effective level for a given lint id.
1050    pub fn effective_level(&self, id: &LintId) -> Option<LintLevel> {
1051        self.overrides
1052            .iter()
1053            .rev()
1054            .find_map(|(k, v)| if k == id { Some(*v) } else { None })
1055    }
1056}
1057
1058#[cfg(test)]
1059mod lint_profile_tests {
1060    use super::*;
1061
1062    fn mk_diag_with_id(id: &str, sev: Severity) -> LintDiagnostic {
1063        use framework::{LintId, SourceRange};
1064        LintDiagnostic::new(LintId::new(id), sev, "msg", SourceRange::default())
1065    }
1066
1067    #[test]
1068    fn test_lint_filter_no_constraints() {
1069        let filter = LintFilter::new();
1070        let diag = mk_diag_with_id("unused_variable", Severity::Warning);
1071        assert!(filter.accepts(&diag));
1072    }
1073
1074    #[test]
1075    fn test_lint_filter_min_severity_ok() {
1076        let filter = LintFilter::new().min_severity(Severity::Warning);
1077        let warn = mk_diag_with_id("x", Severity::Warning);
1078        let info = mk_diag_with_id("x", Severity::Info);
1079        assert!(filter.accepts(&warn));
1080        assert!(!filter.accepts(&info));
1081    }
1082
1083    #[test]
1084    fn test_lint_filter_include_pattern() {
1085        let filter = LintFilter::new().include("unused_*");
1086        let pass = mk_diag_with_id("unused_variable", Severity::Warning);
1087        let fail = mk_diag_with_id("dead_code", Severity::Warning);
1088        assert!(filter.accepts(&pass));
1089        assert!(!filter.accepts(&fail));
1090    }
1091
1092    #[test]
1093    fn test_lint_filter_exclude_pattern() {
1094        let filter = LintFilter::new().exclude("dead_*");
1095        let pass = mk_diag_with_id("unused_variable", Severity::Warning);
1096        let fail = mk_diag_with_id("dead_code", Severity::Warning);
1097        assert!(filter.accepts(&pass));
1098        assert!(!filter.accepts(&fail));
1099    }
1100
1101    #[test]
1102    fn test_lint_filter_apply() {
1103        let filter = LintFilter::new().min_severity(Severity::Error);
1104        let diags = vec![
1105            mk_diag_with_id("a", Severity::Error),
1106            mk_diag_with_id("b", Severity::Warning),
1107            mk_diag_with_id("c", Severity::Info),
1108        ];
1109        let accepted = filter.apply(&diags);
1110        assert_eq!(accepted.len(), 1);
1111    }
1112
1113    #[test]
1114    fn test_lint_output_format_display() {
1115        assert_eq!(format!("{}", LintOutputFormat::Text), "text");
1116        assert_eq!(format!("{}", LintOutputFormat::Json), "json");
1117        assert_eq!(format!("{}", LintOutputFormat::Count), "count");
1118    }
1119
1120    #[test]
1121    fn test_lint_output_format_from_str() {
1122        assert_eq!(
1123            LintOutputFormat::parse("json"),
1124            Some(LintOutputFormat::Json)
1125        );
1126        assert_eq!(LintOutputFormat::parse("unknown"), None);
1127    }
1128
1129    #[test]
1130    fn test_lint_profile_basic() {
1131        let profile = LintProfile::new("strict");
1132        assert_eq!(profile.name, "strict");
1133        assert!(profile.rule_sets.is_empty());
1134    }
1135
1136    #[test]
1137    fn test_lint_profile_with_rule_set() {
1138        let mut rs = LintRuleSet::new("style");
1139        rs.add("unused_variable");
1140        rs.add("dead_code");
1141        let profile = LintProfile::new("standard").with_rule_set(rs);
1142        assert_eq!(profile.all_ids().len(), 2);
1143    }
1144
1145    #[test]
1146    fn test_lint_profile_overrides() {
1147        let profile = LintProfile::new("strict").with_override("dead_code", LintLevel::Deny);
1148        let id = LintId::new("dead_code");
1149        assert_eq!(profile.effective_level(&id), Some(LintLevel::Deny));
1150        let id2 = LintId::new("nonexistent");
1151        assert_eq!(profile.effective_level(&id2), None);
1152    }
1153
1154    #[test]
1155    fn test_lint_stats_is_clean_after_info_only() {
1156        let mut s = LintStats::new();
1157        s.record(Severity::Info);
1158        assert!(s.is_clean());
1159    }
1160
1161    #[test]
1162    fn test_lint_filter_both_include_and_exclude() {
1163        // include wins for `*` but exclude removes specific
1164        let filter = LintFilter::new()
1165            .include("unused_*")
1166            .exclude("unused_import");
1167        let pass = mk_diag_with_id("unused_variable", Severity::Hint);
1168        let excluded = mk_diag_with_id("unused_import", Severity::Hint);
1169        assert!(filter.accepts(&pass));
1170        assert!(!filter.accepts(&excluded));
1171    }
1172}
1173
1174// ============================================================
1175// LintDatabase
1176// ============================================================
1177
1178/// A persistent store of all known lint rules and their metadata.
1179#[derive(Debug, Default)]
1180#[allow(dead_code)]
1181pub struct LintDatabase {
1182    entries: std::collections::HashMap<String, LintEntry>,
1183}
1184
1185/// Metadata for a single lint rule stored in the database.
1186#[derive(Clone, Debug)]
1187#[allow(dead_code)]
1188pub struct LintEntry {
1189    pub id: LintId,
1190    pub description: String,
1191    pub default_level: Severity,
1192    pub tags: Vec<String>,
1193    pub has_autofix: bool,
1194}
1195
1196impl LintEntry {
1197    #[allow(dead_code)]
1198    pub fn new(id: &str, description: &str, default_level: Severity) -> Self {
1199        Self {
1200            id: LintId::new(id),
1201            description: description.to_string(),
1202            default_level,
1203            tags: Vec::new(),
1204            has_autofix: false,
1205        }
1206    }
1207
1208    #[allow(dead_code)]
1209    pub fn with_tag(mut self, tag: &str) -> Self {
1210        self.tags.push(tag.to_string());
1211        self
1212    }
1213
1214    #[allow(dead_code)]
1215    pub fn with_autofix(mut self) -> Self {
1216        self.has_autofix = true;
1217        self
1218    }
1219}
1220
1221impl LintDatabase {
1222    #[allow(dead_code)]
1223    pub fn new() -> Self {
1224        Self {
1225            entries: std::collections::HashMap::new(),
1226        }
1227    }
1228
1229    /// Register a lint entry.
1230    #[allow(dead_code)]
1231    pub fn register(&mut self, entry: LintEntry) {
1232        self.entries.insert(entry.id.as_str().to_string(), entry);
1233    }
1234
1235    /// Look up a lint entry by ID string.
1236    #[allow(dead_code)]
1237    pub fn get(&self, id: &str) -> Option<&LintEntry> {
1238        self.entries.get(id)
1239    }
1240
1241    /// Return all lint IDs sorted.
1242    #[allow(dead_code)]
1243    pub fn all_ids(&self) -> Vec<&str> {
1244        let mut ids: Vec<&str> = self.entries.keys().map(|s| s.as_str()).collect();
1245        ids.sort();
1246        ids
1247    }
1248
1249    /// Return entries matching a given tag.
1250    #[allow(dead_code)]
1251    pub fn by_tag(&self, tag: &str) -> Vec<&LintEntry> {
1252        self.entries
1253            .values()
1254            .filter(|e| e.tags.iter().any(|t| t == tag))
1255            .collect()
1256    }
1257
1258    /// Return entries that have an auto-fix available.
1259    #[allow(dead_code)]
1260    pub fn with_autofix(&self) -> Vec<&LintEntry> {
1261        self.entries.values().filter(|e| e.has_autofix).collect()
1262    }
1263
1264    /// Total number of entries.
1265    #[allow(dead_code)]
1266    pub fn len(&self) -> usize {
1267        self.entries.len()
1268    }
1269
1270    #[allow(dead_code)]
1271    pub fn is_empty(&self) -> bool {
1272        self.entries.is_empty()
1273    }
1274}
1275
1276// ============================================================
1277// LintRunOptions
1278// ============================================================
1279
1280/// Options controlling a single lint run.
1281#[derive(Clone, Debug)]
1282#[allow(dead_code)]
1283pub struct LintRunOptions {
1284    /// Whether to emit `Info`-level diagnostics.
1285    pub include_info: bool,
1286    /// Whether to emit `Hint`-level diagnostics.
1287    pub include_hints: bool,
1288    /// Maximum number of diagnostics to emit before truncating.
1289    pub max_diagnostics: Option<usize>,
1290    /// Whether to apply auto-fixes automatically.
1291    pub auto_apply_fixes: bool,
1292    /// Whether to stop on the first error.
1293    pub fail_fast: bool,
1294}
1295
1296impl LintRunOptions {
1297    #[allow(dead_code)]
1298    pub fn default_opts() -> Self {
1299        Self {
1300            include_info: true,
1301            include_hints: false,
1302            max_diagnostics: None,
1303            auto_apply_fixes: false,
1304            fail_fast: false,
1305        }
1306    }
1307
1308    #[allow(dead_code)]
1309    pub fn strict() -> Self {
1310        Self {
1311            include_info: true,
1312            include_hints: true,
1313            max_diagnostics: None,
1314            auto_apply_fixes: false,
1315            fail_fast: true,
1316        }
1317    }
1318}
1319
1320impl Default for LintRunOptions {
1321    fn default() -> Self {
1322        Self::default_opts()
1323    }
1324}
1325
1326// ============================================================
1327// LintSummaryReport
1328// ============================================================
1329
1330/// A high-level summary report of a lint run.
1331#[allow(dead_code)]
1332pub struct LintSummaryReport {
1333    pub total_diagnostics: usize,
1334    pub by_severity: std::collections::HashMap<String, usize>,
1335    pub by_category: std::collections::HashMap<String, usize>,
1336    pub files_with_issues: usize,
1337    pub auto_fixes_available: usize,
1338}
1339
1340impl LintSummaryReport {
1341    #[allow(dead_code)]
1342    pub fn new() -> Self {
1343        Self {
1344            total_diagnostics: 0,
1345            by_severity: std::collections::HashMap::new(),
1346            by_category: std::collections::HashMap::new(),
1347            files_with_issues: 0,
1348            auto_fixes_available: 0,
1349        }
1350    }
1351
1352    /// Add a diagnostic to the report.
1353    #[allow(dead_code)]
1354    pub fn add(&mut self, diag: &LintDiagnostic) {
1355        self.total_diagnostics += 1;
1356        let sev_key = format!("{:?}", diag.severity).to_lowercase();
1357        *self.by_severity.entry(sev_key).or_insert(0) += 1;
1358        if diag.fix.is_some() {
1359            self.auto_fixes_available += 1;
1360        }
1361    }
1362
1363    /// Returns `true` when there are no errors or warnings.
1364    #[allow(dead_code)]
1365    pub fn is_clean(&self) -> bool {
1366        let errors = self.by_severity.get("error").copied().unwrap_or(0);
1367        let warnings = self.by_severity.get("warning").copied().unwrap_or(0);
1368        errors == 0 && warnings == 0
1369    }
1370}
1371
1372impl Default for LintSummaryReport {
1373    fn default() -> Self {
1374        Self::new()
1375    }
1376}
1377
1378// ============================================================
1379// LintIgnoreList
1380// ============================================================
1381
1382/// A list of lint IDs that are explicitly ignored (suppressed globally).
1383#[allow(dead_code)]
1384pub struct LintIgnoreList {
1385    ignored: std::collections::HashSet<String>,
1386}
1387
1388impl LintIgnoreList {
1389    #[allow(dead_code)]
1390    pub fn new() -> Self {
1391        Self {
1392            ignored: std::collections::HashSet::new(),
1393        }
1394    }
1395
1396    /// Add a lint ID to the ignore list.
1397    #[allow(dead_code)]
1398    pub fn ignore(&mut self, id: &str) {
1399        self.ignored.insert(id.to_string());
1400    }
1401
1402    /// Returns `true` if the given ID is ignored.
1403    #[allow(dead_code)]
1404    pub fn is_ignored(&self, id: &str) -> bool {
1405        self.ignored.contains(id)
1406    }
1407
1408    /// Filter a slice of diagnostics, removing any that are ignored.
1409    #[allow(dead_code)]
1410    pub fn filter<'a>(&self, diags: &'a [LintDiagnostic]) -> Vec<&'a LintDiagnostic> {
1411        diags
1412            .iter()
1413            .filter(|d| !self.is_ignored(d.lint_id.as_str()))
1414            .collect()
1415    }
1416
1417    /// Number of ignored lints.
1418    #[allow(dead_code)]
1419    pub fn len(&self) -> usize {
1420        self.ignored.len()
1421    }
1422}
1423
1424impl Default for LintIgnoreList {
1425    fn default() -> Self {
1426        Self::new()
1427    }
1428}
1429
1430// ============================================================
1431// LintFormatter
1432// ============================================================
1433
1434/// Formats `LintDiagnostic`s into strings according to an output format.
1435#[allow(dead_code)]
1436pub struct LintFormatter {
1437    pub format: LintOutputFormat,
1438}
1439
1440impl LintFormatter {
1441    #[allow(dead_code)]
1442    pub fn new(format: LintOutputFormat) -> Self {
1443        Self { format }
1444    }
1445
1446    /// Format a single diagnostic.
1447    #[allow(dead_code)]
1448    pub fn format_one(&self, diag: &LintDiagnostic) -> String {
1449        let file = diag.range.file.as_deref().unwrap_or("unknown");
1450        let offset = diag.range.start;
1451        match self.format {
1452            LintOutputFormat::Text => {
1453                format!(
1454                    "[{:?}] {} at {}:{}: {}",
1455                    diag.severity,
1456                    diag.lint_id.as_str(),
1457                    file,
1458                    offset,
1459                    diag.message
1460                )
1461            }
1462            LintOutputFormat::GitHubActions => {
1463                let level = match diag.severity {
1464                    Severity::Error => "error",
1465                    Severity::Warning => "warning",
1466                    Severity::Hint | Severity::Info => "notice",
1467                };
1468                format!(
1469                    "::{} file={},line={}::{}",
1470                    level, file, offset, diag.message
1471                )
1472            }
1473            LintOutputFormat::Json => {
1474                format!(
1475                    "{{\"id\":\"{}\",\"severity\":\"{:?}\",\"file\":\"{}\",\"line\":{},\"message\":\"{}\"}}",
1476                    diag.lint_id.as_str(),
1477                    diag.severity,
1478                    file,
1479                    offset,
1480                    diag.message.replace('"', "\\\"")
1481                )
1482            }
1483            LintOutputFormat::Count => {
1484                format!(
1485                    "{}:{}:{:?}:{} - {}",
1486                    file,
1487                    offset,
1488                    diag.severity,
1489                    diag.lint_id.as_str(),
1490                    diag.message
1491                )
1492            }
1493        }
1494    }
1495
1496    /// Format multiple diagnostics and return a combined string.
1497    #[allow(dead_code)]
1498    pub fn format_all(&self, diags: &[LintDiagnostic]) -> String {
1499        diags
1500            .iter()
1501            .map(|d| self.format_one(d))
1502            .collect::<Vec<_>>()
1503            .join("\n")
1504    }
1505}
1506
1507// ============================================================
1508// LintTrend
1509// ============================================================
1510
1511/// Tracks diagnostic counts across runs to detect trends.
1512#[allow(dead_code)]
1513pub struct LintTrend {
1514    snapshots: Vec<(String, usize)>,
1515}
1516
1517impl LintTrend {
1518    #[allow(dead_code)]
1519    pub fn new() -> Self {
1520        Self {
1521            snapshots: Vec::new(),
1522        }
1523    }
1524
1525    /// Record a new snapshot with a label and diagnostic count.
1526    #[allow(dead_code)]
1527    pub fn record(&mut self, label: &str, count: usize) {
1528        self.snapshots.push((label.to_string(), count));
1529    }
1530
1531    /// Returns `true` when the latest count is less than the previous.
1532    #[allow(dead_code)]
1533    pub fn is_improving(&self) -> bool {
1534        if self.snapshots.len() < 2 {
1535            return false;
1536        }
1537        let prev = self.snapshots[self.snapshots.len() - 2].1;
1538        let latest = self.snapshots[self.snapshots.len() - 1].1;
1539        latest < prev
1540    }
1541
1542    /// Latest diagnostic count.
1543    #[allow(dead_code)]
1544    pub fn latest_count(&self) -> usize {
1545        self.snapshots.last().map(|(_, c)| *c).unwrap_or(0)
1546    }
1547
1548    /// Number of snapshots.
1549    #[allow(dead_code)]
1550    pub fn snapshot_count(&self) -> usize {
1551        self.snapshots.len()
1552    }
1553}
1554
1555impl Default for LintTrend {
1556    fn default() -> Self {
1557        Self::new()
1558    }
1559}
1560
1561// ============================================================
1562// LintBaseline
1563// ============================================================
1564
1565/// Records a known set of lint diagnostics as a baseline for comparison.
1566#[allow(dead_code)]
1567pub struct LintBaseline {
1568    /// Known diagnostic fingerprints (id + location key).
1569    known: std::collections::HashSet<String>,
1570}
1571
1572impl LintBaseline {
1573    #[allow(dead_code)]
1574    pub fn new() -> Self {
1575        Self {
1576            known: std::collections::HashSet::new(),
1577        }
1578    }
1579
1580    /// Add a diagnostic to the baseline.
1581    #[allow(dead_code)]
1582    pub fn add(&mut self, diag: &LintDiagnostic) {
1583        let file = diag.range.file.as_deref().unwrap_or("unknown");
1584        let key = format!("{}:{}:{}", diag.lint_id.as_str(), file, diag.range.start);
1585        self.known.insert(key);
1586    }
1587
1588    /// Returns `true` when `diag` is already in the baseline (i.e., not new).
1589    #[allow(dead_code)]
1590    pub fn is_known(&self, diag: &LintDiagnostic) -> bool {
1591        let file = diag.range.file.as_deref().unwrap_or("unknown");
1592        let key = format!("{}:{}:{}", diag.lint_id.as_str(), file, diag.range.start);
1593        self.known.contains(&key)
1594    }
1595
1596    /// Filter to only new diagnostics not in the baseline.
1597    #[allow(dead_code)]
1598    pub fn new_diagnostics<'a>(&self, diags: &'a [LintDiagnostic]) -> Vec<&'a LintDiagnostic> {
1599        diags.iter().filter(|d| !self.is_known(d)).collect()
1600    }
1601
1602    /// Number of items in the baseline.
1603    #[allow(dead_code)]
1604    pub fn size(&self) -> usize {
1605        self.known.len()
1606    }
1607}
1608
1609impl Default for LintBaseline {
1610    fn default() -> Self {
1611        Self::new()
1612    }
1613}
1614
1615// ============================================================
1616// LintRuleGroup
1617// ============================================================
1618
1619/// A named group of lint rules for easy management.
1620#[allow(dead_code)]
1621pub struct LintRuleGroup {
1622    pub name: String,
1623    pub description: String,
1624    pub rules: Vec<String>,
1625}
1626
1627impl LintRuleGroup {
1628    #[allow(dead_code)]
1629    pub fn new(name: &str, description: &str) -> Self {
1630        Self {
1631            name: name.to_string(),
1632            description: description.to_string(),
1633            rules: Vec::new(),
1634        }
1635    }
1636
1637    #[allow(dead_code)]
1638    pub fn add_rule(&mut self, rule: &str) {
1639        self.rules.push(rule.to_string());
1640    }
1641
1642    #[allow(dead_code)]
1643    pub fn rule_count(&self) -> usize {
1644        self.rules.len()
1645    }
1646
1647    #[allow(dead_code)]
1648    pub fn contains(&self, rule: &str) -> bool {
1649        self.rules.iter().any(|r| r == rule)
1650    }
1651}
1652
1653// ============================================================
1654// LintAggregator
1655// ============================================================
1656
1657/// Aggregates diagnostics from multiple sources into a combined set.
1658#[allow(dead_code)]
1659pub struct LintAggregator {
1660    diagnostics: Vec<LintDiagnostic>,
1661}
1662
1663impl LintAggregator {
1664    #[allow(dead_code)]
1665    pub fn new() -> Self {
1666        Self {
1667            diagnostics: Vec::new(),
1668        }
1669    }
1670
1671    /// Add a single diagnostic.
1672    #[allow(dead_code)]
1673    pub fn add(&mut self, diag: LintDiagnostic) {
1674        self.diagnostics.push(diag);
1675    }
1676
1677    /// Add multiple diagnostics.
1678    #[allow(dead_code)]
1679    pub fn add_all(&mut self, diags: Vec<LintDiagnostic>) {
1680        self.diagnostics.extend(diags);
1681    }
1682
1683    /// Consume the aggregator and return the collected diagnostics.
1684    #[allow(dead_code)]
1685    pub fn into_diagnostics(self) -> Vec<LintDiagnostic> {
1686        self.diagnostics
1687    }
1688
1689    /// Number of diagnostics collected.
1690    #[allow(dead_code)]
1691    pub fn count(&self) -> usize {
1692        self.diagnostics.len()
1693    }
1694
1695    /// Count by severity.
1696    #[allow(dead_code)]
1697    pub fn count_by_severity(&self, severity: Severity) -> usize {
1698        self.diagnostics
1699            .iter()
1700            .filter(|d| d.severity == severity)
1701            .count()
1702    }
1703}
1704
1705impl Default for LintAggregator {
1706    fn default() -> Self {
1707        Self::new()
1708    }
1709}
1710
1711// ============================================================
1712// LintEventLog
1713// ============================================================
1714
1715/// Logs lint events (rule run, diagnostic emitted, fix applied) for debugging.
1716#[allow(dead_code)]
1717pub struct LintEventLog {
1718    events: Vec<LintEvent>,
1719    counter: u64,
1720}
1721
1722/// A single lint event.
1723#[allow(dead_code)]
1724#[derive(Clone, Debug)]
1725pub struct LintEvent {
1726    pub id: u64,
1727    pub kind: LintEventKind,
1728    pub message: String,
1729}
1730
1731/// The kind of lint event.
1732#[allow(dead_code)]
1733#[derive(Clone, Debug)]
1734pub enum LintEventKind {
1735    RuleStarted,
1736    RuleFinished,
1737    DiagnosticEmitted,
1738    FixApplied,
1739    FixSkipped,
1740    PassEnabled,
1741    PassDisabled,
1742}
1743
1744impl LintEventLog {
1745    #[allow(dead_code)]
1746    pub fn new() -> Self {
1747        Self {
1748            events: Vec::new(),
1749            counter: 0,
1750        }
1751    }
1752
1753    #[allow(dead_code)]
1754    pub fn log(&mut self, kind: LintEventKind, message: &str) -> u64 {
1755        self.counter += 1;
1756        let id = self.counter;
1757        self.events.push(LintEvent {
1758            id,
1759            kind,
1760            message: message.to_string(),
1761        });
1762        id
1763    }
1764
1765    #[allow(dead_code)]
1766    pub fn total(&self) -> usize {
1767        self.events.len()
1768    }
1769
1770    #[allow(dead_code)]
1771    pub fn events(&self) -> &[LintEvent] {
1772        &self.events
1773    }
1774}
1775
1776impl Default for LintEventLog {
1777    fn default() -> Self {
1778        Self::new()
1779    }
1780}
1781
1782// ============================================================
1783// LintDiff
1784// ============================================================
1785
1786/// Computes the diff between two sets of diagnostics (added/removed).
1787#[allow(dead_code)]
1788pub struct LintDiff {
1789    pub added: Vec<String>,
1790    pub removed: Vec<String>,
1791}
1792
1793impl LintDiff {
1794    /// Compute the diff between `before` and `after` sets of diagnostic fingerprints.
1795    #[allow(dead_code)]
1796    pub fn compute(before: &[String], after: &[String]) -> Self {
1797        let before_set: std::collections::HashSet<&String> = before.iter().collect();
1798        let after_set: std::collections::HashSet<&String> = after.iter().collect();
1799        let added = after_set
1800            .difference(&before_set)
1801            .map(|s| s.to_string())
1802            .collect();
1803        let removed = before_set
1804            .difference(&after_set)
1805            .map(|s| s.to_string())
1806            .collect();
1807        Self { added, removed }
1808    }
1809
1810    /// Returns `true` when there are no differences.
1811    #[allow(dead_code)]
1812    pub fn is_empty(&self) -> bool {
1813        self.added.is_empty() && self.removed.is_empty()
1814    }
1815}
1816
1817// ============================================================
1818// Additional tests
1819// ============================================================
1820
1821#[cfg(test)]
1822mod lib_extended_tests {
1823    use super::*;
1824    fn mk_diag(id: &str, severity: Severity) -> LintDiagnostic {
1825        LintDiagnostic::new(
1826            LintId::new(id),
1827            severity,
1828            "test",
1829            framework::SourceRange::with_file(0, 0, "test.ox".to_string()),
1830        )
1831    }
1832
1833    // --- LintDatabase ---
1834
1835    #[test]
1836    fn lint_database_register_and_get() {
1837        let mut db = LintDatabase::new();
1838        let entry = LintEntry::new("unused_import", "Remove unused imports", Severity::Warning)
1839            .with_tag("style")
1840            .with_autofix();
1841        db.register(entry);
1842        assert!(!db.is_empty());
1843        let found = db.get("unused_import").expect("key should exist");
1844        assert!(found.has_autofix);
1845        assert!(found.tags.contains(&"style".to_string()));
1846    }
1847
1848    #[test]
1849    fn lint_database_by_tag() {
1850        let mut db = LintDatabase::new();
1851        db.register(LintEntry::new("a", "a", Severity::Info).with_tag("security"));
1852        db.register(LintEntry::new("b", "b", Severity::Info).with_tag("style"));
1853        db.register(LintEntry::new("c", "c", Severity::Info).with_tag("security"));
1854        let sec = db.by_tag("security");
1855        assert_eq!(sec.len(), 2);
1856    }
1857
1858    #[test]
1859    fn lint_database_with_autofix() {
1860        let mut db = LintDatabase::new();
1861        db.register(LintEntry::new("fixable", "fixable", Severity::Warning).with_autofix());
1862        db.register(LintEntry::new("not_fixable", "no fix", Severity::Warning));
1863        let fixable = db.with_autofix();
1864        assert_eq!(fixable.len(), 1);
1865    }
1866
1867    // --- LintRunOptions ---
1868
1869    #[test]
1870    fn lint_run_options_default() {
1871        let opts = LintRunOptions::default_opts();
1872        assert!(opts.include_info);
1873        assert!(!opts.include_hints);
1874        assert!(!opts.auto_apply_fixes);
1875        assert!(!opts.fail_fast);
1876    }
1877
1878    #[test]
1879    fn lint_run_options_strict() {
1880        let opts = LintRunOptions::strict();
1881        assert!(opts.include_hints);
1882        assert!(opts.fail_fast);
1883    }
1884
1885    // --- LintCategory ---
1886
1887    #[test]
1888    fn lint_category_display() {
1889        assert_eq!(format!("{}", LintCategory::Style), "style");
1890        assert_eq!(format!("{}", LintCategory::Security), "security");
1891        assert_eq!(
1892            format!("{}", LintCategory::Custom("my_cat".to_string())),
1893            "custom:my_cat"
1894        );
1895    }
1896
1897    // --- LintSummaryReport ---
1898
1899    #[test]
1900    fn lint_summary_report_add() {
1901        let mut report = LintSummaryReport::new();
1902        let diag = mk_diag("test", Severity::Warning);
1903        report.add(&diag);
1904        assert_eq!(report.total_diagnostics, 1);
1905        assert!(!report.is_clean()); // has warning
1906    }
1907
1908    #[test]
1909    fn lint_summary_report_clean_with_info_only() {
1910        let mut report = LintSummaryReport::new();
1911        report.add(&mk_diag("test", Severity::Info));
1912        assert!(report.is_clean());
1913    }
1914
1915    // --- LintIgnoreList ---
1916
1917    #[test]
1918    fn lint_ignore_list_filters() {
1919        let mut ignore = LintIgnoreList::new();
1920        ignore.ignore("dead_code");
1921        ignore.ignore("unused_import");
1922        let diags = vec![
1923            mk_diag("dead_code", Severity::Warning),
1924            mk_diag("naming_convention", Severity::Warning),
1925        ];
1926        let filtered = ignore.filter(&diags);
1927        assert_eq!(filtered.len(), 1);
1928        assert_eq!(filtered[0].lint_id.as_str(), "naming_convention");
1929    }
1930
1931    #[test]
1932    fn lint_ignore_list_is_ignored() {
1933        let mut ignore = LintIgnoreList::new();
1934        ignore.ignore("foo");
1935        assert!(ignore.is_ignored("foo"));
1936        assert!(!ignore.is_ignored("bar"));
1937        assert_eq!(ignore.len(), 1);
1938    }
1939
1940    // --- LintOutputFormat ---
1941
1942    #[test]
1943    fn lint_output_format_display() {
1944        assert_eq!(format!("{}", LintOutputFormat::Text), "text");
1945        assert_eq!(format!("{}", LintOutputFormat::Json), "json");
1946        assert_eq!(
1947            format!("{}", LintOutputFormat::GitHubActions),
1948            "github-actions"
1949        );
1950        assert_eq!(format!("{}", LintOutputFormat::Count), "count");
1951    }
1952
1953    // --- LintFormatter ---
1954
1955    #[test]
1956    fn lint_formatter_text() {
1957        let formatter = LintFormatter::new(LintOutputFormat::Text);
1958        let diag = mk_diag("unused_import", Severity::Warning);
1959        let output = formatter.format_one(&diag);
1960        assert!(output.contains("unused_import"));
1961        assert!(output.contains("test.ox"));
1962    }
1963
1964    #[test]
1965    fn lint_formatter_github() {
1966        let formatter = LintFormatter::new(LintOutputFormat::GitHubActions);
1967        let diag = mk_diag("unused_import", Severity::Warning);
1968        let output = formatter.format_one(&diag);
1969        assert!(output.starts_with("::warning"));
1970    }
1971
1972    #[test]
1973    fn lint_formatter_json() {
1974        let formatter = LintFormatter::new(LintOutputFormat::Json);
1975        let diag = mk_diag("foo", Severity::Error);
1976        let output = formatter.format_one(&diag);
1977        assert!(output.starts_with('{'));
1978        assert!(output.contains("\"id\":\"foo\""));
1979    }
1980
1981    #[test]
1982    fn lint_formatter_compact() {
1983        let formatter = LintFormatter::new(LintOutputFormat::Count);
1984        let diag = mk_diag("bar", Severity::Info);
1985        let output = formatter.format_one(&diag);
1986        assert!(output.contains("bar"));
1987    }
1988
1989    #[test]
1990    fn lint_formatter_format_all() {
1991        let formatter = LintFormatter::new(LintOutputFormat::Count);
1992        let diags = vec![
1993            mk_diag("a", Severity::Warning),
1994            mk_diag("b", Severity::Info),
1995        ];
1996        let output = formatter.format_all(&diags);
1997        assert!(output.contains('\n'));
1998    }
1999
2000    // --- LintTrend ---
2001
2002    #[test]
2003    fn lint_trend_improving() {
2004        let mut trend = LintTrend::new();
2005        trend.record("v1", 10);
2006        trend.record("v2", 5);
2007        assert!(trend.is_improving());
2008        assert_eq!(trend.latest_count(), 5);
2009        assert_eq!(trend.snapshot_count(), 2);
2010    }
2011
2012    #[test]
2013    fn lint_trend_not_improving() {
2014        let mut trend = LintTrend::new();
2015        trend.record("v1", 3);
2016        trend.record("v2", 7);
2017        assert!(!trend.is_improving());
2018    }
2019
2020    // --- LintBaseline ---
2021
2022    #[test]
2023    fn lint_baseline_filters_known() {
2024        let diag = mk_diag("dead_code", Severity::Warning);
2025        let mut baseline = LintBaseline::new();
2026        baseline.add(&diag);
2027        assert!(baseline.is_known(&diag));
2028
2029        let new_diag = mk_diag("new_lint", Severity::Warning);
2030        assert!(!baseline.is_known(&new_diag));
2031
2032        let all = vec![diag, new_diag];
2033        let new_only = baseline.new_diagnostics(&all);
2034        assert_eq!(new_only.len(), 1);
2035        assert_eq!(new_only[0].lint_id.as_str(), "new_lint");
2036    }
2037
2038    // --- LintRuleGroup ---
2039
2040    #[test]
2041    fn lint_rule_group_contains() {
2042        let mut group = LintRuleGroup::new("style", "Style rules");
2043        group.add_rule("naming_convention");
2044        group.add_rule("unused_import");
2045        assert!(group.contains("naming_convention"));
2046        assert!(!group.contains("dead_code"));
2047        assert_eq!(group.rule_count(), 2);
2048    }
2049
2050    // --- LintAggregator ---
2051
2052    #[test]
2053    fn lint_aggregator_collects() {
2054        let mut agg = LintAggregator::new();
2055        agg.add(mk_diag("a", Severity::Warning));
2056        agg.add(mk_diag("b", Severity::Error));
2057        agg.add_all(vec![mk_diag("c", Severity::Info)]);
2058        assert_eq!(agg.count(), 3);
2059        assert_eq!(agg.count_by_severity(Severity::Warning), 1);
2060        assert_eq!(agg.count_by_severity(Severity::Error), 1);
2061    }
2062
2063    #[test]
2064    fn lint_aggregator_into_diagnostics() {
2065        let mut agg = LintAggregator::new();
2066        agg.add(mk_diag("x", Severity::Info));
2067        let diags = agg.into_diagnostics();
2068        assert_eq!(diags.len(), 1);
2069    }
2070
2071    // --- LintEventLog ---
2072
2073    #[test]
2074    fn lint_event_log_basic() {
2075        let mut log = LintEventLog::new();
2076        let id = log.log(LintEventKind::RuleStarted, "checking naming_convention");
2077        assert_eq!(log.total(), 1);
2078        assert_eq!(log.events()[0].id, id);
2079    }
2080
2081    // --- LintDiff ---
2082
2083    #[test]
2084    fn lint_diff_no_change() {
2085        let fingerprints = vec!["a".to_string(), "b".to_string()];
2086        let diff = LintDiff::compute(&fingerprints, &fingerprints);
2087        assert!(diff.is_empty());
2088    }
2089
2090    #[test]
2091    fn lint_diff_new_and_removed() {
2092        let before = vec!["a".to_string(), "b".to_string()];
2093        let after = vec!["b".to_string(), "c".to_string()];
2094        let diff = LintDiff::compute(&before, &after);
2095        assert!(!diff.is_empty());
2096        assert!(diff.added.contains(&"c".to_string()));
2097        assert!(diff.removed.contains(&"a".to_string()));
2098    }
2099}
2100
2101// ============================================================
2102// LintRuleMetadata
2103// ============================================================
2104
2105/// Complete metadata for a lint rule.
2106#[allow(dead_code)]
2107pub struct LintRuleMetadata {
2108    pub id: LintId,
2109    pub name: String,
2110    pub category: LintCategory,
2111    pub default_level: Severity,
2112    pub description: String,
2113    pub rationale: String,
2114    pub examples: Vec<LintExample>,
2115    pub since_version: String,
2116    pub deprecated: bool,
2117}
2118
2119/// An example of a lint rule firing (good/bad pair).
2120#[allow(dead_code)]
2121pub struct LintExample {
2122    pub title: String,
2123    pub bad: String,
2124    pub good: String,
2125}
2126
2127impl LintRuleMetadata {
2128    #[allow(dead_code)]
2129    pub fn new(id: &str, name: &str, category: LintCategory, default_level: Severity) -> Self {
2130        Self {
2131            id: LintId::new(id),
2132            name: name.to_string(),
2133            category,
2134            default_level,
2135            description: String::new(),
2136            rationale: String::new(),
2137            examples: Vec::new(),
2138            since_version: "0.1.1".to_string(),
2139            deprecated: false,
2140        }
2141    }
2142
2143    #[allow(dead_code)]
2144    pub fn with_description(mut self, desc: &str) -> Self {
2145        self.description = desc.to_string();
2146        self
2147    }
2148
2149    #[allow(dead_code)]
2150    pub fn with_rationale(mut self, rationale: &str) -> Self {
2151        self.rationale = rationale.to_string();
2152        self
2153    }
2154
2155    #[allow(dead_code)]
2156    pub fn with_example(mut self, title: &str, bad: &str, good: &str) -> Self {
2157        self.examples.push(LintExample {
2158            title: title.to_string(),
2159            bad: bad.to_string(),
2160            good: good.to_string(),
2161        });
2162        self
2163    }
2164
2165    #[allow(dead_code)]
2166    pub fn mark_deprecated(mut self) -> Self {
2167        self.deprecated = true;
2168        self
2169    }
2170}
2171
2172// ============================================================
2173// LintPriorityQueue
2174// ============================================================
2175
2176/// A priority queue for diagnostics, returning the most severe first.
2177#[allow(dead_code)]
2178pub struct LintPriorityQueue {
2179    items: Vec<(u8, LintDiagnostic)>,
2180}
2181
2182impl LintPriorityQueue {
2183    #[allow(dead_code)]
2184    pub fn new() -> Self {
2185        Self { items: Vec::new() }
2186    }
2187
2188    #[allow(dead_code)]
2189    fn severity_to_priority(s: Severity) -> u8 {
2190        match s {
2191            Severity::Error => 4,
2192            Severity::Warning => 3,
2193            Severity::Hint => 2,
2194            Severity::Info => 1,
2195        }
2196    }
2197
2198    /// Push a diagnostic into the queue.
2199    #[allow(dead_code)]
2200    pub fn push(&mut self, diag: LintDiagnostic) {
2201        let priority = Self::severity_to_priority(diag.severity);
2202        self.items.push((priority, diag));
2203        // Keep sorted by priority descending.
2204        self.items.sort_by(|a, b| b.0.cmp(&a.0));
2205    }
2206
2207    /// Pop the highest-priority diagnostic.
2208    #[allow(dead_code)]
2209    pub fn pop(&mut self) -> Option<LintDiagnostic> {
2210        if self.items.is_empty() {
2211            None
2212        } else {
2213            Some(self.items.remove(0).1)
2214        }
2215    }
2216
2217    #[allow(dead_code)]
2218    pub fn len(&self) -> usize {
2219        self.items.len()
2220    }
2221
2222    #[allow(dead_code)]
2223    pub fn is_empty(&self) -> bool {
2224        self.items.is_empty()
2225    }
2226}
2227
2228impl Default for LintPriorityQueue {
2229    fn default() -> Self {
2230        Self::new()
2231    }
2232}
2233
2234// ============================================================
2235// LintBudget
2236// ============================================================
2237
2238/// Limits the total number of diagnostics emitted.
2239#[allow(dead_code)]
2240pub struct LintBudget {
2241    pub max_total: usize,
2242    pub max_per_file: usize,
2243    total_used: usize,
2244    per_file_used: std::collections::HashMap<String, usize>,
2245}
2246
2247impl LintBudget {
2248    #[allow(dead_code)]
2249    pub fn new(max_total: usize, max_per_file: usize) -> Self {
2250        Self {
2251            max_total,
2252            max_per_file,
2253            total_used: 0,
2254            per_file_used: std::collections::HashMap::new(),
2255        }
2256    }
2257
2258    /// Try to "spend" a slot for `file`. Returns `false` if any budget is exhausted.
2259    #[allow(dead_code)]
2260    pub fn try_spend(&mut self, file: &str) -> bool {
2261        if self.total_used >= self.max_total {
2262            return false;
2263        }
2264        let per_file = self.per_file_used.entry(file.to_string()).or_insert(0);
2265        if *per_file >= self.max_per_file {
2266            return false;
2267        }
2268        *per_file += 1;
2269        self.total_used += 1;
2270        true
2271    }
2272
2273    #[allow(dead_code)]
2274    pub fn remaining_total(&self) -> usize {
2275        self.max_total.saturating_sub(self.total_used)
2276    }
2277}
2278
2279// ============================================================
2280// LintCooldown
2281// ============================================================
2282
2283/// Suppresses repeated identical diagnostics within a cooldown window.
2284#[allow(dead_code)]
2285pub struct LintCooldown {
2286    pub window: usize,
2287    seen: std::collections::HashMap<String, usize>,
2288    current_tick: usize,
2289}
2290
2291impl LintCooldown {
2292    #[allow(dead_code)]
2293    pub fn new(window: usize) -> Self {
2294        Self {
2295            window,
2296            seen: std::collections::HashMap::new(),
2297            current_tick: 0,
2298        }
2299    }
2300
2301    /// Advance the internal tick counter.
2302    #[allow(dead_code)]
2303    pub fn tick(&mut self) {
2304        self.current_tick += 1;
2305    }
2306
2307    /// Returns `true` if `fingerprint` should be emitted (not in cooldown).
2308    #[allow(dead_code)]
2309    pub fn should_emit(&mut self, fingerprint: &str) -> bool {
2310        match self.seen.get(fingerprint).copied() {
2311            None => {
2312                self.seen.insert(fingerprint.to_string(), self.current_tick);
2313                true
2314            }
2315            Some(last) if self.current_tick.saturating_sub(last) >= self.window => {
2316                self.seen.insert(fingerprint.to_string(), self.current_tick);
2317                true
2318            }
2319            _ => false,
2320        }
2321    }
2322}
2323
2324// ============================================================
2325// Additional tests
2326// ============================================================
2327
2328#[cfg(test)]
2329mod lib_final_tests {
2330    use super::*;
2331
2332    fn mk_diag(id: &str, severity: Severity) -> LintDiagnostic {
2333        LintDiagnostic::new(
2334            LintId::new(id),
2335            severity,
2336            "test",
2337            framework::SourceRange::new(0, 0),
2338        )
2339    }
2340
2341    // --- LintRuleMetadata ---
2342
2343    #[test]
2344    fn lint_rule_metadata_basic() {
2345        let meta = LintRuleMetadata::new(
2346            "unused_import",
2347            "Unused Import",
2348            LintCategory::Style,
2349            Severity::Warning,
2350        )
2351        .with_description("Detects unused imports.")
2352        .with_rationale("Unused imports add noise.")
2353        .with_example("simple", "import Unused", "-- no import");
2354        assert_eq!(meta.id.as_str().to_string(), "unused_import");
2355        assert_eq!(meta.examples.len(), 1);
2356        assert!(!meta.deprecated);
2357    }
2358
2359    #[test]
2360    fn lint_rule_metadata_deprecated() {
2361        let meta =
2362            LintRuleMetadata::new("old_lint", "Old Lint", LintCategory::Style, Severity::Info)
2363                .mark_deprecated();
2364        assert!(meta.deprecated);
2365    }
2366
2367    // --- LintPriorityQueue ---
2368
2369    #[test]
2370    fn lint_priority_queue_orders_by_severity() {
2371        let mut pq = LintPriorityQueue::new();
2372        pq.push(mk_diag("info_lint", Severity::Info));
2373        pq.push(mk_diag("error_lint", Severity::Error));
2374        pq.push(mk_diag("warning_lint", Severity::Warning));
2375        // Error should come out first.
2376        let first = pq.pop().expect("queue should not be empty");
2377        assert_eq!(first.severity, Severity::Error);
2378        let second = pq.pop().expect("queue should not be empty");
2379        assert_eq!(second.severity, Severity::Warning);
2380    }
2381
2382    #[test]
2383    fn lint_priority_queue_empty() {
2384        let mut pq = LintPriorityQueue::new();
2385        assert!(pq.is_empty());
2386        assert!(pq.pop().is_none());
2387    }
2388
2389    // --- LintBudget ---
2390
2391    #[test]
2392    fn lint_budget_total_limit() {
2393        let mut budget = LintBudget::new(2, 10);
2394        assert!(budget.try_spend("a.ox"));
2395        assert!(budget.try_spend("b.ox"));
2396        assert!(!budget.try_spend("c.ox")); // total exhausted
2397        assert_eq!(budget.remaining_total(), 0);
2398    }
2399
2400    #[test]
2401    fn lint_budget_per_file_limit() {
2402        let mut budget = LintBudget::new(100, 2);
2403        assert!(budget.try_spend("a.ox"));
2404        assert!(budget.try_spend("a.ox"));
2405        assert!(!budget.try_spend("a.ox")); // per-file exhausted
2406    }
2407
2408    // --- LintCooldown ---
2409
2410    #[test]
2411    fn lint_cooldown_emits_once_then_suppresses() {
2412        let mut cd = LintCooldown::new(3);
2413        assert!(cd.should_emit("lint:a.ox:1"));
2414        // Same fingerprint within window — should be suppressed.
2415        assert!(!cd.should_emit("lint:a.ox:1"));
2416        // Tick past the window.
2417        cd.tick();
2418        cd.tick();
2419        cd.tick();
2420        assert!(cd.should_emit("lint:a.ox:1"));
2421    }
2422
2423    #[test]
2424    fn lint_cooldown_different_fingerprints() {
2425        let mut cd = LintCooldown::new(5);
2426        assert!(cd.should_emit("fp1"));
2427        assert!(cd.should_emit("fp2")); // different fingerprint, always OK
2428    }
2429}
2430
2431// ============================================================
2432// LintSessionContext
2433// ============================================================
2434
2435/// Context for a lint session across multiple files.
2436#[allow(dead_code)]
2437pub struct LintSessionContext {
2438    pub session_id: String,
2439    pub files_processed: usize,
2440    pub total_diagnostics: usize,
2441    pub elapsed_ms: u64,
2442}
2443
2444impl LintSessionContext {
2445    #[allow(dead_code)]
2446    pub fn new(session_id: &str) -> Self {
2447        Self {
2448            session_id: session_id.to_string(),
2449            files_processed: 0,
2450            total_diagnostics: 0,
2451            elapsed_ms: 0,
2452        }
2453    }
2454
2455    #[allow(dead_code)]
2456    pub fn record_file(&mut self, diagnostic_count: usize, elapsed_ms: u64) {
2457        self.files_processed += 1;
2458        self.total_diagnostics += diagnostic_count;
2459        self.elapsed_ms += elapsed_ms;
2460    }
2461
2462    #[allow(dead_code)]
2463    pub fn average_diagnostics_per_file(&self) -> f64 {
2464        if self.files_processed == 0 {
2465            return 0.0;
2466        }
2467        self.total_diagnostics as f64 / self.files_processed as f64
2468    }
2469}
2470
2471// ============================================================
2472// LintConfigValidator
2473// ============================================================
2474
2475/// Validates lint configuration for consistency.
2476#[allow(dead_code)]
2477pub struct LintConfigValidator;
2478
2479impl LintConfigValidator {
2480    /// Validate that a config has no conflicting entries.
2481    /// Returns a list of validation error messages.
2482    #[allow(dead_code)]
2483    pub fn validate(config: &LintConfig) -> Vec<String> {
2484        let mut errors = Vec::new();
2485        // Check that no rule appears in both enabled and disabled sets.
2486        for id in config.enabled.iter() {
2487            if config.disabled.contains(id) {
2488                errors.push(format!(
2489                    "Rule `{}` appears in both enabled and disabled lists",
2490                    id.as_str().to_string()
2491                ));
2492            }
2493        }
2494        errors
2495    }
2496}
2497
2498#[cfg(test)]
2499mod lint_session_tests {
2500    use super::*;
2501
2502    #[test]
2503    fn lint_session_context_average() {
2504        let mut ctx = LintSessionContext::new("sess-1");
2505        ctx.record_file(10, 50);
2506        ctx.record_file(20, 100);
2507        assert_eq!(ctx.files_processed, 2);
2508        assert!((ctx.average_diagnostics_per_file() - 15.0).abs() < 1e-9);
2509        assert_eq!(ctx.elapsed_ms, 150);
2510    }
2511
2512    #[test]
2513    fn lint_config_builder_builds() {
2514        let config = LintConfigBuilder::new()
2515            .allow("unused_import")
2516            .deny("dead_code")
2517            .build();
2518        assert_eq!(config.enabled.len(), 1);
2519        assert_eq!(config.disabled.len(), 1);
2520    }
2521
2522    #[test]
2523    fn lint_config_validator_no_conflict() {
2524        let config = LintConfigBuilder::new()
2525            .allow("lint_a")
2526            .allow("lint_b")
2527            .build();
2528        let errors = LintConfigValidator::validate(&config);
2529        assert!(errors.is_empty());
2530    }
2531
2532    #[test]
2533    fn lint_config_validator_with_conflict() {
2534        let config = LintConfigBuilder::new()
2535            .allow("conflict_lint")
2536            .deny("conflict_lint")
2537            .build();
2538        let errors = LintConfigValidator::validate(&config);
2539        assert!(!errors.is_empty());
2540        assert!(errors[0].contains("conflict_lint"));
2541    }
2542}
2543
2544// ============================================================
2545// LintRunSummary
2546// ============================================================
2547
2548/// A high-level end-of-run summary.
2549#[allow(dead_code)]
2550pub struct LintRunSummary {
2551    pub files_checked: usize,
2552    pub total_diagnostics: usize,
2553    pub errors: usize,
2554    pub warnings: usize,
2555    pub elapsed_ms: u64,
2556    pub fix_suggestions: usize,
2557}
2558
2559impl LintRunSummary {
2560    #[allow(dead_code)]
2561    pub fn new() -> Self {
2562        Self {
2563            files_checked: 0,
2564            total_diagnostics: 0,
2565            errors: 0,
2566            warnings: 0,
2567            elapsed_ms: 0,
2568            fix_suggestions: 0,
2569        }
2570    }
2571
2572    /// Returns `true` when the run produced no errors.
2573    #[allow(dead_code)]
2574    pub fn is_success(&self) -> bool {
2575        self.errors == 0
2576    }
2577
2578    /// Diagnostics per millisecond (throughput).
2579    #[allow(dead_code)]
2580    pub fn throughput(&self) -> f64 {
2581        if self.elapsed_ms == 0 {
2582            return 0.0;
2583        }
2584        self.total_diagnostics as f64 / self.elapsed_ms as f64
2585    }
2586}
2587
2588impl Default for LintRunSummary {
2589    fn default() -> Self {
2590        Self::new()
2591    }
2592}
2593
2594#[cfg(test)]
2595mod lint_run_summary_tests {
2596    use super::*;
2597
2598    #[test]
2599    fn lint_run_summary_is_success() {
2600        let mut s = LintRunSummary::new();
2601        assert!(s.is_success());
2602        s.errors = 1;
2603        assert!(!s.is_success());
2604    }
2605
2606    #[test]
2607    fn lint_run_summary_throughput() {
2608        let s = LintRunSummary {
2609            total_diagnostics: 100,
2610            elapsed_ms: 50,
2611            ..LintRunSummary::new()
2612        };
2613        assert!((s.throughput() - 2.0).abs() < 1e-9);
2614    }
2615
2616    #[test]
2617    fn lint_run_summary_zero_elapsed() {
2618        let s = LintRunSummary::new();
2619        assert_eq!(s.throughput(), 0.0);
2620    }
2621}