Skip to main content

rma_common/
config.rs

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