Skip to main content

rma_common/
config.rs

1//! Enterprise configuration system for RMA
2//!
3//! Supports:
4//! - Local config file: rma.toml in repo root or .rma/rma.toml
5//! - Profiles: fast, balanced, strict
6//! - Rule enable/disable, severity overrides, threshold overrides
7//! - Path-specific overrides
8//! - Allowlists for approved patterns
9//! - Baseline mode for legacy debt management
10
11use crate::Severity;
12use serde::{Deserialize, Serialize};
13use std::collections::HashMap;
14use std::path::{Path, PathBuf};
15
16/// Profile presets for quick configuration
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
18#[serde(rename_all = "lowercase")]
19pub enum Profile {
20    /// Fast scanning with relaxed thresholds
21    Fast,
22    /// Balanced defaults suitable for most projects
23    #[default]
24    Balanced,
25    /// Strict mode for high-quality codebases
26    Strict,
27}
28
29impl std::fmt::Display for Profile {
30    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
31        match self {
32            Profile::Fast => write!(f, "fast"),
33            Profile::Balanced => write!(f, "balanced"),
34            Profile::Strict => write!(f, "strict"),
35        }
36    }
37}
38
39impl std::str::FromStr for Profile {
40    type Err = String;
41
42    fn from_str(s: &str) -> Result<Self, Self::Err> {
43        match s.to_lowercase().as_str() {
44            "fast" => Ok(Profile::Fast),
45            "balanced" => Ok(Profile::Balanced),
46            "strict" => Ok(Profile::Strict),
47            _ => Err(format!(
48                "Unknown profile: {}. Use: fast, balanced, strict",
49                s
50            )),
51        }
52    }
53}
54
55/// Baseline mode for handling legacy code
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
57#[serde(rename_all = "kebab-case")]
58pub enum BaselineMode {
59    /// Report all findings
60    #[default]
61    All,
62    /// Only report new findings not in baseline
63    NewOnly,
64}
65
66/// Scan path configuration
67#[derive(Debug, Clone, Serialize, Deserialize, Default)]
68pub struct ScanConfig {
69    /// Glob patterns to include (default: all supported files)
70    #[serde(default)]
71    pub include: Vec<String>,
72
73    /// Glob patterns to exclude
74    #[serde(default)]
75    pub exclude: Vec<String>,
76
77    /// Maximum file size in bytes (default: 10MB)
78    #[serde(default = "default_max_file_size")]
79    pub max_file_size: usize,
80}
81
82fn default_max_file_size() -> usize {
83    10 * 1024 * 1024
84}
85
86/// Rule configuration
87#[derive(Debug, Clone, Serialize, Deserialize, Default)]
88pub struct RulesConfig {
89    /// Rules to enable (supports wildcards like "security/*")
90    #[serde(default = "default_enable")]
91    pub enable: Vec<String>,
92
93    /// Rules to disable (takes precedence over enable)
94    #[serde(default)]
95    pub disable: Vec<String>,
96
97    /// Global ignore paths - findings in these paths are suppressed for all rules
98    /// Supports glob patterns (e.g., "**/tests/**", "**/examples/**")
99    #[serde(default)]
100    pub ignore_paths: Vec<String>,
101
102    /// Per-rule ignore paths - findings for specific rules in these paths are suppressed
103    /// Maps rule_id or pattern to a list of glob patterns
104    /// e.g., "generic/long-function" -> ["**/tests/**", "**/examples/**"]
105    #[serde(default)]
106    pub ignore_paths_by_rule: HashMap<String, Vec<String>>,
107}
108
109/// Default ignore path presets for common test/example directories
110/// Used automatically in --mode pr and --mode ci unless overridden
111pub const DEFAULT_TEST_IGNORE_PATHS: &[&str] = &[
112    "**/test/**",
113    "**/tests/**",
114    "**/testing/**",
115    "**/__tests__/**",
116    "**/__test__/**",
117    "**/*.test.ts",
118    "**/*.test.js",
119    "**/*.test.tsx",
120    "**/*.test.jsx",
121    "**/*.spec.ts",
122    "**/*.spec.js",
123    "**/*.spec.tsx",
124    "**/*.spec.jsx",
125    "**/test_*.py",
126    "**/*_test.py",
127    "**/tests_*.py",
128    "**/*_test.go",
129    "**/*_test.rs",
130];
131
132/// Default ignore paths for examples/fixtures (less strict rules)
133pub const DEFAULT_EXAMPLE_IGNORE_PATHS: &[&str] = &[
134    "**/examples/**",
135    "**/example/**",
136    "**/fixtures/**",
137    "**/fixture/**",
138    "**/testdata/**",
139    "**/test_data/**",
140    "**/demo/**",
141    "**/demos/**",
142    "**/mocks/**",
143    "**/mock/**",
144    "**/__mocks__/**",
145    "**/stubs/**",
146];
147
148/// Rules that should NOT be suppressed in test/example paths
149/// Security rules should still fire in tests to catch issues
150pub const RULES_ALWAYS_ENABLED: &[&str] = &[
151    "rust/command-injection",
152    "python/shell-injection",
153    "go/command-injection",
154    "java/command-execution",
155    "js/dynamic-code-execution",
156    "generic/hardcoded-secret",
157];
158
159/// Ruleset configuration - named groups of rules
160#[derive(Debug, Clone, Serialize, Deserialize, Default)]
161pub struct RulesetsConfig {
162    /// Security-focused rules
163    #[serde(default)]
164    pub security: Vec<String>,
165
166    /// Maintainability-focused rules
167    #[serde(default)]
168    pub maintainability: Vec<String>,
169
170    /// Custom rulesets defined by user
171    #[serde(flatten)]
172    pub custom: HashMap<String, Vec<String>>,
173}
174
175fn default_enable() -> Vec<String> {
176    vec!["*".to_string()]
177}
178
179/// Profile-specific thresholds
180#[derive(Debug, Clone, Serialize, Deserialize)]
181pub struct ProfileThresholds {
182    /// Maximum lines per function
183    #[serde(default = "default_max_function_lines")]
184    pub max_function_lines: usize,
185
186    /// Maximum cyclomatic complexity
187    #[serde(default = "default_max_complexity")]
188    pub max_complexity: usize,
189
190    /// Maximum cognitive complexity
191    #[serde(default = "default_max_cognitive_complexity")]
192    pub max_cognitive_complexity: usize,
193
194    /// Maximum file lines
195    #[serde(default = "default_max_file_lines")]
196    pub max_file_lines: usize,
197}
198
199fn default_max_function_lines() -> usize {
200    100
201}
202
203fn default_max_complexity() -> usize {
204    15
205}
206
207fn default_max_cognitive_complexity() -> usize {
208    20
209}
210
211fn default_max_file_lines() -> usize {
212    1000
213}
214
215impl Default for ProfileThresholds {
216    fn default() -> Self {
217        Self {
218            max_function_lines: default_max_function_lines(),
219            max_complexity: default_max_complexity(),
220            max_cognitive_complexity: default_max_cognitive_complexity(),
221            max_file_lines: default_max_file_lines(),
222        }
223    }
224}
225
226impl ProfileThresholds {
227    /// Get thresholds for a specific profile
228    pub fn for_profile(profile: Profile) -> Self {
229        match profile {
230            Profile::Fast => Self {
231                max_function_lines: 200,
232                max_complexity: 25,
233                max_cognitive_complexity: 35,
234                max_file_lines: 2000,
235            },
236            Profile::Balanced => Self::default(),
237            Profile::Strict => Self {
238                max_function_lines: 50,
239                max_complexity: 10,
240                max_cognitive_complexity: 15,
241                max_file_lines: 500,
242            },
243        }
244    }
245}
246
247/// All profiles configuration
248#[derive(Debug, Clone, Serialize, Deserialize, Default)]
249pub struct ProfilesConfig {
250    /// Default profile to use
251    #[serde(default)]
252    pub default: Profile,
253
254    /// Fast profile thresholds
255    #[serde(default = "fast_profile_defaults")]
256    pub fast: ProfileThresholds,
257
258    /// Balanced profile thresholds
259    #[serde(default)]
260    pub balanced: ProfileThresholds,
261
262    /// Strict profile thresholds
263    #[serde(default = "strict_profile_defaults")]
264    pub strict: ProfileThresholds,
265}
266
267fn fast_profile_defaults() -> ProfileThresholds {
268    ProfileThresholds::for_profile(Profile::Fast)
269}
270
271fn strict_profile_defaults() -> ProfileThresholds {
272    ProfileThresholds::for_profile(Profile::Strict)
273}
274
275impl ProfilesConfig {
276    /// Get thresholds for the specified profile
277    pub fn get_thresholds(&self, profile: Profile) -> &ProfileThresholds {
278        match profile {
279            Profile::Fast => &self.fast,
280            Profile::Balanced => &self.balanced,
281            Profile::Strict => &self.strict,
282        }
283    }
284}
285
286/// Path-specific threshold overrides
287#[derive(Debug, Clone, Serialize, Deserialize)]
288pub struct ThresholdOverride {
289    /// Glob pattern for paths this override applies to
290    pub path: String,
291
292    /// Maximum function lines (optional)
293    #[serde(default)]
294    pub max_function_lines: Option<usize>,
295
296    /// Maximum complexity (optional)
297    #[serde(default)]
298    pub max_complexity: Option<usize>,
299
300    /// Maximum cognitive complexity (optional)
301    #[serde(default)]
302    pub max_cognitive_complexity: Option<usize>,
303
304    /// Rules to disable for these paths
305    #[serde(default)]
306    pub disable_rules: Vec<String>,
307}
308
309/// Allowlist configuration for approved patterns
310#[derive(Debug, Clone, Serialize, Deserialize, Default)]
311pub struct AllowConfig {
312    /// Allow setTimeout with string argument
313    #[serde(default)]
314    pub settimeout_string: bool,
315
316    /// Allow setTimeout with function argument
317    #[serde(default = "default_true")]
318    pub settimeout_function: bool,
319
320    /// Paths where innerHTML is allowed
321    #[serde(default)]
322    pub innerhtml_paths: Vec<String>,
323
324    /// Paths where eval is allowed (e.g., build tools)
325    #[serde(default)]
326    pub eval_paths: Vec<String>,
327
328    /// Paths where unsafe Rust is allowed
329    #[serde(default)]
330    pub unsafe_rust_paths: Vec<String>,
331
332    /// Approved secret patterns (regex)
333    #[serde(default)]
334    pub approved_secrets: Vec<String>,
335}
336
337fn default_true() -> bool {
338    true
339}
340
341// =============================================================================
342// PROVIDERS CONFIGURATION
343// =============================================================================
344
345/// Available analysis providers
346#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
347#[serde(rename_all = "lowercase")]
348pub enum ProviderType {
349    /// Built-in RMA rules (always available)
350    Rma,
351    /// PMD for Java analysis (optional)
352    Pmd,
353    /// Oxlint for JavaScript/TypeScript via external binary (optional)
354    Oxlint,
355    /// Native Oxc for JavaScript/TypeScript via Rust crates (optional)
356    Oxc,
357    /// RustSec for Rust dependency vulnerabilities (optional)
358    RustSec,
359    /// Gosec for Go security analysis (optional)
360    Gosec,
361    /// OSV for multi-language dependency vulnerability scanning (optional)
362    Osv,
363}
364
365impl std::fmt::Display for ProviderType {
366    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
367        match self {
368            ProviderType::Rma => write!(f, "rma"),
369            ProviderType::Pmd => write!(f, "pmd"),
370            ProviderType::Oxlint => write!(f, "oxlint"),
371            ProviderType::Oxc => write!(f, "oxc"),
372            ProviderType::RustSec => write!(f, "rustsec"),
373            ProviderType::Gosec => write!(f, "gosec"),
374            ProviderType::Osv => write!(f, "osv"),
375        }
376    }
377}
378
379impl std::str::FromStr for ProviderType {
380    type Err = String;
381
382    fn from_str(s: &str) -> Result<Self, Self::Err> {
383        match s.to_lowercase().as_str() {
384            "rma" => Ok(ProviderType::Rma),
385            "pmd" => Ok(ProviderType::Pmd),
386            "oxlint" => Ok(ProviderType::Oxlint),
387            "oxc" => Ok(ProviderType::Oxc),
388            "rustsec" => Ok(ProviderType::RustSec),
389            "gosec" => Ok(ProviderType::Gosec),
390            "osv" => Ok(ProviderType::Osv),
391            _ => Err(format!(
392                "Unknown provider: {}. Available: rma, pmd, oxlint, oxc, rustsec, gosec, osv",
393                s
394            )),
395        }
396    }
397}
398
399/// Providers configuration section
400#[derive(Debug, Clone, Serialize, Deserialize)]
401pub struct ProvidersConfig {
402    /// List of enabled providers (default: ["rma"])
403    #[serde(default = "default_enabled_providers")]
404    pub enabled: Vec<ProviderType>,
405
406    /// PMD provider configuration
407    #[serde(default)]
408    pub pmd: PmdProviderConfig,
409
410    /// Oxlint provider configuration (external binary)
411    #[serde(default)]
412    pub oxlint: OxlintProviderConfig,
413
414    /// Native Oxc provider configuration (Rust crates)
415    #[serde(default)]
416    pub oxc: OxcProviderConfig,
417
418    /// Gosec provider configuration
419    #[serde(default)]
420    pub gosec: GosecProviderConfig,
421
422    /// OSV provider configuration (multi-language dependency vulnerabilities)
423    #[serde(default)]
424    pub osv: OsvProviderConfig,
425}
426
427impl Default for ProvidersConfig {
428    fn default() -> Self {
429        Self {
430            enabled: default_enabled_providers(),
431            pmd: PmdProviderConfig::default(),
432            oxlint: OxlintProviderConfig::default(),
433            oxc: OxcProviderConfig::default(),
434            gosec: GosecProviderConfig::default(),
435            osv: OsvProviderConfig::default(),
436        }
437    }
438}
439
440fn default_enabled_providers() -> Vec<ProviderType> {
441    vec![ProviderType::Rma]
442}
443
444/// PMD Java provider configuration
445#[derive(Debug, Clone, Serialize, Deserialize)]
446pub struct PmdProviderConfig {
447    /// Whether PMD provider is configured (separate from enabled list)
448    #[serde(default)]
449    pub configured: bool,
450
451    /// Path to Java executable
452    #[serde(default = "default_java_path")]
453    pub java_path: String,
454
455    /// Path to PMD installation (either pmd binary or pmd-dist directory)
456    /// If empty, will try to find 'pmd' in PATH
457    #[serde(default)]
458    pub pmd_path: String,
459
460    /// PMD rulesets to use
461    #[serde(default = "default_pmd_rulesets")]
462    pub rulesets: Vec<String>,
463
464    /// Timeout for PMD execution in milliseconds
465    #[serde(default = "default_pmd_timeout")]
466    pub timeout_ms: u64,
467
468    /// File patterns to include for PMD analysis
469    #[serde(default = "default_pmd_include_patterns")]
470    pub include_patterns: Vec<String>,
471
472    /// File patterns to exclude from PMD analysis
473    #[serde(default = "default_pmd_exclude_patterns")]
474    pub exclude_patterns: Vec<String>,
475
476    /// Severity mapping from PMD priority to RMA severity
477    /// Keys: "1", "2", "3", "4", "5" (PMD priorities)
478    /// Values: "critical", "error", "warning", "info"
479    #[serde(default = "default_pmd_severity_map")]
480    pub severity_map: HashMap<String, Severity>,
481
482    /// Whether to fail the scan if PMD itself fails (not findings, but tool errors)
483    #[serde(default)]
484    pub fail_on_error: bool,
485
486    /// Minimum PMD priority to report (1=highest, 5=lowest)
487    #[serde(default = "default_pmd_min_priority")]
488    pub min_priority: u8,
489
490    /// Additional PMD command-line arguments
491    #[serde(default)]
492    pub extra_args: Vec<String>,
493}
494
495impl Default for PmdProviderConfig {
496    fn default() -> Self {
497        Self {
498            configured: false,
499            java_path: default_java_path(),
500            pmd_path: String::new(),
501            rulesets: default_pmd_rulesets(),
502            timeout_ms: default_pmd_timeout(),
503            include_patterns: default_pmd_include_patterns(),
504            exclude_patterns: default_pmd_exclude_patterns(),
505            severity_map: default_pmd_severity_map(),
506            fail_on_error: false,
507            min_priority: default_pmd_min_priority(),
508            extra_args: Vec::new(),
509        }
510    }
511}
512
513fn default_java_path() -> String {
514    "java".to_string()
515}
516
517fn default_pmd_rulesets() -> Vec<String> {
518    vec![
519        "category/java/security.xml".to_string(),
520        "category/java/bestpractices.xml".to_string(),
521        "category/java/errorprone.xml".to_string(),
522    ]
523}
524
525fn default_pmd_timeout() -> u64 {
526    600_000 // 10 minutes
527}
528
529fn default_pmd_include_patterns() -> Vec<String> {
530    vec!["**/*.java".to_string()]
531}
532
533fn default_pmd_exclude_patterns() -> Vec<String> {
534    vec![
535        "**/target/**".to_string(),
536        "**/build/**".to_string(),
537        "**/generated/**".to_string(),
538        "**/out/**".to_string(),
539        "**/.git/**".to_string(),
540        "**/node_modules/**".to_string(),
541    ]
542}
543
544fn default_pmd_severity_map() -> HashMap<String, Severity> {
545    let mut map = HashMap::new();
546    map.insert("1".to_string(), Severity::Critical);
547    map.insert("2".to_string(), Severity::Error);
548    map.insert("3".to_string(), Severity::Warning);
549    map.insert("4".to_string(), Severity::Info);
550    map.insert("5".to_string(), Severity::Info);
551    map
552}
553
554fn default_pmd_min_priority() -> u8 {
555    5 // Report all priorities by default
556}
557
558/// Oxlint provider configuration
559#[derive(Debug, Clone, Serialize, Deserialize)]
560pub struct OxlintProviderConfig {
561    /// Whether Oxlint provider is configured
562    #[serde(default)]
563    pub configured: bool,
564
565    /// Path to oxlint binary (default: search PATH)
566    #[serde(default)]
567    pub binary_path: String,
568
569    /// Timeout for oxlint execution in milliseconds
570    #[serde(default = "default_oxlint_timeout")]
571    pub timeout_ms: u64,
572
573    /// Additional oxlint command-line arguments
574    #[serde(default)]
575    pub extra_args: Vec<String>,
576}
577
578impl Default for OxlintProviderConfig {
579    fn default() -> Self {
580        Self {
581            configured: false,
582            binary_path: String::new(),
583            timeout_ms: default_oxlint_timeout(),
584            extra_args: Vec::new(),
585        }
586    }
587}
588
589fn default_oxlint_timeout() -> u64 {
590    300_000 // 5 minutes
591}
592
593/// Gosec provider configuration
594#[derive(Debug, Clone, Serialize, Deserialize)]
595pub struct GosecProviderConfig {
596    /// Whether Gosec provider is configured
597    #[serde(default)]
598    pub configured: bool,
599
600    /// Path to gosec binary (default: search PATH)
601    #[serde(default)]
602    pub binary_path: String,
603
604    /// Timeout for gosec execution in milliseconds
605    #[serde(default = "default_gosec_timeout")]
606    pub timeout_ms: u64,
607
608    /// Gosec rules to exclude (e.g., ["G104", "G304"])
609    #[serde(default)]
610    pub exclude_rules: Vec<String>,
611
612    /// Gosec rules to include only (if set, only these rules run)
613    #[serde(default)]
614    pub include_rules: Vec<String>,
615
616    /// Additional gosec command-line arguments
617    #[serde(default)]
618    pub extra_args: Vec<String>,
619}
620
621impl Default for GosecProviderConfig {
622    fn default() -> Self {
623        Self {
624            configured: false,
625            binary_path: String::new(),
626            timeout_ms: default_gosec_timeout(),
627            exclude_rules: Vec::new(),
628            include_rules: Vec::new(),
629            extra_args: Vec::new(),
630        }
631    }
632}
633
634fn default_gosec_timeout() -> u64 {
635    300_000 // 5 minutes
636}
637
638/// Native Oxc provider configuration (Rust-native JS/TS linting)
639#[derive(Debug, Clone, Default, Serialize, Deserialize)]
640pub struct OxcProviderConfig {
641    /// Whether Oxc provider is configured
642    #[serde(default)]
643    pub configured: bool,
644
645    /// Rules to enable (empty = all rules)
646    #[serde(default)]
647    pub enable_rules: Vec<String>,
648
649    /// Rules to disable
650    #[serde(default)]
651    pub disable_rules: Vec<String>,
652
653    /// Severity overrides per rule ID (e.g., "js/oxc/no-debugger" -> "info")
654    #[serde(default)]
655    pub severity_overrides: HashMap<String, Severity>,
656}
657
658/// OSV ecosystem identifiers
659#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
660#[serde(rename_all = "lowercase")]
661pub enum OsvEcosystem {
662    /// crates.io (Rust)
663    #[serde(rename = "crates.io")]
664    CratesIo,
665    /// npm (JavaScript/TypeScript)
666    Npm,
667    /// PyPI (Python)
668    PyPI,
669    /// Go modules
670    Go,
671    /// Maven (Java)
672    Maven,
673}
674
675impl std::fmt::Display for OsvEcosystem {
676    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
677        match self {
678            OsvEcosystem::CratesIo => write!(f, "crates.io"),
679            OsvEcosystem::Npm => write!(f, "npm"),
680            OsvEcosystem::PyPI => write!(f, "PyPI"),
681            OsvEcosystem::Go => write!(f, "Go"),
682            OsvEcosystem::Maven => write!(f, "Maven"),
683        }
684    }
685}
686
687/// OSV provider configuration (multi-language dependency vulnerability scanning)
688#[derive(Debug, Clone, Serialize, Deserialize)]
689pub struct OsvProviderConfig {
690    /// Whether OSV provider is configured
691    #[serde(default)]
692    pub configured: bool,
693
694    /// Include dev dependencies in scan (default: false)
695    #[serde(default)]
696    pub include_dev_deps: bool,
697
698    /// Cache TTL as duration string (default: "24h")
699    /// Supported formats: "1h", "30m", "24h", "7d"
700    #[serde(default = "default_osv_cache_ttl")]
701    pub cache_ttl: String,
702
703    /// Enabled ecosystems (default: all)
704    #[serde(default = "default_osv_ecosystems")]
705    pub enabled_ecosystems: Vec<OsvEcosystem>,
706
707    /// Severity overrides by OSV ID or CVE ID
708    /// e.g., "GHSA-xxx" -> "warning", "CVE-2024-xxx" -> "info"
709    #[serde(default)]
710    pub severity_overrides: HashMap<String, Severity>,
711
712    /// Allowlist/ignore list by OSV ID or CVE ID
713    /// Vulnerabilities in this list will not be reported
714    #[serde(default)]
715    pub ignore_list: Vec<String>,
716
717    /// Offline mode - use cache only, no network requests
718    #[serde(default)]
719    pub offline: bool,
720
721    /// Custom cache directory (default: .rma/cache)
722    #[serde(default)]
723    pub cache_dir: Option<PathBuf>,
724}
725
726impl Default for OsvProviderConfig {
727    fn default() -> Self {
728        Self {
729            configured: false,
730            include_dev_deps: false,
731            cache_ttl: default_osv_cache_ttl(),
732            enabled_ecosystems: default_osv_ecosystems(),
733            severity_overrides: HashMap::new(),
734            ignore_list: Vec::new(),
735            offline: false,
736            cache_dir: None,
737        }
738    }
739}
740
741fn default_osv_cache_ttl() -> String {
742    "24h".to_string()
743}
744
745fn default_osv_ecosystems() -> Vec<OsvEcosystem> {
746    vec![
747        OsvEcosystem::CratesIo,
748        OsvEcosystem::Npm,
749        OsvEcosystem::PyPI,
750        OsvEcosystem::Go,
751        OsvEcosystem::Maven,
752    ]
753}
754
755/// Baseline configuration
756#[derive(Debug, Clone, Serialize, Deserialize, Default)]
757pub struct BaselineConfig {
758    /// Path to baseline file
759    #[serde(default = "default_baseline_file")]
760    pub file: PathBuf,
761
762    /// Baseline mode
763    #[serde(default)]
764    pub mode: BaselineMode,
765}
766
767fn default_baseline_file() -> PathBuf {
768    PathBuf::from(".rma/baseline.json")
769}
770
771/// Current supported config version
772pub const CURRENT_CONFIG_VERSION: u32 = 1;
773
774/// Complete RMA TOML configuration
775#[derive(Debug, Clone, Serialize, Deserialize, Default)]
776pub struct RmaTomlConfig {
777    /// Config format version (for future compatibility)
778    #[serde(default)]
779    pub config_version: Option<u32>,
780
781    /// Scan path configuration
782    #[serde(default)]
783    pub scan: ScanConfig,
784
785    /// Rules configuration
786    #[serde(default)]
787    pub rules: RulesConfig,
788
789    /// Rulesets configuration (named groups of rules)
790    #[serde(default)]
791    pub rulesets: RulesetsConfig,
792
793    /// Profiles configuration
794    #[serde(default)]
795    pub profiles: ProfilesConfig,
796
797    /// Severity overrides by rule ID
798    #[serde(default)]
799    pub severity: HashMap<String, Severity>,
800
801    /// Path-specific threshold overrides
802    #[serde(default)]
803    pub threshold_overrides: Vec<ThresholdOverride>,
804
805    /// Allowlist configuration
806    #[serde(default)]
807    pub allow: AllowConfig,
808
809    /// Baseline configuration
810    #[serde(default)]
811    pub baseline: BaselineConfig,
812
813    /// Analysis providers configuration
814    #[serde(default)]
815    pub providers: ProvidersConfig,
816}
817
818/// Result of loading a config file
819#[derive(Debug)]
820pub struct ConfigLoadResult {
821    /// The loaded configuration
822    pub config: RmaTomlConfig,
823    /// Warning if version was missing
824    pub version_warning: Option<String>,
825}
826
827impl RmaTomlConfig {
828    /// Load configuration from file with version validation
829    pub fn load(path: &Path) -> Result<Self, String> {
830        let result = Self::load_with_validation(path)?;
831        Ok(result.config)
832    }
833
834    /// Load configuration from file with full validation info
835    pub fn load_with_validation(path: &Path) -> Result<ConfigLoadResult, String> {
836        let content = std::fs::read_to_string(path)
837            .map_err(|e| format!("Failed to read config file: {}", e))?;
838
839        let config: RmaTomlConfig =
840            toml::from_str(&content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
841
842        // Validate version
843        let version_warning = config.validate_version()?;
844
845        Ok(ConfigLoadResult {
846            config,
847            version_warning,
848        })
849    }
850
851    /// Validate config version, returns warning message if version is missing
852    fn validate_version(&self) -> Result<Option<String>, String> {
853        match self.config_version {
854            Some(CURRENT_CONFIG_VERSION) => Ok(None),
855            Some(version) if version > CURRENT_CONFIG_VERSION => Err(format!(
856                "Unsupported config version: {}. Maximum supported version is {}. \
857                     Please upgrade RMA or use a compatible config format.",
858                version, CURRENT_CONFIG_VERSION
859            )),
860            Some(version) => {
861                // Version 0 or any future "older than current" version
862                Err(format!(
863                    "Invalid config version: {}. Expected version {}.",
864                    version, CURRENT_CONFIG_VERSION
865                ))
866            }
867            None => Ok(Some(
868                "Config file is missing 'config_version'. Assuming version 1. \
869                 Add 'config_version = 1' to suppress this warning."
870                    .to_string(),
871            )),
872        }
873    }
874
875    /// Check if config version is present
876    pub fn has_version(&self) -> bool {
877        self.config_version.is_some()
878    }
879
880    /// Get the effective config version (defaults to 1 if missing)
881    pub fn effective_version(&self) -> u32 {
882        self.config_version.unwrap_or(CURRENT_CONFIG_VERSION)
883    }
884
885    /// Find and load configuration from standard locations
886    pub fn discover(start_path: &Path) -> Option<(PathBuf, Self)> {
887        let candidates = [
888            start_path.join("rma.toml"),
889            start_path.join(".rma/rma.toml"),
890            start_path.join(".rma.toml"),
891        ];
892
893        for candidate in &candidates {
894            if candidate.exists()
895                && let Ok(config) = Self::load(candidate)
896            {
897                return Some((candidate.clone(), config));
898            }
899        }
900
901        // Check parent directories up to 5 levels
902        let mut current = start_path.to_path_buf();
903        for _ in 0..5 {
904            if let Some(parent) = current.parent() {
905                let config_path = parent.join("rma.toml");
906                if config_path.exists()
907                    && let Ok(config) = Self::load(&config_path)
908                {
909                    return Some((config_path, config));
910                }
911                current = parent.to_path_buf();
912            } else {
913                break;
914            }
915        }
916
917        None
918    }
919
920    /// Validate configuration for errors and conflicts
921    pub fn validate(&self) -> Vec<ConfigWarning> {
922        let mut warnings = Vec::new();
923
924        // Check config version
925        if self.config_version.is_none() {
926            warnings.push(ConfigWarning {
927                level: WarningLevel::Warning,
928                message: "Missing 'config_version'. Add 'config_version = 1' to your config file."
929                    .to_string(),
930            });
931        } else if let Some(version) = self.config_version
932            && version > CURRENT_CONFIG_VERSION
933        {
934            warnings.push(ConfigWarning {
935                level: WarningLevel::Error,
936                message: format!(
937                    "Unsupported config version: {}. Maximum supported is {}.",
938                    version, CURRENT_CONFIG_VERSION
939                ),
940            });
941        }
942
943        // Check for conflicting enable/disable rules
944        for disabled in &self.rules.disable {
945            for enabled in &self.rules.enable {
946                if enabled == disabled {
947                    warnings.push(ConfigWarning {
948                        level: WarningLevel::Warning,
949                        message: format!(
950                            "Rule '{}' is both enabled and disabled (disable takes precedence)",
951                            disabled
952                        ),
953                    });
954                }
955            }
956        }
957
958        // Check threshold overrides have valid patterns
959        for (i, override_) in self.threshold_overrides.iter().enumerate() {
960            if override_.path.is_empty() {
961                warnings.push(ConfigWarning {
962                    level: WarningLevel::Error,
963                    message: format!("threshold_overrides[{}]: path cannot be empty", i),
964                });
965            }
966        }
967
968        // Check baseline file path
969        if self.baseline.mode == BaselineMode::NewOnly && !self.baseline.file.exists() {
970            warnings.push(ConfigWarning {
971                level: WarningLevel::Warning,
972                message: format!(
973                    "Baseline mode is 'new-only' but baseline file '{}' does not exist. Run 'rma baseline' first.",
974                    self.baseline.file.display()
975                ),
976            });
977        }
978
979        // Check severity overrides reference valid severities
980        for rule_id in self.severity.keys() {
981            if rule_id.is_empty() {
982                warnings.push(ConfigWarning {
983                    level: WarningLevel::Error,
984                    message: "Empty rule ID in severity overrides".to_string(),
985                });
986            }
987        }
988
989        // Validate provider configuration
990        if self.providers.enabled.contains(&ProviderType::Pmd) {
991            // PMD is enabled - check if it's configured
992            if !self.providers.pmd.configured && self.providers.pmd.pmd_path.is_empty() {
993                warnings.push(ConfigWarning {
994                    level: WarningLevel::Warning,
995                    message: "PMD provider is enabled but not configured. Set [providers.pmd] configured = true or provide pmd_path.".to_string(),
996                });
997            }
998
999            // Check PMD rulesets
1000            if self.providers.pmd.rulesets.is_empty() {
1001                warnings.push(ConfigWarning {
1002                    level: WarningLevel::Warning,
1003                    message:
1004                        "PMD provider has no rulesets configured. Add rulesets to [providers.pmd]."
1005                            .to_string(),
1006                });
1007            }
1008
1009            // Check severity map validity
1010            for priority in self.providers.pmd.severity_map.keys() {
1011                if !["1", "2", "3", "4", "5"].contains(&priority.as_str()) {
1012                    warnings.push(ConfigWarning {
1013                        level: WarningLevel::Warning,
1014                        message: format!(
1015                            "Invalid PMD priority '{}' in severity_map. Valid priorities: 1-5.",
1016                            priority
1017                        ),
1018                    });
1019                }
1020            }
1021        }
1022
1023        if self.providers.enabled.contains(&ProviderType::Oxlint)
1024            && !self.providers.oxlint.configured
1025        {
1026            warnings.push(ConfigWarning {
1027                level: WarningLevel::Warning,
1028                message: "Oxlint provider is enabled but not configured. Set [providers.oxlint] configured = true.".to_string(),
1029            });
1030        }
1031
1032        warnings
1033    }
1034
1035    /// Check if a specific provider is enabled
1036    pub fn is_provider_enabled(&self, provider: ProviderType) -> bool {
1037        self.providers.enabled.contains(&provider)
1038    }
1039
1040    /// Get the list of enabled providers
1041    pub fn get_enabled_providers(&self) -> &[ProviderType] {
1042        &self.providers.enabled
1043    }
1044
1045    /// Get PMD provider config (if PMD is enabled)
1046    pub fn get_pmd_config(&self) -> Option<&PmdProviderConfig> {
1047        if self.is_provider_enabled(ProviderType::Pmd) {
1048            Some(&self.providers.pmd)
1049        } else {
1050            None
1051        }
1052    }
1053
1054    /// Check if a rule is enabled (without ruleset filtering)
1055    pub fn is_rule_enabled(&self, rule_id: &str) -> bool {
1056        self.is_rule_enabled_with_ruleset(rule_id, None)
1057    }
1058
1059    /// Check if a rule is enabled with optional ruleset filter
1060    ///
1061    /// If a ruleset is specified, only rules in that ruleset are considered enabled.
1062    /// Explicit disable always takes precedence.
1063    pub fn is_rule_enabled_with_ruleset(&self, rule_id: &str, ruleset: Option<&str>) -> bool {
1064        // Check if explicitly disabled - always takes precedence
1065        for pattern in &self.rules.disable {
1066            if Self::matches_pattern(rule_id, pattern) {
1067                return false;
1068            }
1069        }
1070
1071        // If a ruleset is specified, check if rule is in that ruleset
1072        if let Some(ruleset_name) = ruleset {
1073            let ruleset_rules = self.get_ruleset_rules(ruleset_name);
1074            if !ruleset_rules.is_empty() {
1075                // Rule must be in the ruleset to be enabled
1076                return ruleset_rules
1077                    .iter()
1078                    .any(|r| Self::matches_pattern(rule_id, r));
1079            }
1080        }
1081
1082        // Check if explicitly enabled
1083        for pattern in &self.rules.enable {
1084            if Self::matches_pattern(rule_id, pattern) {
1085                return true;
1086            }
1087        }
1088
1089        false
1090    }
1091
1092    /// Get rules for a named ruleset
1093    pub fn get_ruleset_rules(&self, name: &str) -> Vec<String> {
1094        match name {
1095            "security" => self.rulesets.security.clone(),
1096            "maintainability" => self.rulesets.maintainability.clone(),
1097            _ => self.rulesets.custom.get(name).cloned().unwrap_or_default(),
1098        }
1099    }
1100
1101    /// Get all available ruleset names
1102    pub fn get_ruleset_names(&self) -> Vec<String> {
1103        let mut names = Vec::new();
1104        if !self.rulesets.security.is_empty() {
1105            names.push("security".to_string());
1106        }
1107        if !self.rulesets.maintainability.is_empty() {
1108            names.push("maintainability".to_string());
1109        }
1110        names.extend(self.rulesets.custom.keys().cloned());
1111        names
1112    }
1113
1114    /// Get severity override for a rule
1115    pub fn get_severity_override(&self, rule_id: &str) -> Option<Severity> {
1116        self.severity.get(rule_id).copied()
1117    }
1118
1119    /// Get thresholds for a path, applying overrides
1120    pub fn get_thresholds_for_path(&self, path: &Path, profile: Profile) -> ProfileThresholds {
1121        let mut thresholds = self.profiles.get_thresholds(profile).clone();
1122
1123        // Apply path-specific overrides
1124        let path_str = path.to_string_lossy();
1125        for override_ in &self.threshold_overrides {
1126            if Self::matches_glob(&path_str, &override_.path) {
1127                if let Some(v) = override_.max_function_lines {
1128                    thresholds.max_function_lines = v;
1129                }
1130                if let Some(v) = override_.max_complexity {
1131                    thresholds.max_complexity = v;
1132                }
1133                if let Some(v) = override_.max_cognitive_complexity {
1134                    thresholds.max_cognitive_complexity = v;
1135                }
1136            }
1137        }
1138
1139        thresholds
1140    }
1141
1142    /// Check if a path is allowed for a specific rule
1143    pub fn is_path_allowed(&self, path: &Path, rule_type: AllowType) -> bool {
1144        let path_str = path.to_string_lossy();
1145        let allowed_paths = match rule_type {
1146            AllowType::InnerHtml => &self.allow.innerhtml_paths,
1147            AllowType::Eval => &self.allow.eval_paths,
1148            AllowType::UnsafeRust => &self.allow.unsafe_rust_paths,
1149        };
1150
1151        for pattern in allowed_paths {
1152            if Self::matches_glob(&path_str, pattern) {
1153                return true;
1154            }
1155        }
1156
1157        false
1158    }
1159
1160    fn matches_pattern(rule_id: &str, pattern: &str) -> bool {
1161        if pattern == "*" {
1162            return true;
1163        }
1164
1165        if let Some(prefix) = pattern.strip_suffix("/*") {
1166            return rule_id.starts_with(prefix);
1167        }
1168
1169        rule_id == pattern
1170    }
1171
1172    fn matches_glob(path: &str, pattern: &str) -> bool {
1173        // Simple glob matching (supports * and **)
1174        let pattern = pattern
1175            .replace("**", "§")
1176            .replace('*', "[^/]*")
1177            .replace('§', ".*");
1178        regex::Regex::new(&format!("^{}$", pattern))
1179            .map(|re| re.is_match(path))
1180            .unwrap_or(false)
1181    }
1182
1183    /// Generate default configuration as TOML string
1184    pub fn default_toml(profile: Profile) -> String {
1185        let thresholds = ProfileThresholds::for_profile(profile);
1186
1187        format!(
1188            r#"# RMA Configuration
1189# Documentation: https://github.com/bumahkib7/rust-monorepo-analyzer
1190
1191# Config format version (required for future compatibility)
1192config_version = 1
1193
1194[scan]
1195# Paths to include in scanning (default: all supported files)
1196include = ["src/**", "lib/**", "scripts/**"]
1197
1198# Paths to exclude from scanning
1199exclude = [
1200    "node_modules/**",
1201    "target/**",
1202    "dist/**",
1203    "build/**",
1204    "vendor/**",
1205    "**/*.min.js",
1206    "**/*.bundle.js",
1207]
1208
1209# Maximum file size to scan (10MB default)
1210max_file_size = 10485760
1211
1212[rules]
1213# Rules to enable (wildcards supported)
1214enable = ["*"]
1215
1216# Rules to disable (takes precedence over enable)
1217disable = []
1218
1219# Global ignore paths - findings in these paths are suppressed for all rules
1220# Supports glob patterns. Uncomment to customize.
1221# ignore_paths = ["**/vendor/**", "**/generated/**"]
1222
1223# Per-rule ignore paths - suppress specific rules in specific paths
1224# Note: Security rules (command-injection, hardcoded-secret, etc.) cannot be
1225# suppressed via path ignores, only via inline comments with reason.
1226# [rules.ignore_paths_by_rule]
1227# "generic/long-function" = ["**/tests/**", "**/examples/**"]
1228# "js/console-log" = ["**/debug/**"]
1229
1230# Default test/example suppressions are automatically applied in --mode pr/ci
1231# This reduces noise from test files. Security rules are NOT suppressed.
1232
1233[profiles]
1234# Default profile: fast, balanced, or strict
1235default = "{profile}"
1236
1237[profiles.fast]
1238max_function_lines = 200
1239max_complexity = 25
1240max_cognitive_complexity = 35
1241max_file_lines = 2000
1242
1243[profiles.balanced]
1244max_function_lines = {max_function_lines}
1245max_complexity = {max_complexity}
1246max_cognitive_complexity = {max_cognitive_complexity}
1247max_file_lines = 1000
1248
1249[profiles.strict]
1250max_function_lines = 50
1251max_complexity = 10
1252max_cognitive_complexity = 15
1253max_file_lines = 500
1254
1255[rulesets]
1256# Named rule groups for targeted scanning
1257security = ["js/innerhtml-xss", "js/timer-string-eval", "js/dynamic-code-execution", "rust/unsafe-block", "python/shell-injection"]
1258maintainability = ["generic/long-function", "generic/high-complexity", "js/console-log"]
1259
1260[severity]
1261# Override severity for specific rules
1262# "generic/long-function" = "warning"
1263# "js/innerhtml-xss" = "error"
1264# "rust/unsafe-block" = "warning"
1265
1266# [[threshold_overrides]]
1267# path = "src/legacy/**"
1268# max_function_lines = 300
1269# max_complexity = 30
1270
1271# [[threshold_overrides]]
1272# path = "tests/**"
1273# disable_rules = ["generic/long-function"]
1274
1275[allow]
1276# Approved patterns that won't trigger alerts
1277settimeout_string = false
1278settimeout_function = true
1279innerhtml_paths = []
1280eval_paths = []
1281unsafe_rust_paths = []
1282approved_secrets = []
1283
1284[baseline]
1285# Baseline file for tracking legacy issues
1286file = ".rma/baseline.json"
1287# Mode: "all" or "new-only"
1288mode = "all"
1289
1290# =============================================================================
1291# ANALYSIS PROVIDERS
1292# =============================================================================
1293# RMA supports external analysis providers for extended language coverage.
1294# Providers can be enabled/disabled individually.
1295
1296[providers]
1297# List of enabled providers (default: only "rma" built-in rules)
1298enabled = ["rma"]
1299# To enable PMD for Java: enabled = ["rma", "pmd"]
1300# To enable Oxlint for JS/TS: enabled = ["rma", "oxlint"]
1301
1302# -----------------------------------------------------------------------------
1303# PMD Provider - Java Static Analysis (optional)
1304# -----------------------------------------------------------------------------
1305# PMD provides comprehensive Java security and quality analysis.
1306# Requires: Java runtime and PMD installation
1307#
1308# [providers.pmd]
1309# configured = true
1310# java_path = "java"                    # Path to java binary
1311# pmd_path = ""                         # Path to pmd binary (or leave empty to use PATH)
1312# rulesets = [
1313#     "category/java/security.xml",
1314#     "category/java/bestpractices.xml",
1315#     "category/java/errorprone.xml",
1316# ]
1317# timeout_ms = 600000                   # 10 minutes timeout
1318# include_patterns = ["**/*.java"]
1319# exclude_patterns = ["**/target/**", "**/build/**", "**/generated/**"]
1320# fail_on_error = false                 # Continue scan if PMD fails
1321# min_priority = 5                      # Report all priorities (1-5)
1322# extra_args = []                       # Additional PMD CLI arguments
1323
1324# [providers.pmd.severity_map]
1325# # Map PMD priority (1-5) to RMA severity
1326# "1" = "critical"
1327# "2" = "error"
1328# "3" = "warning"
1329# "4" = "info"
1330# "5" = "info"
1331
1332# -----------------------------------------------------------------------------
1333# Oxlint Provider - Fast JavaScript/TypeScript Linting (optional)
1334# -----------------------------------------------------------------------------
1335# [providers.oxlint]
1336# configured = true
1337# binary_path = ""                      # Path to oxlint binary (or leave empty to use PATH)
1338# timeout_ms = 300000                   # 5 minutes timeout
1339# extra_args = []
1340"#,
1341            profile = profile,
1342            max_function_lines = thresholds.max_function_lines,
1343            max_complexity = thresholds.max_complexity,
1344            max_cognitive_complexity = thresholds.max_cognitive_complexity,
1345        )
1346    }
1347}
1348
1349/// Type of allowlist check
1350#[derive(Debug, Clone, Copy)]
1351pub enum AllowType {
1352    InnerHtml,
1353    Eval,
1354    UnsafeRust,
1355}
1356
1357/// Source of a configuration value (for precedence tracking)
1358#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1359#[serde(rename_all = "kebab-case")]
1360pub enum ConfigSource {
1361    /// Built-in default value
1362    Default,
1363    /// From rma.toml configuration file
1364    ConfigFile,
1365    /// From CLI flag or environment variable
1366    CliFlag,
1367}
1368
1369impl std::fmt::Display for ConfigSource {
1370    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1371        match self {
1372            ConfigSource::Default => write!(f, "default"),
1373            ConfigSource::ConfigFile => write!(f, "config-file"),
1374            ConfigSource::CliFlag => write!(f, "cli-flag"),
1375        }
1376    }
1377}
1378
1379/// Effective (resolved) configuration after applying precedence
1380///
1381/// Precedence order (highest to lowest):
1382/// 1. CLI flags (--config, --profile, --baseline-mode)
1383/// 2. rma.toml in repo root (or explicit --config path)
1384/// 3. Built-in defaults
1385#[derive(Debug, Clone, Serialize, Deserialize)]
1386pub struct EffectiveConfig {
1387    /// Source of the configuration file (if any)
1388    pub config_file: Option<PathBuf>,
1389
1390    /// Active profile
1391    pub profile: Profile,
1392
1393    /// Where the profile came from
1394    pub profile_source: ConfigSource,
1395
1396    /// Resolved thresholds
1397    pub thresholds: ProfileThresholds,
1398
1399    /// Number of enabled rules
1400    pub enabled_rules_count: usize,
1401
1402    /// Number of disabled rules
1403    pub disabled_rules_count: usize,
1404
1405    /// Number of severity overrides
1406    pub severity_overrides_count: usize,
1407
1408    /// Threshold overrides (paths in order)
1409    pub threshold_override_paths: Vec<String>,
1410
1411    /// Baseline mode
1412    pub baseline_mode: BaselineMode,
1413
1414    /// Where baseline mode came from
1415    pub baseline_mode_source: ConfigSource,
1416
1417    /// Exclude patterns
1418    pub exclude_patterns: Vec<String>,
1419
1420    /// Include patterns
1421    pub include_patterns: Vec<String>,
1422}
1423
1424impl EffectiveConfig {
1425    /// Build effective config from sources with proper precedence
1426    pub fn resolve(
1427        toml_config: Option<&RmaTomlConfig>,
1428        config_path: Option<&Path>,
1429        cli_profile: Option<Profile>,
1430        cli_baseline_mode: bool,
1431    ) -> Self {
1432        // Resolve profile: CLI > config > default
1433        let (profile, profile_source) = if let Some(p) = cli_profile {
1434            (p, ConfigSource::CliFlag)
1435        } else if let Some(cfg) = toml_config {
1436            (cfg.profiles.default, ConfigSource::ConfigFile)
1437        } else {
1438            (Profile::default(), ConfigSource::Default)
1439        };
1440
1441        // Resolve baseline mode: CLI > config > default
1442        let (baseline_mode, baseline_mode_source) = if cli_baseline_mode {
1443            (BaselineMode::NewOnly, ConfigSource::CliFlag)
1444        } else if let Some(cfg) = toml_config {
1445            (cfg.baseline.mode, ConfigSource::ConfigFile)
1446        } else {
1447            (BaselineMode::default(), ConfigSource::Default)
1448        };
1449
1450        // Get thresholds for profile
1451        let thresholds = toml_config
1452            .map(|cfg| cfg.profiles.get_thresholds(profile).clone())
1453            .unwrap_or_else(|| ProfileThresholds::for_profile(profile));
1454
1455        // Count rules
1456        let (enabled_rules_count, disabled_rules_count) = toml_config
1457            .map(|cfg| (cfg.rules.enable.len(), cfg.rules.disable.len()))
1458            .unwrap_or((1, 0)); // default: enable = ["*"]
1459
1460        // Severity overrides
1461        let severity_overrides_count = toml_config.map(|cfg| cfg.severity.len()).unwrap_or(0);
1462
1463        // Threshold override paths
1464        let threshold_override_paths = toml_config
1465            .map(|cfg| {
1466                cfg.threshold_overrides
1467                    .iter()
1468                    .map(|o| o.path.clone())
1469                    .collect()
1470            })
1471            .unwrap_or_default();
1472
1473        // Patterns
1474        let exclude_patterns = toml_config
1475            .map(|cfg| cfg.scan.exclude.clone())
1476            .unwrap_or_default();
1477
1478        let include_patterns = toml_config
1479            .map(|cfg| cfg.scan.include.clone())
1480            .unwrap_or_default();
1481
1482        Self {
1483            config_file: config_path.map(|p| p.to_path_buf()),
1484            profile,
1485            profile_source,
1486            thresholds,
1487            enabled_rules_count,
1488            disabled_rules_count,
1489            severity_overrides_count,
1490            threshold_override_paths,
1491            baseline_mode,
1492            baseline_mode_source,
1493            exclude_patterns,
1494            include_patterns,
1495        }
1496    }
1497
1498    /// Format as human-readable text
1499    pub fn to_text(&self) -> String {
1500        let mut out = String::new();
1501
1502        out.push_str("Effective Configuration\n");
1503        out.push_str("═══════════════════════════════════════════════════════════\n\n");
1504
1505        // Config file
1506        out.push_str("  Config file:        ");
1507        match &self.config_file {
1508            Some(p) => out.push_str(&format!("{}\n", p.display())),
1509            None => out.push_str("(none - using defaults)\n"),
1510        }
1511
1512        // Profile
1513        out.push_str(&format!(
1514            "  Profile:            {} (from {})\n",
1515            self.profile, self.profile_source
1516        ));
1517
1518        // Thresholds
1519        out.push_str("\n  Thresholds:\n");
1520        out.push_str(&format!(
1521            "    max_function_lines:     {}\n",
1522            self.thresholds.max_function_lines
1523        ));
1524        out.push_str(&format!(
1525            "    max_complexity:         {}\n",
1526            self.thresholds.max_complexity
1527        ));
1528        out.push_str(&format!(
1529            "    max_cognitive_complexity: {}\n",
1530            self.thresholds.max_cognitive_complexity
1531        ));
1532        out.push_str(&format!(
1533            "    max_file_lines:         {}\n",
1534            self.thresholds.max_file_lines
1535        ));
1536
1537        // Rules
1538        out.push_str("\n  Rules:\n");
1539        out.push_str(&format!(
1540            "    enabled patterns:       {}\n",
1541            self.enabled_rules_count
1542        ));
1543        out.push_str(&format!(
1544            "    disabled patterns:      {}\n",
1545            self.disabled_rules_count
1546        ));
1547        out.push_str(&format!(
1548            "    severity overrides:     {}\n",
1549            self.severity_overrides_count
1550        ));
1551
1552        // Threshold overrides
1553        if !self.threshold_override_paths.is_empty() {
1554            out.push_str("\n  Threshold overrides:\n");
1555            for path in &self.threshold_override_paths {
1556                out.push_str(&format!("    - {}\n", path));
1557            }
1558        }
1559
1560        // Baseline
1561        out.push_str(&format!(
1562            "\n  Baseline mode:      {:?} (from {})\n",
1563            self.baseline_mode, self.baseline_mode_source
1564        ));
1565
1566        out
1567    }
1568
1569    /// Format as JSON
1570    pub fn to_json(&self) -> Result<String, serde_json::Error> {
1571        serde_json::to_string_pretty(self)
1572    }
1573}
1574
1575/// Configuration warning or error
1576#[derive(Debug, Clone)]
1577pub struct ConfigWarning {
1578    pub level: WarningLevel,
1579    pub message: String,
1580}
1581
1582#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1583pub enum WarningLevel {
1584    Warning,
1585    Error,
1586}
1587
1588/// Inline suppression comment parsed from source code
1589#[derive(Debug, Clone, PartialEq, Eq)]
1590pub struct InlineSuppression {
1591    /// The rule ID to suppress
1592    pub rule_id: String,
1593    /// The reason for suppression (required in strict profile)
1594    pub reason: Option<String>,
1595    /// Line number where the suppression comment appears
1596    pub line: usize,
1597    /// Type of suppression
1598    pub suppression_type: SuppressionType,
1599}
1600
1601/// Type of inline suppression
1602#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1603pub enum SuppressionType {
1604    /// Suppresses the next line only
1605    NextLine,
1606    /// Suppresses until end of block/function (or file-level until blank line)
1607    Block,
1608}
1609
1610impl InlineSuppression {
1611    /// Parse a suppression comment from a line of code
1612    ///
1613    /// Supported formats:
1614    /// - `// rma-ignore-next-line <rule_id> reason="<text>"`
1615    /// - `// rma-ignore <rule_id> reason="<text>"`
1616    /// - `# rma-ignore-next-line <rule_id> reason="<text>"` (Python)
1617    pub fn parse(line: &str, line_number: usize) -> Option<Self> {
1618        let trimmed = line.trim();
1619
1620        // Check for comment prefixes
1621        let comment_body = if let Some(rest) = trimmed.strip_prefix("//") {
1622            rest.trim()
1623        } else if let Some(rest) = trimmed.strip_prefix('#') {
1624            rest.trim()
1625        } else {
1626            return None;
1627        };
1628
1629        // Check for rma-ignore-next-line
1630        if let Some(rest) = comment_body.strip_prefix("rma-ignore-next-line") {
1631            return Self::parse_suppression_body(
1632                rest.trim(),
1633                line_number,
1634                SuppressionType::NextLine,
1635            );
1636        }
1637
1638        // Check for rma-ignore (block level)
1639        if let Some(rest) = comment_body.strip_prefix("rma-ignore") {
1640            return Self::parse_suppression_body(rest.trim(), line_number, SuppressionType::Block);
1641        }
1642
1643        None
1644    }
1645
1646    fn parse_suppression_body(
1647        body: &str,
1648        line_number: usize,
1649        suppression_type: SuppressionType,
1650    ) -> Option<Self> {
1651        if body.is_empty() {
1652            return None;
1653        }
1654
1655        // Parse: <rule_id> [reason="<text>"]
1656        let mut parts = body.splitn(2, ' ');
1657        let rule_id = parts.next()?.trim().to_string();
1658
1659        if rule_id.is_empty() {
1660            return None;
1661        }
1662
1663        let reason = parts.next().and_then(|rest| {
1664            // Look for reason="..."
1665            if let Some(start) = rest.find("reason=\"") {
1666                let after_quote = &rest[start + 8..];
1667                if let Some(end) = after_quote.find('"') {
1668                    return Some(after_quote[..end].to_string());
1669                }
1670            }
1671            None
1672        });
1673
1674        Some(Self {
1675            rule_id,
1676            reason,
1677            line: line_number,
1678            suppression_type,
1679        })
1680    }
1681
1682    /// Check if this suppression applies to a finding at the given line
1683    pub fn applies_to(&self, finding_line: usize, rule_id: &str) -> bool {
1684        if self.rule_id != rule_id && self.rule_id != "*" {
1685            return false;
1686        }
1687
1688        match self.suppression_type {
1689            SuppressionType::NextLine => finding_line == self.line + 1,
1690            SuppressionType::Block => finding_line >= self.line,
1691        }
1692    }
1693
1694    /// Validate suppression (check if reason is required and present)
1695    pub fn validate(&self, require_reason: bool) -> Result<(), String> {
1696        if require_reason && self.reason.is_none() {
1697            return Err(format!(
1698                "Suppression for '{}' at line {} requires a reason in strict profile",
1699                self.rule_id, self.line
1700            ));
1701        }
1702        Ok(())
1703    }
1704}
1705
1706/// Parse all inline suppressions from source code
1707pub fn parse_inline_suppressions(content: &str) -> Vec<InlineSuppression> {
1708    content
1709        .lines()
1710        .enumerate()
1711        .filter_map(|(i, line)| InlineSuppression::parse(line, i + 1))
1712        .collect()
1713}
1714
1715/// Stable fingerprint for a finding
1716///
1717/// Fingerprints are designed to survive:
1718/// - Line number changes (refactoring moves code)
1719/// - Minor whitespace changes
1720/// - Non-semantic message text changes
1721///
1722/// Fingerprint inputs (in order):
1723/// 1. rule_id (e.g., "js/innerhtml-xss")
1724/// 2. file path (normalized, unix separators)
1725/// 3. normalized snippet (trimmed, collapsed whitespace)
1726#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
1727pub struct Fingerprint(String);
1728
1729impl Fingerprint {
1730    /// Generate a stable fingerprint for a finding
1731    pub fn generate(rule_id: &str, file_path: &Path, snippet: &str) -> Self {
1732        use sha2::{Digest, Sha256};
1733
1734        let mut hasher = Sha256::new();
1735
1736        // 1. Rule ID
1737        hasher.update(rule_id.as_bytes());
1738        hasher.update(b"|");
1739
1740        // 2. Normalized file path (unix separators, lowercase for case-insensitive FS)
1741        let normalized_path = file_path
1742            .to_string_lossy()
1743            .replace('\\', "/")
1744            .to_lowercase();
1745        hasher.update(normalized_path.as_bytes());
1746        hasher.update(b"|");
1747
1748        // 3. Normalized snippet (collapse whitespace, trim)
1749        let normalized_snippet = Self::normalize_snippet(snippet);
1750        hasher.update(normalized_snippet.as_bytes());
1751
1752        let hash = hasher.finalize();
1753        Self(format!("sha256:{:x}", hash))
1754    }
1755
1756    /// Normalize snippet for stable fingerprinting
1757    fn normalize_snippet(snippet: &str) -> String {
1758        snippet
1759            .split_whitespace()
1760            .collect::<Vec<_>>()
1761            .join(" ")
1762            .trim()
1763            .to_string()
1764    }
1765
1766    /// Get the fingerprint string
1767    pub fn as_str(&self) -> &str {
1768        &self.0
1769    }
1770
1771    /// Create from existing fingerprint string
1772    pub fn from_string(s: String) -> Self {
1773        Self(s)
1774    }
1775}
1776
1777impl std::fmt::Display for Fingerprint {
1778    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1779        write!(f, "{}", self.0)
1780    }
1781}
1782
1783impl From<Fingerprint> for String {
1784    fn from(fp: Fingerprint) -> String {
1785        fp.0
1786    }
1787}
1788
1789/// Baseline entry for a finding
1790#[derive(Debug, Clone, Serialize, Deserialize)]
1791pub struct BaselineEntry {
1792    pub rule_id: String,
1793    pub file: PathBuf,
1794    #[serde(default)]
1795    pub line: usize,
1796    pub fingerprint: String,
1797    pub first_seen: String,
1798    #[serde(default)]
1799    pub suppressed: bool,
1800    #[serde(default)]
1801    pub comment: Option<String>,
1802}
1803
1804/// Baseline file containing known findings
1805#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1806pub struct Baseline {
1807    pub version: String,
1808    pub created: String,
1809    pub entries: Vec<BaselineEntry>,
1810}
1811
1812impl Baseline {
1813    pub fn new() -> Self {
1814        Self {
1815            version: "1.0".to_string(),
1816            created: chrono::Utc::now().to_rfc3339(),
1817            entries: Vec::new(),
1818        }
1819    }
1820
1821    pub fn load(path: &Path) -> Result<Self, String> {
1822        let content = std::fs::read_to_string(path)
1823            .map_err(|e| format!("Failed to read baseline file: {}", e))?;
1824
1825        serde_json::from_str(&content).map_err(|e| format!("Failed to parse baseline: {}", e))
1826    }
1827
1828    pub fn save(&self, path: &Path) -> Result<(), String> {
1829        if let Some(parent) = path.parent() {
1830            std::fs::create_dir_all(parent)
1831                .map_err(|e| format!("Failed to create directory: {}", e))?;
1832        }
1833
1834        let content = serde_json::to_string_pretty(self)
1835            .map_err(|e| format!("Failed to serialize baseline: {}", e))?;
1836
1837        std::fs::write(path, content).map_err(|e| format!("Failed to write baseline: {}", e))
1838    }
1839
1840    /// Check if a finding is in the baseline by fingerprint
1841    pub fn contains_fingerprint(&self, fingerprint: &Fingerprint) -> bool {
1842        self.entries
1843            .iter()
1844            .any(|e| e.fingerprint == fingerprint.as_str())
1845    }
1846
1847    /// Check if a finding is in the baseline (legacy method)
1848    pub fn contains(&self, rule_id: &str, file: &Path, fingerprint: &str) -> bool {
1849        self.entries
1850            .iter()
1851            .any(|e| e.rule_id == rule_id && e.file == file && e.fingerprint == fingerprint)
1852    }
1853
1854    /// Add a finding to the baseline using stable fingerprint
1855    pub fn add_with_fingerprint(
1856        &mut self,
1857        rule_id: String,
1858        file: PathBuf,
1859        line: usize,
1860        fingerprint: Fingerprint,
1861    ) {
1862        if !self.contains_fingerprint(&fingerprint) {
1863            self.entries.push(BaselineEntry {
1864                rule_id,
1865                file,
1866                line,
1867                fingerprint: fingerprint.into(),
1868                first_seen: chrono::Utc::now().to_rfc3339(),
1869                suppressed: false,
1870                comment: None,
1871            });
1872        }
1873    }
1874
1875    /// Add a finding to the baseline (legacy method)
1876    pub fn add(&mut self, rule_id: String, file: PathBuf, line: usize, fingerprint: String) {
1877        if !self.contains(&rule_id, &file, &fingerprint) {
1878            self.entries.push(BaselineEntry {
1879                rule_id,
1880                file,
1881                line,
1882                fingerprint,
1883                first_seen: chrono::Utc::now().to_rfc3339(),
1884                suppressed: false,
1885                comment: None,
1886            });
1887        }
1888    }
1889}
1890
1891// =============================================================================
1892// SUPPRESSION ENGINE
1893// =============================================================================
1894
1895/// Result of checking if a finding should be suppressed
1896#[derive(Debug, Clone, Serialize, Deserialize)]
1897pub struct SuppressionResult {
1898    /// Whether the finding is suppressed
1899    pub suppressed: bool,
1900    /// Reason for suppression (if suppressed)
1901    pub reason: Option<String>,
1902    /// Source of suppression (path, inline, baseline, preset)
1903    pub source: Option<SuppressionSource>,
1904    /// Location of the suppression (e.g., line number for inline, glob pattern for path)
1905    pub location: Option<String>,
1906}
1907
1908impl SuppressionResult {
1909    /// Create a not-suppressed result
1910    pub fn not_suppressed() -> Self {
1911        Self {
1912            suppressed: false,
1913            reason: None,
1914            source: None,
1915            location: None,
1916        }
1917    }
1918
1919    /// Create a suppressed result
1920    pub fn suppressed(source: SuppressionSource, reason: String, location: String) -> Self {
1921        Self {
1922            suppressed: true,
1923            reason: Some(reason),
1924            source: Some(source),
1925            location: Some(location),
1926        }
1927    }
1928}
1929
1930/// Source of a suppression
1931#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1932#[serde(rename_all = "kebab-case")]
1933pub enum SuppressionSource {
1934    /// Suppressed by inline comment
1935    Inline,
1936    /// Suppressed by global ignore_paths config
1937    PathGlobal,
1938    /// Suppressed by per-rule ignore_paths_by_rule config
1939    PathRule,
1940    /// Suppressed by default test/example preset (--mode pr/ci)
1941    Preset,
1942    /// Suppressed by baseline
1943    Baseline,
1944}
1945
1946impl std::fmt::Display for SuppressionSource {
1947    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1948        match self {
1949            SuppressionSource::Inline => write!(f, "inline"),
1950            SuppressionSource::PathGlobal => write!(f, "path-global"),
1951            SuppressionSource::PathRule => write!(f, "path-rule"),
1952            SuppressionSource::Preset => write!(f, "preset"),
1953            SuppressionSource::Baseline => write!(f, "baseline"),
1954        }
1955    }
1956}
1957
1958/// Engine for checking if findings should be suppressed
1959///
1960/// Consolidates all suppression logic:
1961/// - Global path ignores (rules.ignore_paths)
1962/// - Per-rule path ignores (rules.ignore_paths_by_rule)
1963/// - Inline suppressions (rma-ignore comments)
1964/// - Default test/example presets for PR/CI mode
1965/// - Baseline filtering
1966pub struct SuppressionEngine {
1967    /// Global ignore paths
1968    global_ignore_paths: Vec<String>,
1969    /// Per-rule ignore paths
1970    rule_ignore_paths: HashMap<String, Vec<String>>,
1971    /// Whether to apply default presets (for --mode pr/ci)
1972    use_default_presets: bool,
1973    /// Baseline for filtering existing findings
1974    baseline: Option<Baseline>,
1975    /// Compiled regex patterns for global ignores
1976    global_patterns: Vec<regex::Regex>,
1977    /// Compiled regex patterns for per-rule ignores
1978    rule_patterns: HashMap<String, Vec<regex::Regex>>,
1979    /// Compiled regex patterns for default test paths
1980    test_patterns: Vec<regex::Regex>,
1981    /// Compiled regex patterns for default example paths
1982    example_patterns: Vec<regex::Regex>,
1983}
1984
1985impl SuppressionEngine {
1986    /// Create a new suppression engine from config
1987    pub fn new(rules_config: &RulesConfig, use_default_presets: bool) -> Self {
1988        let global_patterns = rules_config
1989            .ignore_paths
1990            .iter()
1991            .filter_map(|p| Self::compile_glob(p))
1992            .collect();
1993
1994        let mut rule_patterns = HashMap::new();
1995        for (rule_id, paths) in &rules_config.ignore_paths_by_rule {
1996            let patterns: Vec<regex::Regex> =
1997                paths.iter().filter_map(|p| Self::compile_glob(p)).collect();
1998            if !patterns.is_empty() {
1999                rule_patterns.insert(rule_id.clone(), patterns);
2000            }
2001        }
2002
2003        let test_patterns = if use_default_presets {
2004            DEFAULT_TEST_IGNORE_PATHS
2005                .iter()
2006                .filter_map(|p| Self::compile_glob(p))
2007                .collect()
2008        } else {
2009            Vec::new()
2010        };
2011
2012        let example_patterns = if use_default_presets {
2013            DEFAULT_EXAMPLE_IGNORE_PATHS
2014                .iter()
2015                .filter_map(|p| Self::compile_glob(p))
2016                .collect()
2017        } else {
2018            Vec::new()
2019        };
2020
2021        Self {
2022            global_ignore_paths: rules_config.ignore_paths.clone(),
2023            rule_ignore_paths: rules_config.ignore_paths_by_rule.clone(),
2024            use_default_presets,
2025            baseline: None,
2026            global_patterns,
2027            rule_patterns,
2028            test_patterns,
2029            example_patterns,
2030        }
2031    }
2032
2033    /// Create a suppression engine with just default presets (no config)
2034    pub fn with_defaults_only() -> Self {
2035        Self::new(&RulesConfig::default(), true)
2036    }
2037
2038    /// Set the baseline for filtering
2039    pub fn with_baseline(mut self, baseline: Baseline) -> Self {
2040        self.baseline = Some(baseline);
2041        self
2042    }
2043
2044    /// Compile a glob pattern to a regex
2045    ///
2046    /// Handles cases like:
2047    /// - `**/tests/**` matches `src/tests/foo.rs` AND `tests/foo.rs`
2048    /// - `**/*.test.ts` matches `app.test.ts` AND `src/app.test.ts`
2049    fn compile_glob(pattern: &str) -> Option<regex::Regex> {
2050        let regex_pattern = pattern
2051            .replace('.', r"\.")
2052            .replace("**", "§")
2053            .replace('*', "[^/]*")
2054            .replace('§', ".*");
2055
2056        // Handle patterns that start with .*/ to also match paths that start
2057        // directly with the pattern (e.g., "tests/foo.rs" matching "**/tests/**")
2058        let regex_pattern = if let Some(rest) = regex_pattern.strip_prefix(".*/") {
2059            // Make the leading .*/ optional: (^|.*/) matches start or .*/
2060            format!("(^|.*/){}", rest)
2061        } else if regex_pattern.starts_with(".*") {
2062            // Pattern starts with ** but no trailing slash, just use as-is
2063            regex_pattern
2064        } else {
2065            // Pattern doesn't start with **, anchor to start
2066            format!("^{}", regex_pattern)
2067        };
2068
2069        regex::Regex::new(&format!("(?i){}$", regex_pattern)).ok()
2070    }
2071
2072    /// Check if a path matches any of the given patterns
2073    fn matches_patterns(path: &str, patterns: &[regex::Regex]) -> bool {
2074        let normalized = path.replace('\\', "/");
2075        patterns.iter().any(|re| re.is_match(&normalized))
2076    }
2077
2078    /// Check if a rule is in the always-enabled list (security rules)
2079    pub fn is_always_enabled(rule_id: &str) -> bool {
2080        RULES_ALWAYS_ENABLED.iter().any(|r| {
2081            rule_id == *r
2082                || rule_id.starts_with(&format!("{}:", r))
2083                || r.ends_with("*") && rule_id.starts_with(r.trim_end_matches('*'))
2084        })
2085    }
2086
2087    /// Check if a finding should be suppressed
2088    ///
2089    /// Returns a SuppressionResult with details about why it was suppressed (or not).
2090    /// Order of checks:
2091    /// 1. Always-enabled rules (never suppressed by path/preset)
2092    /// 2. Inline suppressions
2093    /// 3. Global path ignores
2094    /// 4. Per-rule path ignores
2095    /// 5. Default test/example presets
2096    /// 6. Baseline
2097    pub fn check(
2098        &self,
2099        rule_id: &str,
2100        file_path: &Path,
2101        finding_line: usize,
2102        inline_suppressions: &[InlineSuppression],
2103        fingerprint: Option<&str>,
2104    ) -> SuppressionResult {
2105        let path_str = file_path.to_string_lossy();
2106
2107        // 1. Check inline suppressions first (they apply regardless of always-enabled)
2108        for suppression in inline_suppressions {
2109            if suppression.applies_to(finding_line, rule_id) {
2110                let reason = suppression
2111                    .reason
2112                    .clone()
2113                    .unwrap_or_else(|| "No reason provided".to_string());
2114                return SuppressionResult::suppressed(
2115                    SuppressionSource::Inline,
2116                    reason,
2117                    format!("line {}", suppression.line),
2118                );
2119            }
2120        }
2121
2122        // Security rules should not be suppressed by path/preset (only inline or baseline)
2123        let is_always_enabled = Self::is_always_enabled(rule_id);
2124
2125        if !is_always_enabled {
2126            // 2. Check global path ignores
2127            if Self::matches_patterns(&path_str, &self.global_patterns) {
2128                for (i, pattern) in self.global_ignore_paths.iter().enumerate() {
2129                    if let Some(re) = self.global_patterns.get(i)
2130                        && re.is_match(&path_str.replace('\\', "/"))
2131                    {
2132                        return SuppressionResult::suppressed(
2133                            SuppressionSource::PathGlobal,
2134                            format!("Path matches global ignore pattern: {}", pattern),
2135                            pattern.clone(),
2136                        );
2137                    }
2138                }
2139            }
2140
2141            // 3. Check per-rule path ignores
2142            if let Some(patterns) = self.rule_patterns.get(rule_id)
2143                && Self::matches_patterns(&path_str, patterns)
2144                && let Some(rule_paths) = self.rule_ignore_paths.get(rule_id)
2145            {
2146                for (i, pattern) in rule_paths.iter().enumerate() {
2147                    if let Some(re) = patterns.get(i)
2148                        && re.is_match(&path_str.replace('\\', "/"))
2149                    {
2150                        return SuppressionResult::suppressed(
2151                            SuppressionSource::PathRule,
2152                            format!("Path matches rule-specific ignore pattern: {}", pattern),
2153                            format!("{}:{}", rule_id, pattern),
2154                        );
2155                    }
2156                }
2157            }
2158
2159            // Also check wildcard rule patterns
2160            for (pattern_rule_id, patterns) in &self.rule_patterns {
2161                if pattern_rule_id.ends_with("/*") {
2162                    let prefix = pattern_rule_id.trim_end_matches("/*");
2163                    if rule_id.starts_with(prefix)
2164                        && Self::matches_patterns(&path_str, patterns)
2165                        && let Some(rule_paths) = self.rule_ignore_paths.get(pattern_rule_id)
2166                        && let Some(pattern) = rule_paths.first()
2167                    {
2168                        return SuppressionResult::suppressed(
2169                            SuppressionSource::PathRule,
2170                            format!("Path matches rule-specific ignore pattern: {}", pattern),
2171                            format!("{}:{}", pattern_rule_id, pattern),
2172                        );
2173                    }
2174                }
2175            }
2176
2177            // 4. Check default test/example presets
2178            if self.use_default_presets {
2179                if Self::matches_patterns(&path_str, &self.test_patterns) {
2180                    return SuppressionResult::suppressed(
2181                        SuppressionSource::Preset,
2182                        "File is in test directory (suppressed by default preset)".to_string(),
2183                        "test-preset".to_string(),
2184                    );
2185                }
2186                if Self::matches_patterns(&path_str, &self.example_patterns) {
2187                    return SuppressionResult::suppressed(
2188                        SuppressionSource::Preset,
2189                        "File is in example/fixture directory (suppressed by default preset)"
2190                            .to_string(),
2191                        "example-preset".to_string(),
2192                    );
2193                }
2194            }
2195        }
2196
2197        // 5. Check baseline (applies to all rules including always-enabled)
2198        if let Some(ref baseline) = self.baseline
2199            && let Some(fp) = fingerprint
2200        {
2201            let fingerprint_obj = Fingerprint::from_string(fp.to_string());
2202            if baseline.contains_fingerprint(&fingerprint_obj) {
2203                return SuppressionResult::suppressed(
2204                    SuppressionSource::Baseline,
2205                    "Finding is in baseline".to_string(),
2206                    "baseline".to_string(),
2207                );
2208            }
2209        }
2210
2211        SuppressionResult::not_suppressed()
2212    }
2213
2214    /// Check if a path should be completely ignored (before parsing/analysis)
2215    ///
2216    /// This is a fast path check that doesn't require inline suppressions.
2217    /// Only checks path-based ignores, not inline or baseline.
2218    pub fn should_skip_path(&self, file_path: &Path) -> bool {
2219        let path_str = file_path.to_string_lossy();
2220
2221        // Check global path ignores
2222        if Self::matches_patterns(&path_str, &self.global_patterns) {
2223            return true;
2224        }
2225
2226        // Check default presets (for tests/examples)
2227        if self.use_default_presets {
2228            // For path skipping, we only skip if ALL rules would be suppressed
2229            // Security rules can still fire, so we don't skip the path entirely
2230            // This is a conservative approach - we still parse the file
2231            // but suppress non-security findings later
2232            false
2233        } else {
2234            false
2235        }
2236    }
2237
2238    /// Add suppression metadata to a finding's properties
2239    pub fn add_suppression_metadata(
2240        properties: &mut HashMap<String, serde_json::Value>,
2241        result: &SuppressionResult,
2242    ) {
2243        if result.suppressed {
2244            properties.insert("suppressed".to_string(), serde_json::json!(true));
2245            if let Some(ref reason) = result.reason {
2246                properties.insert("suppression_reason".to_string(), serde_json::json!(reason));
2247            }
2248            if let Some(ref source) = result.source {
2249                properties.insert(
2250                    "suppression_source".to_string(),
2251                    serde_json::json!(source.to_string()),
2252                );
2253            }
2254            if let Some(ref location) = result.location {
2255                properties.insert(
2256                    "suppression_location".to_string(),
2257                    serde_json::json!(location),
2258                );
2259            }
2260        }
2261    }
2262}
2263
2264impl Default for SuppressionEngine {
2265    fn default() -> Self {
2266        Self::new(&RulesConfig::default(), false)
2267    }
2268}
2269
2270#[cfg(test)]
2271mod tests {
2272    use super::*;
2273    use std::str::FromStr;
2274
2275    #[test]
2276    fn test_profile_parsing() {
2277        assert_eq!(Profile::from_str("fast").unwrap(), Profile::Fast);
2278        assert_eq!(Profile::from_str("balanced").unwrap(), Profile::Balanced);
2279        assert_eq!(Profile::from_str("strict").unwrap(), Profile::Strict);
2280        assert!(Profile::from_str("unknown").is_err());
2281    }
2282
2283    #[test]
2284    fn test_rule_matching() {
2285        assert!(RmaTomlConfig::matches_pattern("security/xss", "*"));
2286        assert!(RmaTomlConfig::matches_pattern("security/xss", "security/*"));
2287        assert!(!RmaTomlConfig::matches_pattern(
2288            "generic/long",
2289            "security/*"
2290        ));
2291        assert!(RmaTomlConfig::matches_pattern(
2292            "security/xss",
2293            "security/xss"
2294        ));
2295    }
2296
2297    #[test]
2298    fn test_default_config_parses() {
2299        let toml = RmaTomlConfig::default_toml(Profile::Balanced);
2300        let config: RmaTomlConfig = toml::from_str(&toml).expect("Default config should parse");
2301        assert_eq!(config.profiles.default, Profile::Balanced);
2302    }
2303
2304    #[test]
2305    fn test_thresholds_for_profile() {
2306        let fast = ProfileThresholds::for_profile(Profile::Fast);
2307        let strict = ProfileThresholds::for_profile(Profile::Strict);
2308
2309        assert!(fast.max_function_lines > strict.max_function_lines);
2310        assert!(fast.max_complexity > strict.max_complexity);
2311    }
2312
2313    #[test]
2314    fn test_fingerprint_stable_across_line_changes() {
2315        // Same finding at different line numbers should yield same fingerprint
2316        let fp1 = Fingerprint::generate(
2317            "js/xss-sink",
2318            Path::new("src/app.js"),
2319            "element.textContent = userInput;",
2320        );
2321        let fp2 = Fingerprint::generate(
2322            "js/xss-sink",
2323            Path::new("src/app.js"),
2324            "element.textContent = userInput;",
2325        );
2326
2327        assert_eq!(fp1, fp2);
2328    }
2329
2330    #[test]
2331    fn test_fingerprint_stable_with_whitespace_changes() {
2332        // Minor whitespace changes shouldn't affect fingerprint
2333        let fp1 = Fingerprint::generate(
2334            "generic/long-function",
2335            Path::new("src/utils.rs"),
2336            "fn very_long_function() {",
2337        );
2338        let fp2 = Fingerprint::generate(
2339            "generic/long-function",
2340            Path::new("src/utils.rs"),
2341            "fn   very_long_function()   {",
2342        );
2343        let fp3 = Fingerprint::generate(
2344            "generic/long-function",
2345            Path::new("src/utils.rs"),
2346            "  fn very_long_function() {  ",
2347        );
2348
2349        assert_eq!(fp1, fp2);
2350        assert_eq!(fp2, fp3);
2351    }
2352
2353    #[test]
2354    fn test_fingerprint_different_for_different_rules() {
2355        let fp1 = Fingerprint::generate("js/xss-sink", Path::new("src/app.js"), "element.x = val;");
2356        let fp2 = Fingerprint::generate("js/eval", Path::new("src/app.js"), "element.x = val;");
2357
2358        assert_ne!(fp1, fp2);
2359    }
2360
2361    #[test]
2362    fn test_fingerprint_different_for_different_files() {
2363        let fp1 = Fingerprint::generate("js/xss-sink", Path::new("src/app.js"), "element.x = val;");
2364        let fp2 =
2365            Fingerprint::generate("js/xss-sink", Path::new("src/other.js"), "element.x = val;");
2366
2367        assert_ne!(fp1, fp2);
2368    }
2369
2370    #[test]
2371    fn test_fingerprint_path_normalization() {
2372        // Windows and Unix paths should normalize to same fingerprint
2373        let fp1 = Fingerprint::generate("js/xss-sink", Path::new("src/components/App.js"), "x");
2374        let fp2 = Fingerprint::generate("js/xss-sink", Path::new("src\\components\\App.js"), "x");
2375
2376        assert_eq!(fp1, fp2);
2377    }
2378
2379    #[test]
2380    fn test_effective_config_precedence() {
2381        // Test CLI overrides config
2382        let toml_config = RmaTomlConfig::default();
2383        let effective = EffectiveConfig::resolve(
2384            Some(&toml_config),
2385            Some(Path::new("rma.toml")),
2386            Some(Profile::Strict), // CLI override
2387            false,
2388        );
2389
2390        assert_eq!(effective.profile, Profile::Strict);
2391        assert_eq!(effective.profile_source, ConfigSource::CliFlag);
2392    }
2393
2394    #[test]
2395    fn test_effective_config_defaults() {
2396        // No config, no CLI flags = defaults
2397        let effective = EffectiveConfig::resolve(None, None, None, false);
2398
2399        assert_eq!(effective.profile, Profile::Balanced);
2400        assert_eq!(effective.profile_source, ConfigSource::Default);
2401        assert!(effective.config_file.is_none());
2402    }
2403
2404    #[test]
2405    fn test_effective_config_from_file() {
2406        // Config file with no CLI override
2407        let mut toml_config = RmaTomlConfig::default();
2408        toml_config.profiles.default = Profile::Fast;
2409
2410        let effective =
2411            EffectiveConfig::resolve(Some(&toml_config), Some(Path::new("rma.toml")), None, false);
2412
2413        assert_eq!(effective.profile, Profile::Fast);
2414        assert_eq!(effective.profile_source, ConfigSource::ConfigFile);
2415    }
2416
2417    #[test]
2418    fn test_config_version_missing_warns() {
2419        let toml = r#"
2420[profiles]
2421default = "balanced"
2422"#;
2423        let config: RmaTomlConfig = toml::from_str(toml).unwrap();
2424        assert!(config.config_version.is_none());
2425        assert!(!config.has_version());
2426        assert_eq!(config.effective_version(), 1);
2427
2428        let warnings = config.validate();
2429        assert!(
2430            warnings
2431                .iter()
2432                .any(|w| w.message.contains("Missing 'config_version'"))
2433        );
2434    }
2435
2436    #[test]
2437    fn test_config_version_1_ok() {
2438        let toml = r#"
2439config_version = 1
2440
2441[profiles]
2442default = "balanced"
2443"#;
2444        let config: RmaTomlConfig = toml::from_str(toml).unwrap();
2445        assert_eq!(config.config_version, Some(1));
2446        assert!(config.has_version());
2447        assert_eq!(config.effective_version(), 1);
2448
2449        let warnings = config.validate();
2450        assert!(
2451            !warnings
2452                .iter()
2453                .any(|w| w.message.contains("config_version"))
2454        );
2455    }
2456
2457    #[test]
2458    fn test_config_version_999_fails() {
2459        let toml = r#"
2460config_version = 999
2461
2462[profiles]
2463default = "balanced"
2464"#;
2465        let config: RmaTomlConfig = toml::from_str(toml).unwrap();
2466        assert_eq!(config.config_version, Some(999));
2467
2468        let warnings = config.validate();
2469        let error = warnings.iter().find(|w| w.level == WarningLevel::Error);
2470        assert!(error.is_some());
2471        assert!(
2472            error
2473                .unwrap()
2474                .message
2475                .contains("Unsupported config version: 999")
2476        );
2477    }
2478
2479    #[test]
2480    fn test_default_toml_includes_version() {
2481        let toml = RmaTomlConfig::default_toml(Profile::Balanced);
2482        assert!(toml.contains("config_version = 1"));
2483
2484        // Verify it parses correctly
2485        let config: RmaTomlConfig = toml::from_str(&toml).unwrap();
2486        assert_eq!(config.config_version, Some(1));
2487    }
2488
2489    #[test]
2490    fn test_ruleset_security() {
2491        let toml = r#"
2492config_version = 1
2493
2494[rulesets]
2495security = ["js/innerhtml-xss", "js/timer-string-eval"]
2496maintainability = ["generic/long-function"]
2497
2498[rules]
2499enable = ["*"]
2500"#;
2501        let config: RmaTomlConfig = toml::from_str(toml).unwrap();
2502
2503        // With security ruleset, only security rules are enabled
2504        assert!(config.is_rule_enabled_with_ruleset("js/innerhtml-xss", Some("security")));
2505        assert!(config.is_rule_enabled_with_ruleset("js/timer-string-eval", Some("security")));
2506        assert!(!config.is_rule_enabled_with_ruleset("generic/long-function", Some("security")));
2507
2508        // Without ruleset, normal enable/disable applies
2509        assert!(config.is_rule_enabled("generic/long-function"));
2510    }
2511
2512    #[test]
2513    fn test_ruleset_with_disable() {
2514        let toml = r#"
2515config_version = 1
2516
2517[rulesets]
2518security = ["js/innerhtml-xss", "js/timer-string-eval"]
2519
2520[rules]
2521enable = ["*"]
2522disable = ["js/timer-string-eval"]
2523"#;
2524        let config: RmaTomlConfig = toml::from_str(toml).unwrap();
2525
2526        // Disable takes precedence even with ruleset
2527        assert!(config.is_rule_enabled_with_ruleset("js/innerhtml-xss", Some("security")));
2528        assert!(!config.is_rule_enabled_with_ruleset("js/timer-string-eval", Some("security")));
2529    }
2530
2531    #[test]
2532    fn test_get_ruleset_names() {
2533        let toml = r#"
2534config_version = 1
2535
2536[rulesets]
2537security = ["js/innerhtml-xss"]
2538maintainability = ["generic/long-function"]
2539"#;
2540        let config: RmaTomlConfig = toml::from_str(toml).unwrap();
2541        let names = config.get_ruleset_names();
2542
2543        assert!(names.contains(&"security".to_string()));
2544        assert!(names.contains(&"maintainability".to_string()));
2545    }
2546
2547    #[test]
2548    fn test_default_toml_includes_rulesets() {
2549        let toml = RmaTomlConfig::default_toml(Profile::Balanced);
2550        assert!(toml.contains("[rulesets]"));
2551        assert!(toml.contains("security = "));
2552        assert!(toml.contains("maintainability = "));
2553    }
2554
2555    #[test]
2556    fn test_inline_suppression_next_line() {
2557        let suppression = InlineSuppression::parse(
2558            "// rma-ignore-next-line js/innerhtml-xss reason=\"sanitized input\"",
2559            10,
2560        );
2561        assert!(suppression.is_some());
2562        let s = suppression.unwrap();
2563        assert_eq!(s.rule_id, "js/innerhtml-xss");
2564        assert_eq!(s.reason, Some("sanitized input".to_string()));
2565        assert_eq!(s.line, 10);
2566        assert_eq!(s.suppression_type, SuppressionType::NextLine);
2567
2568        // Should apply to line 11
2569        assert!(s.applies_to(11, "js/innerhtml-xss"));
2570        // Should NOT apply to line 12
2571        assert!(!s.applies_to(12, "js/innerhtml-xss"));
2572        // Should NOT apply to other rules
2573        assert!(!s.applies_to(11, "js/console-log"));
2574    }
2575
2576    #[test]
2577    fn test_inline_suppression_block() {
2578        let suppression = InlineSuppression::parse(
2579            "// rma-ignore generic/long-function reason=\"legacy code\"",
2580            5,
2581        );
2582        assert!(suppression.is_some());
2583        let s = suppression.unwrap();
2584        assert_eq!(s.rule_id, "generic/long-function");
2585        assert_eq!(s.suppression_type, SuppressionType::Block);
2586
2587        // Should apply to line 5 and beyond
2588        assert!(s.applies_to(5, "generic/long-function"));
2589        assert!(s.applies_to(10, "generic/long-function"));
2590        assert!(s.applies_to(100, "generic/long-function"));
2591    }
2592
2593    #[test]
2594    fn test_inline_suppression_without_reason() {
2595        let suppression = InlineSuppression::parse("// rma-ignore-next-line js/console-log", 1);
2596        assert!(suppression.is_some());
2597        let s = suppression.unwrap();
2598        assert_eq!(s.rule_id, "js/console-log");
2599        assert!(s.reason.is_none());
2600    }
2601
2602    #[test]
2603    fn test_inline_suppression_python_style() {
2604        let suppression = InlineSuppression::parse(
2605            "# rma-ignore-next-line python/hardcoded-secret reason=\"test data\"",
2606            3,
2607        );
2608        assert!(suppression.is_some());
2609        let s = suppression.unwrap();
2610        assert_eq!(s.rule_id, "python/hardcoded-secret");
2611        assert_eq!(s.reason, Some("test data".to_string()));
2612    }
2613
2614    #[test]
2615    fn test_inline_suppression_validation_strict() {
2616        let s = InlineSuppression {
2617            rule_id: "js/xss".to_string(),
2618            reason: None,
2619            line: 1,
2620            suppression_type: SuppressionType::NextLine,
2621        };
2622
2623        // Without reason, strict validation fails
2624        assert!(s.validate(true).is_err());
2625        // Without reason, non-strict validation passes
2626        assert!(s.validate(false).is_ok());
2627
2628        let s_with_reason = InlineSuppression {
2629            rule_id: "js/xss".to_string(),
2630            reason: Some("approved".to_string()),
2631            line: 1,
2632            suppression_type: SuppressionType::NextLine,
2633        };
2634
2635        // With reason, both pass
2636        assert!(s_with_reason.validate(true).is_ok());
2637        assert!(s_with_reason.validate(false).is_ok());
2638    }
2639
2640    #[test]
2641    fn test_parse_inline_suppressions() {
2642        let content = r#"
2643function foo() {
2644    // rma-ignore-next-line js/console-log reason="debugging"
2645    console.log("test");
2646
2647    // rma-ignore generic/long-function reason="complex algorithm"
2648    // ... lots of code ...
2649}
2650"#;
2651        let suppressions = parse_inline_suppressions(content);
2652        assert_eq!(suppressions.len(), 2);
2653        assert_eq!(suppressions[0].rule_id, "js/console-log");
2654        assert_eq!(suppressions[1].rule_id, "generic/long-function");
2655    }
2656
2657    #[test]
2658    fn test_suppression_does_not_affect_other_rules() {
2659        let suppression = InlineSuppression::parse(
2660            "// rma-ignore-next-line js/innerhtml-xss reason=\"safe\"",
2661            10,
2662        )
2663        .unwrap();
2664
2665        // Applies to the specific rule
2666        assert!(suppression.applies_to(11, "js/innerhtml-xss"));
2667        // Does NOT apply to other rules
2668        assert!(!suppression.applies_to(11, "js/console-log"));
2669        assert!(!suppression.applies_to(11, "generic/long-function"));
2670    }
2671
2672    // =========================================================================
2673    // SUPPRESSION ENGINE TESTS
2674    // =========================================================================
2675
2676    #[test]
2677    fn test_suppression_engine_global_path_ignore() {
2678        let rules_config = RulesConfig {
2679            ignore_paths: vec!["**/vendor/**".to_string(), "**/generated/**".to_string()],
2680            ..Default::default()
2681        };
2682
2683        let engine = SuppressionEngine::new(&rules_config, false);
2684
2685        // Should be suppressed
2686        let result = engine.check(
2687            "generic/long-function",
2688            Path::new("src/vendor/lib.js"),
2689            10,
2690            &[],
2691            None,
2692        );
2693        assert!(result.suppressed);
2694        assert_eq!(result.source, Some(SuppressionSource::PathGlobal));
2695
2696        // Should NOT be suppressed
2697        let result = engine.check(
2698            "generic/long-function",
2699            Path::new("src/app.js"),
2700            10,
2701            &[],
2702            None,
2703        );
2704        assert!(!result.suppressed);
2705    }
2706
2707    #[test]
2708    fn test_suppression_engine_per_rule_path_ignore() {
2709        let rules_config = RulesConfig {
2710            ignore_paths_by_rule: HashMap::from([(
2711                "generic/long-function".to_string(),
2712                vec!["**/tests/**".to_string()],
2713            )]),
2714            ..Default::default()
2715        };
2716
2717        let engine = SuppressionEngine::new(&rules_config, false);
2718
2719        // Should be suppressed for this specific rule in tests
2720        let result = engine.check(
2721            "generic/long-function",
2722            Path::new("src/tests/test_app.js"),
2723            10,
2724            &[],
2725            None,
2726        );
2727        assert!(result.suppressed);
2728        assert_eq!(result.source, Some(SuppressionSource::PathRule));
2729
2730        // Should NOT be suppressed for a different rule in tests
2731        let result = engine.check(
2732            "js/console-log",
2733            Path::new("src/tests/test_app.js"),
2734            10,
2735            &[],
2736            None,
2737        );
2738        assert!(!result.suppressed);
2739    }
2740
2741    #[test]
2742    fn test_suppression_engine_inline_suppression() {
2743        let rules_config = RulesConfig::default();
2744        let engine = SuppressionEngine::new(&rules_config, false);
2745
2746        let inline_suppressions = vec![InlineSuppression {
2747            rule_id: "js/console-log".to_string(),
2748            reason: Some("debug output".to_string()),
2749            line: 10,
2750            suppression_type: SuppressionType::NextLine,
2751        }];
2752
2753        // Should be suppressed by inline comment
2754        let result = engine.check(
2755            "js/console-log",
2756            Path::new("src/app.js"),
2757            11, // Line after the suppression comment
2758            &inline_suppressions,
2759            None,
2760        );
2761        assert!(result.suppressed);
2762        assert_eq!(result.source, Some(SuppressionSource::Inline));
2763        assert_eq!(result.reason, Some("debug output".to_string()));
2764
2765        // Should NOT be suppressed for different line
2766        let result = engine.check(
2767            "js/console-log",
2768            Path::new("src/app.js"),
2769            12,
2770            &inline_suppressions,
2771            None,
2772        );
2773        assert!(!result.suppressed);
2774    }
2775
2776    #[test]
2777    fn test_suppression_engine_default_presets() {
2778        let rules_config = RulesConfig::default();
2779        let engine = SuppressionEngine::new(&rules_config, true); // Enable presets
2780
2781        // Test files should be suppressed
2782        let result = engine.check(
2783            "generic/long-function",
2784            Path::new("src/tests/test_app.rs"),
2785            10,
2786            &[],
2787            None,
2788        );
2789        assert!(result.suppressed);
2790        assert_eq!(result.source, Some(SuppressionSource::Preset));
2791
2792        // .test.ts files should be suppressed
2793        let result = engine.check(
2794            "js/console-log",
2795            Path::new("src/app.test.ts"),
2796            10,
2797            &[],
2798            None,
2799        );
2800        assert!(result.suppressed);
2801
2802        // Example files should be suppressed
2803        let result = engine.check(
2804            "generic/long-function",
2805            Path::new("examples/demo.rs"),
2806            10,
2807            &[],
2808            None,
2809        );
2810        assert!(result.suppressed);
2811
2812        // Regular source files should NOT be suppressed
2813        let result = engine.check(
2814            "generic/long-function",
2815            Path::new("src/lib.rs"),
2816            10,
2817            &[],
2818            None,
2819        );
2820        assert!(!result.suppressed);
2821    }
2822
2823    #[test]
2824    fn test_suppression_engine_security_rules_not_suppressed_by_preset() {
2825        let rules_config = RulesConfig::default();
2826        let engine = SuppressionEngine::new(&rules_config, true); // Enable presets
2827
2828        // Security rules should NOT be suppressed by preset in test files
2829        let result = engine.check(
2830            "rust/command-injection",
2831            Path::new("src/tests/test_app.rs"),
2832            10,
2833            &[],
2834            None,
2835        );
2836        assert!(!result.suppressed);
2837
2838        let result = engine.check(
2839            "generic/hardcoded-secret",
2840            Path::new("examples/demo.py"),
2841            10,
2842            &[],
2843            None,
2844        );
2845        assert!(!result.suppressed);
2846
2847        let result = engine.check(
2848            "python/shell-injection",
2849            Path::new("tests/test_shell.py"),
2850            10,
2851            &[],
2852            None,
2853        );
2854        assert!(!result.suppressed);
2855    }
2856
2857    #[test]
2858    fn test_suppression_engine_security_rules_can_be_suppressed_inline() {
2859        let rules_config = RulesConfig::default();
2860        let engine = SuppressionEngine::new(&rules_config, true);
2861
2862        let inline_suppressions = vec![InlineSuppression {
2863            rule_id: "rust/command-injection".to_string(),
2864            reason: Some("sanitized input validated upstream".to_string()),
2865            line: 10,
2866            suppression_type: SuppressionType::NextLine,
2867        }];
2868
2869        // Security rules CAN be suppressed by inline comment
2870        let result = engine.check(
2871            "rust/command-injection",
2872            Path::new("src/app.rs"),
2873            11,
2874            &inline_suppressions,
2875            None,
2876        );
2877        assert!(result.suppressed);
2878        assert_eq!(result.source, Some(SuppressionSource::Inline));
2879    }
2880
2881    #[test]
2882    fn test_suppression_engine_is_always_enabled() {
2883        assert!(SuppressionEngine::is_always_enabled(
2884            "rust/command-injection"
2885        ));
2886        assert!(SuppressionEngine::is_always_enabled(
2887            "python/shell-injection"
2888        ));
2889        assert!(SuppressionEngine::is_always_enabled(
2890            "generic/hardcoded-secret"
2891        ));
2892        assert!(SuppressionEngine::is_always_enabled("go/command-injection"));
2893        assert!(SuppressionEngine::is_always_enabled(
2894            "java/command-execution"
2895        ));
2896        assert!(SuppressionEngine::is_always_enabled(
2897            "js/dynamic-code-execution"
2898        ));
2899
2900        // These should NOT be always-enabled
2901        assert!(!SuppressionEngine::is_always_enabled(
2902            "generic/long-function"
2903        ));
2904        assert!(!SuppressionEngine::is_always_enabled("js/console-log"));
2905        assert!(!SuppressionEngine::is_always_enabled("rust/unsafe-block"));
2906    }
2907
2908    #[test]
2909    fn test_suppression_engine_add_metadata() {
2910        let result = SuppressionResult::suppressed(
2911            SuppressionSource::Inline,
2912            "debug output".to_string(),
2913            "line 10".to_string(),
2914        );
2915
2916        let mut properties = HashMap::new();
2917        SuppressionEngine::add_suppression_metadata(&mut properties, &result);
2918
2919        assert_eq!(properties.get("suppressed"), Some(&serde_json::json!(true)));
2920        assert_eq!(
2921            properties.get("suppression_reason"),
2922            Some(&serde_json::json!("debug output"))
2923        );
2924        assert_eq!(
2925            properties.get("suppression_source"),
2926            Some(&serde_json::json!("inline"))
2927        );
2928        assert_eq!(
2929            properties.get("suppression_location"),
2930            Some(&serde_json::json!("line 10"))
2931        );
2932    }
2933
2934    #[test]
2935    fn test_suppression_result_not_suppressed() {
2936        let result = SuppressionResult::not_suppressed();
2937        assert!(!result.suppressed);
2938        assert!(result.reason.is_none());
2939        assert!(result.source.is_none());
2940        assert!(result.location.is_none());
2941    }
2942
2943    #[test]
2944    fn test_rules_config_with_ignore_paths() {
2945        let toml = r#"
2946config_version = 1
2947
2948[rules]
2949enable = ["*"]
2950disable = []
2951ignore_paths = ["**/vendor/**", "**/generated/**"]
2952
2953[rules.ignore_paths_by_rule]
2954"generic/long-function" = ["**/tests/**", "**/examples/**"]
2955"js/console-log" = ["**/debug/**"]
2956"#;
2957        let config: RmaTomlConfig = toml::from_str(toml).unwrap();
2958
2959        assert_eq!(config.rules.ignore_paths.len(), 2);
2960        assert!(
2961            config
2962                .rules
2963                .ignore_paths
2964                .contains(&"**/vendor/**".to_string())
2965        );
2966        assert!(
2967            config
2968                .rules
2969                .ignore_paths
2970                .contains(&"**/generated/**".to_string())
2971        );
2972
2973        assert_eq!(config.rules.ignore_paths_by_rule.len(), 2);
2974        assert!(
2975            config
2976                .rules
2977                .ignore_paths_by_rule
2978                .contains_key("generic/long-function")
2979        );
2980        assert!(
2981            config
2982                .rules
2983                .ignore_paths_by_rule
2984                .contains_key("js/console-log")
2985        );
2986    }
2987}