Skip to main content

rma_common/
config.rs

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