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