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
98/// Ruleset configuration - named groups of rules
99#[derive(Debug, Clone, Serialize, Deserialize, Default)]
100pub struct RulesetsConfig {
101    /// Security-focused rules
102    #[serde(default)]
103    pub security: Vec<String>,
104
105    /// Maintainability-focused rules
106    #[serde(default)]
107    pub maintainability: Vec<String>,
108
109    /// Custom rulesets defined by user
110    #[serde(flatten)]
111    pub custom: HashMap<String, Vec<String>>,
112}
113
114fn default_enable() -> Vec<String> {
115    vec!["*".to_string()]
116}
117
118/// Profile-specific thresholds
119#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct ProfileThresholds {
121    /// Maximum lines per function
122    #[serde(default = "default_max_function_lines")]
123    pub max_function_lines: usize,
124
125    /// Maximum cyclomatic complexity
126    #[serde(default = "default_max_complexity")]
127    pub max_complexity: usize,
128
129    /// Maximum cognitive complexity
130    #[serde(default = "default_max_cognitive_complexity")]
131    pub max_cognitive_complexity: usize,
132
133    /// Maximum file lines
134    #[serde(default = "default_max_file_lines")]
135    pub max_file_lines: usize,
136}
137
138fn default_max_function_lines() -> usize {
139    100
140}
141
142fn default_max_complexity() -> usize {
143    15
144}
145
146fn default_max_cognitive_complexity() -> usize {
147    20
148}
149
150fn default_max_file_lines() -> usize {
151    1000
152}
153
154impl Default for ProfileThresholds {
155    fn default() -> Self {
156        Self {
157            max_function_lines: default_max_function_lines(),
158            max_complexity: default_max_complexity(),
159            max_cognitive_complexity: default_max_cognitive_complexity(),
160            max_file_lines: default_max_file_lines(),
161        }
162    }
163}
164
165impl ProfileThresholds {
166    /// Get thresholds for a specific profile
167    pub fn for_profile(profile: Profile) -> Self {
168        match profile {
169            Profile::Fast => Self {
170                max_function_lines: 200,
171                max_complexity: 25,
172                max_cognitive_complexity: 35,
173                max_file_lines: 2000,
174            },
175            Profile::Balanced => Self::default(),
176            Profile::Strict => Self {
177                max_function_lines: 50,
178                max_complexity: 10,
179                max_cognitive_complexity: 15,
180                max_file_lines: 500,
181            },
182        }
183    }
184}
185
186/// All profiles configuration
187#[derive(Debug, Clone, Serialize, Deserialize, Default)]
188pub struct ProfilesConfig {
189    /// Default profile to use
190    #[serde(default)]
191    pub default: Profile,
192
193    /// Fast profile thresholds
194    #[serde(default = "fast_profile_defaults")]
195    pub fast: ProfileThresholds,
196
197    /// Balanced profile thresholds
198    #[serde(default)]
199    pub balanced: ProfileThresholds,
200
201    /// Strict profile thresholds
202    #[serde(default = "strict_profile_defaults")]
203    pub strict: ProfileThresholds,
204}
205
206fn fast_profile_defaults() -> ProfileThresholds {
207    ProfileThresholds::for_profile(Profile::Fast)
208}
209
210fn strict_profile_defaults() -> ProfileThresholds {
211    ProfileThresholds::for_profile(Profile::Strict)
212}
213
214impl ProfilesConfig {
215    /// Get thresholds for the specified profile
216    pub fn get_thresholds(&self, profile: Profile) -> &ProfileThresholds {
217        match profile {
218            Profile::Fast => &self.fast,
219            Profile::Balanced => &self.balanced,
220            Profile::Strict => &self.strict,
221        }
222    }
223}
224
225/// Path-specific threshold overrides
226#[derive(Debug, Clone, Serialize, Deserialize)]
227pub struct ThresholdOverride {
228    /// Glob pattern for paths this override applies to
229    pub path: String,
230
231    /// Maximum function lines (optional)
232    #[serde(default)]
233    pub max_function_lines: Option<usize>,
234
235    /// Maximum complexity (optional)
236    #[serde(default)]
237    pub max_complexity: Option<usize>,
238
239    /// Maximum cognitive complexity (optional)
240    #[serde(default)]
241    pub max_cognitive_complexity: Option<usize>,
242
243    /// Rules to disable for these paths
244    #[serde(default)]
245    pub disable_rules: Vec<String>,
246}
247
248/// Allowlist configuration for approved patterns
249#[derive(Debug, Clone, Serialize, Deserialize, Default)]
250pub struct AllowConfig {
251    /// Allow setTimeout with string argument
252    #[serde(default)]
253    pub settimeout_string: bool,
254
255    /// Allow setTimeout with function argument
256    #[serde(default = "default_true")]
257    pub settimeout_function: bool,
258
259    /// Paths where innerHTML is allowed
260    #[serde(default)]
261    pub innerhtml_paths: Vec<String>,
262
263    /// Paths where eval is allowed (e.g., build tools)
264    #[serde(default)]
265    pub eval_paths: Vec<String>,
266
267    /// Paths where unsafe Rust is allowed
268    #[serde(default)]
269    pub unsafe_rust_paths: Vec<String>,
270
271    /// Approved secret patterns (regex)
272    #[serde(default)]
273    pub approved_secrets: Vec<String>,
274}
275
276fn default_true() -> bool {
277    true
278}
279
280// =============================================================================
281// PROVIDERS CONFIGURATION
282// =============================================================================
283
284/// Available analysis providers
285#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
286#[serde(rename_all = "lowercase")]
287pub enum ProviderType {
288    /// Built-in RMA rules (always available)
289    Rma,
290    /// PMD for Java analysis (optional)
291    Pmd,
292    /// Oxlint for JavaScript/TypeScript (optional)
293    Oxlint,
294    /// RustSec for Rust dependency vulnerabilities (optional)
295    RustSec,
296}
297
298impl std::fmt::Display for ProviderType {
299    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
300        match self {
301            ProviderType::Rma => write!(f, "rma"),
302            ProviderType::Pmd => write!(f, "pmd"),
303            ProviderType::Oxlint => write!(f, "oxlint"),
304            ProviderType::RustSec => write!(f, "rustsec"),
305        }
306    }
307}
308
309impl std::str::FromStr for ProviderType {
310    type Err = String;
311
312    fn from_str(s: &str) -> Result<Self, Self::Err> {
313        match s.to_lowercase().as_str() {
314            "rma" => Ok(ProviderType::Rma),
315            "pmd" => Ok(ProviderType::Pmd),
316            "oxlint" => Ok(ProviderType::Oxlint),
317            "rustsec" => Ok(ProviderType::RustSec),
318            _ => Err(format!(
319                "Unknown provider: {}. Available: rma, pmd, oxlint",
320                s
321            )),
322        }
323    }
324}
325
326/// Providers configuration section
327#[derive(Debug, Clone, Serialize, Deserialize)]
328pub struct ProvidersConfig {
329    /// List of enabled providers (default: ["rma"])
330    #[serde(default = "default_enabled_providers")]
331    pub enabled: Vec<ProviderType>,
332
333    /// PMD provider configuration
334    #[serde(default)]
335    pub pmd: PmdProviderConfig,
336
337    /// Oxlint provider configuration
338    #[serde(default)]
339    pub oxlint: OxlintProviderConfig,
340}
341
342impl Default for ProvidersConfig {
343    fn default() -> Self {
344        Self {
345            enabled: default_enabled_providers(),
346            pmd: PmdProviderConfig::default(),
347            oxlint: OxlintProviderConfig::default(),
348        }
349    }
350}
351
352fn default_enabled_providers() -> Vec<ProviderType> {
353    vec![ProviderType::Rma]
354}
355
356/// PMD Java provider configuration
357#[derive(Debug, Clone, Serialize, Deserialize)]
358pub struct PmdProviderConfig {
359    /// Whether PMD provider is configured (separate from enabled list)
360    #[serde(default)]
361    pub configured: bool,
362
363    /// Path to Java executable
364    #[serde(default = "default_java_path")]
365    pub java_path: String,
366
367    /// Path to PMD installation (either pmd binary or pmd-dist directory)
368    /// If empty, will try to find 'pmd' in PATH
369    #[serde(default)]
370    pub pmd_path: String,
371
372    /// PMD rulesets to use
373    #[serde(default = "default_pmd_rulesets")]
374    pub rulesets: Vec<String>,
375
376    /// Timeout for PMD execution in milliseconds
377    #[serde(default = "default_pmd_timeout")]
378    pub timeout_ms: u64,
379
380    /// File patterns to include for PMD analysis
381    #[serde(default = "default_pmd_include_patterns")]
382    pub include_patterns: Vec<String>,
383
384    /// File patterns to exclude from PMD analysis
385    #[serde(default = "default_pmd_exclude_patterns")]
386    pub exclude_patterns: Vec<String>,
387
388    /// Severity mapping from PMD priority to RMA severity
389    /// Keys: "1", "2", "3", "4", "5" (PMD priorities)
390    /// Values: "critical", "error", "warning", "info"
391    #[serde(default = "default_pmd_severity_map")]
392    pub severity_map: HashMap<String, Severity>,
393
394    /// Whether to fail the scan if PMD itself fails (not findings, but tool errors)
395    #[serde(default)]
396    pub fail_on_error: bool,
397
398    /// Minimum PMD priority to report (1=highest, 5=lowest)
399    #[serde(default = "default_pmd_min_priority")]
400    pub min_priority: u8,
401
402    /// Additional PMD command-line arguments
403    #[serde(default)]
404    pub extra_args: Vec<String>,
405}
406
407impl Default for PmdProviderConfig {
408    fn default() -> Self {
409        Self {
410            configured: false,
411            java_path: default_java_path(),
412            pmd_path: String::new(),
413            rulesets: default_pmd_rulesets(),
414            timeout_ms: default_pmd_timeout(),
415            include_patterns: default_pmd_include_patterns(),
416            exclude_patterns: default_pmd_exclude_patterns(),
417            severity_map: default_pmd_severity_map(),
418            fail_on_error: false,
419            min_priority: default_pmd_min_priority(),
420            extra_args: Vec::new(),
421        }
422    }
423}
424
425fn default_java_path() -> String {
426    "java".to_string()
427}
428
429fn default_pmd_rulesets() -> Vec<String> {
430    vec![
431        "category/java/security.xml".to_string(),
432        "category/java/bestpractices.xml".to_string(),
433        "category/java/errorprone.xml".to_string(),
434    ]
435}
436
437fn default_pmd_timeout() -> u64 {
438    600_000 // 10 minutes
439}
440
441fn default_pmd_include_patterns() -> Vec<String> {
442    vec!["**/*.java".to_string()]
443}
444
445fn default_pmd_exclude_patterns() -> Vec<String> {
446    vec![
447        "**/target/**".to_string(),
448        "**/build/**".to_string(),
449        "**/generated/**".to_string(),
450        "**/out/**".to_string(),
451        "**/.git/**".to_string(),
452        "**/node_modules/**".to_string(),
453    ]
454}
455
456fn default_pmd_severity_map() -> HashMap<String, Severity> {
457    let mut map = HashMap::new();
458    map.insert("1".to_string(), Severity::Critical);
459    map.insert("2".to_string(), Severity::Error);
460    map.insert("3".to_string(), Severity::Warning);
461    map.insert("4".to_string(), Severity::Info);
462    map.insert("5".to_string(), Severity::Info);
463    map
464}
465
466fn default_pmd_min_priority() -> u8 {
467    5 // Report all priorities by default
468}
469
470/// Oxlint provider configuration
471#[derive(Debug, Clone, Serialize, Deserialize)]
472pub struct OxlintProviderConfig {
473    /// Whether Oxlint provider is configured
474    #[serde(default)]
475    pub configured: bool,
476
477    /// Path to oxlint binary (default: search PATH)
478    #[serde(default)]
479    pub binary_path: String,
480
481    /// Timeout for oxlint execution in milliseconds
482    #[serde(default = "default_oxlint_timeout")]
483    pub timeout_ms: u64,
484
485    /// Additional oxlint command-line arguments
486    #[serde(default)]
487    pub extra_args: Vec<String>,
488}
489
490impl Default for OxlintProviderConfig {
491    fn default() -> Self {
492        Self {
493            configured: false,
494            binary_path: String::new(),
495            timeout_ms: default_oxlint_timeout(),
496            extra_args: Vec::new(),
497        }
498    }
499}
500
501fn default_oxlint_timeout() -> u64 {
502    300_000 // 5 minutes
503}
504
505/// Baseline configuration
506#[derive(Debug, Clone, Serialize, Deserialize, Default)]
507pub struct BaselineConfig {
508    /// Path to baseline file
509    #[serde(default = "default_baseline_file")]
510    pub file: PathBuf,
511
512    /// Baseline mode
513    #[serde(default)]
514    pub mode: BaselineMode,
515}
516
517fn default_baseline_file() -> PathBuf {
518    PathBuf::from(".rma/baseline.json")
519}
520
521/// Current supported config version
522pub const CURRENT_CONFIG_VERSION: u32 = 1;
523
524/// Complete RMA TOML configuration
525#[derive(Debug, Clone, Serialize, Deserialize, Default)]
526pub struct RmaTomlConfig {
527    /// Config format version (for future compatibility)
528    #[serde(default)]
529    pub config_version: Option<u32>,
530
531    /// Scan path configuration
532    #[serde(default)]
533    pub scan: ScanConfig,
534
535    /// Rules configuration
536    #[serde(default)]
537    pub rules: RulesConfig,
538
539    /// Rulesets configuration (named groups of rules)
540    #[serde(default)]
541    pub rulesets: RulesetsConfig,
542
543    /// Profiles configuration
544    #[serde(default)]
545    pub profiles: ProfilesConfig,
546
547    /// Severity overrides by rule ID
548    #[serde(default)]
549    pub severity: HashMap<String, Severity>,
550
551    /// Path-specific threshold overrides
552    #[serde(default)]
553    pub threshold_overrides: Vec<ThresholdOverride>,
554
555    /// Allowlist configuration
556    #[serde(default)]
557    pub allow: AllowConfig,
558
559    /// Baseline configuration
560    #[serde(default)]
561    pub baseline: BaselineConfig,
562
563    /// Analysis providers configuration
564    #[serde(default)]
565    pub providers: ProvidersConfig,
566}
567
568/// Result of loading a config file
569#[derive(Debug)]
570pub struct ConfigLoadResult {
571    /// The loaded configuration
572    pub config: RmaTomlConfig,
573    /// Warning if version was missing
574    pub version_warning: Option<String>,
575}
576
577impl RmaTomlConfig {
578    /// Load configuration from file with version validation
579    pub fn load(path: &Path) -> Result<Self, String> {
580        let result = Self::load_with_validation(path)?;
581        Ok(result.config)
582    }
583
584    /// Load configuration from file with full validation info
585    pub fn load_with_validation(path: &Path) -> Result<ConfigLoadResult, String> {
586        let content = std::fs::read_to_string(path)
587            .map_err(|e| format!("Failed to read config file: {}", e))?;
588
589        let config: RmaTomlConfig =
590            toml::from_str(&content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
591
592        // Validate version
593        let version_warning = config.validate_version()?;
594
595        Ok(ConfigLoadResult {
596            config,
597            version_warning,
598        })
599    }
600
601    /// Validate config version, returns warning message if version is missing
602    fn validate_version(&self) -> Result<Option<String>, String> {
603        match self.config_version {
604            Some(CURRENT_CONFIG_VERSION) => Ok(None),
605            Some(version) if version > CURRENT_CONFIG_VERSION => Err(format!(
606                "Unsupported config version: {}. Maximum supported version is {}. \
607                     Please upgrade RMA or use a compatible config format.",
608                version, CURRENT_CONFIG_VERSION
609            )),
610            Some(version) => {
611                // Version 0 or any future "older than current" version
612                Err(format!(
613                    "Invalid config version: {}. Expected version {}.",
614                    version, CURRENT_CONFIG_VERSION
615                ))
616            }
617            None => Ok(Some(
618                "Config file is missing 'config_version'. Assuming version 1. \
619                 Add 'config_version = 1' to suppress this warning."
620                    .to_string(),
621            )),
622        }
623    }
624
625    /// Check if config version is present
626    pub fn has_version(&self) -> bool {
627        self.config_version.is_some()
628    }
629
630    /// Get the effective config version (defaults to 1 if missing)
631    pub fn effective_version(&self) -> u32 {
632        self.config_version.unwrap_or(CURRENT_CONFIG_VERSION)
633    }
634
635    /// Find and load configuration from standard locations
636    pub fn discover(start_path: &Path) -> Option<(PathBuf, Self)> {
637        let candidates = [
638            start_path.join("rma.toml"),
639            start_path.join(".rma/rma.toml"),
640            start_path.join(".rma.toml"),
641        ];
642
643        for candidate in &candidates {
644            if candidate.exists()
645                && let Ok(config) = Self::load(candidate)
646            {
647                return Some((candidate.clone(), config));
648            }
649        }
650
651        // Check parent directories up to 5 levels
652        let mut current = start_path.to_path_buf();
653        for _ in 0..5 {
654            if let Some(parent) = current.parent() {
655                let config_path = parent.join("rma.toml");
656                if config_path.exists()
657                    && let Ok(config) = Self::load(&config_path)
658                {
659                    return Some((config_path, config));
660                }
661                current = parent.to_path_buf();
662            } else {
663                break;
664            }
665        }
666
667        None
668    }
669
670    /// Validate configuration for errors and conflicts
671    pub fn validate(&self) -> Vec<ConfigWarning> {
672        let mut warnings = Vec::new();
673
674        // Check config version
675        if self.config_version.is_none() {
676            warnings.push(ConfigWarning {
677                level: WarningLevel::Warning,
678                message: "Missing 'config_version'. Add 'config_version = 1' to your config file."
679                    .to_string(),
680            });
681        } else if let Some(version) = self.config_version
682            && version > CURRENT_CONFIG_VERSION
683        {
684            warnings.push(ConfigWarning {
685                level: WarningLevel::Error,
686                message: format!(
687                    "Unsupported config version: {}. Maximum supported is {}.",
688                    version, CURRENT_CONFIG_VERSION
689                ),
690            });
691        }
692
693        // Check for conflicting enable/disable rules
694        for disabled in &self.rules.disable {
695            for enabled in &self.rules.enable {
696                if enabled == disabled {
697                    warnings.push(ConfigWarning {
698                        level: WarningLevel::Warning,
699                        message: format!(
700                            "Rule '{}' is both enabled and disabled (disable takes precedence)",
701                            disabled
702                        ),
703                    });
704                }
705            }
706        }
707
708        // Check threshold overrides have valid patterns
709        for (i, override_) in self.threshold_overrides.iter().enumerate() {
710            if override_.path.is_empty() {
711                warnings.push(ConfigWarning {
712                    level: WarningLevel::Error,
713                    message: format!("threshold_overrides[{}]: path cannot be empty", i),
714                });
715            }
716        }
717
718        // Check baseline file path
719        if self.baseline.mode == BaselineMode::NewOnly && !self.baseline.file.exists() {
720            warnings.push(ConfigWarning {
721                level: WarningLevel::Warning,
722                message: format!(
723                    "Baseline mode is 'new-only' but baseline file '{}' does not exist. Run 'rma baseline' first.",
724                    self.baseline.file.display()
725                ),
726            });
727        }
728
729        // Check severity overrides reference valid severities
730        for rule_id in self.severity.keys() {
731            if rule_id.is_empty() {
732                warnings.push(ConfigWarning {
733                    level: WarningLevel::Error,
734                    message: "Empty rule ID in severity overrides".to_string(),
735                });
736            }
737        }
738
739        // Validate provider configuration
740        if self.providers.enabled.contains(&ProviderType::Pmd) {
741            // PMD is enabled - check if it's configured
742            if !self.providers.pmd.configured && self.providers.pmd.pmd_path.is_empty() {
743                warnings.push(ConfigWarning {
744                    level: WarningLevel::Warning,
745                    message: "PMD provider is enabled but not configured. Set [providers.pmd] configured = true or provide pmd_path.".to_string(),
746                });
747            }
748
749            // Check PMD rulesets
750            if self.providers.pmd.rulesets.is_empty() {
751                warnings.push(ConfigWarning {
752                    level: WarningLevel::Warning,
753                    message:
754                        "PMD provider has no rulesets configured. Add rulesets to [providers.pmd]."
755                            .to_string(),
756                });
757            }
758
759            // Check severity map validity
760            for priority in self.providers.pmd.severity_map.keys() {
761                if !["1", "2", "3", "4", "5"].contains(&priority.as_str()) {
762                    warnings.push(ConfigWarning {
763                        level: WarningLevel::Warning,
764                        message: format!(
765                            "Invalid PMD priority '{}' in severity_map. Valid priorities: 1-5.",
766                            priority
767                        ),
768                    });
769                }
770            }
771        }
772
773        if self.providers.enabled.contains(&ProviderType::Oxlint)
774            && !self.providers.oxlint.configured
775        {
776            warnings.push(ConfigWarning {
777                level: WarningLevel::Warning,
778                message: "Oxlint provider is enabled but not configured. Set [providers.oxlint] configured = true.".to_string(),
779            });
780        }
781
782        warnings
783    }
784
785    /// Check if a specific provider is enabled
786    pub fn is_provider_enabled(&self, provider: ProviderType) -> bool {
787        self.providers.enabled.contains(&provider)
788    }
789
790    /// Get the list of enabled providers
791    pub fn get_enabled_providers(&self) -> &[ProviderType] {
792        &self.providers.enabled
793    }
794
795    /// Get PMD provider config (if PMD is enabled)
796    pub fn get_pmd_config(&self) -> Option<&PmdProviderConfig> {
797        if self.is_provider_enabled(ProviderType::Pmd) {
798            Some(&self.providers.pmd)
799        } else {
800            None
801        }
802    }
803
804    /// Check if a rule is enabled (without ruleset filtering)
805    pub fn is_rule_enabled(&self, rule_id: &str) -> bool {
806        self.is_rule_enabled_with_ruleset(rule_id, None)
807    }
808
809    /// Check if a rule is enabled with optional ruleset filter
810    ///
811    /// If a ruleset is specified, only rules in that ruleset are considered enabled.
812    /// Explicit disable always takes precedence.
813    pub fn is_rule_enabled_with_ruleset(&self, rule_id: &str, ruleset: Option<&str>) -> bool {
814        // Check if explicitly disabled - always takes precedence
815        for pattern in &self.rules.disable {
816            if Self::matches_pattern(rule_id, pattern) {
817                return false;
818            }
819        }
820
821        // If a ruleset is specified, check if rule is in that ruleset
822        if let Some(ruleset_name) = ruleset {
823            let ruleset_rules = self.get_ruleset_rules(ruleset_name);
824            if !ruleset_rules.is_empty() {
825                // Rule must be in the ruleset to be enabled
826                return ruleset_rules
827                    .iter()
828                    .any(|r| Self::matches_pattern(rule_id, r));
829            }
830        }
831
832        // Check if explicitly enabled
833        for pattern in &self.rules.enable {
834            if Self::matches_pattern(rule_id, pattern) {
835                return true;
836            }
837        }
838
839        false
840    }
841
842    /// Get rules for a named ruleset
843    pub fn get_ruleset_rules(&self, name: &str) -> Vec<String> {
844        match name {
845            "security" => self.rulesets.security.clone(),
846            "maintainability" => self.rulesets.maintainability.clone(),
847            _ => self.rulesets.custom.get(name).cloned().unwrap_or_default(),
848        }
849    }
850
851    /// Get all available ruleset names
852    pub fn get_ruleset_names(&self) -> Vec<String> {
853        let mut names = Vec::new();
854        if !self.rulesets.security.is_empty() {
855            names.push("security".to_string());
856        }
857        if !self.rulesets.maintainability.is_empty() {
858            names.push("maintainability".to_string());
859        }
860        names.extend(self.rulesets.custom.keys().cloned());
861        names
862    }
863
864    /// Get severity override for a rule
865    pub fn get_severity_override(&self, rule_id: &str) -> Option<Severity> {
866        self.severity.get(rule_id).copied()
867    }
868
869    /// Get thresholds for a path, applying overrides
870    pub fn get_thresholds_for_path(&self, path: &Path, profile: Profile) -> ProfileThresholds {
871        let mut thresholds = self.profiles.get_thresholds(profile).clone();
872
873        // Apply path-specific overrides
874        let path_str = path.to_string_lossy();
875        for override_ in &self.threshold_overrides {
876            if Self::matches_glob(&path_str, &override_.path) {
877                if let Some(v) = override_.max_function_lines {
878                    thresholds.max_function_lines = v;
879                }
880                if let Some(v) = override_.max_complexity {
881                    thresholds.max_complexity = v;
882                }
883                if let Some(v) = override_.max_cognitive_complexity {
884                    thresholds.max_cognitive_complexity = v;
885                }
886            }
887        }
888
889        thresholds
890    }
891
892    /// Check if a path is allowed for a specific rule
893    pub fn is_path_allowed(&self, path: &Path, rule_type: AllowType) -> bool {
894        let path_str = path.to_string_lossy();
895        let allowed_paths = match rule_type {
896            AllowType::InnerHtml => &self.allow.innerhtml_paths,
897            AllowType::Eval => &self.allow.eval_paths,
898            AllowType::UnsafeRust => &self.allow.unsafe_rust_paths,
899        };
900
901        for pattern in allowed_paths {
902            if Self::matches_glob(&path_str, pattern) {
903                return true;
904            }
905        }
906
907        false
908    }
909
910    fn matches_pattern(rule_id: &str, pattern: &str) -> bool {
911        if pattern == "*" {
912            return true;
913        }
914
915        if let Some(prefix) = pattern.strip_suffix("/*") {
916            return rule_id.starts_with(prefix);
917        }
918
919        rule_id == pattern
920    }
921
922    fn matches_glob(path: &str, pattern: &str) -> bool {
923        // Simple glob matching (supports * and **)
924        let pattern = pattern
925            .replace("**", "§")
926            .replace('*', "[^/]*")
927            .replace('§', ".*");
928        regex::Regex::new(&format!("^{}$", pattern))
929            .map(|re| re.is_match(path))
930            .unwrap_or(false)
931    }
932
933    /// Generate default configuration as TOML string
934    pub fn default_toml(profile: Profile) -> String {
935        let thresholds = ProfileThresholds::for_profile(profile);
936
937        format!(
938            r#"# RMA Configuration
939# Documentation: https://github.com/bumahkib7/rust-monorepo-analyzer
940
941# Config format version (required for future compatibility)
942config_version = 1
943
944[scan]
945# Paths to include in scanning (default: all supported files)
946include = ["src/**", "lib/**", "scripts/**"]
947
948# Paths to exclude from scanning
949exclude = [
950    "node_modules/**",
951    "target/**",
952    "dist/**",
953    "build/**",
954    "vendor/**",
955    "**/*.min.js",
956    "**/*.bundle.js",
957]
958
959# Maximum file size to scan (10MB default)
960max_file_size = 10485760
961
962[rules]
963# Rules to enable (wildcards supported)
964enable = ["*"]
965
966# Rules to disable (takes precedence over enable)
967disable = []
968
969[profiles]
970# Default profile: fast, balanced, or strict
971default = "{profile}"
972
973[profiles.fast]
974max_function_lines = 200
975max_complexity = 25
976max_cognitive_complexity = 35
977max_file_lines = 2000
978
979[profiles.balanced]
980max_function_lines = {max_function_lines}
981max_complexity = {max_complexity}
982max_cognitive_complexity = {max_cognitive_complexity}
983max_file_lines = 1000
984
985[profiles.strict]
986max_function_lines = 50
987max_complexity = 10
988max_cognitive_complexity = 15
989max_file_lines = 500
990
991[rulesets]
992# Named rule groups for targeted scanning
993security = ["js/innerhtml-xss", "js/timer-string-eval", "js/dynamic-code-execution", "rust/unsafe-block", "python/shell-injection"]
994maintainability = ["generic/long-function", "generic/high-complexity", "js/console-log"]
995
996[severity]
997# Override severity for specific rules
998# "generic/long-function" = "warning"
999# "js/innerhtml-xss" = "error"
1000# "rust/unsafe-block" = "warning"
1001
1002# [[threshold_overrides]]
1003# path = "src/legacy/**"
1004# max_function_lines = 300
1005# max_complexity = 30
1006
1007# [[threshold_overrides]]
1008# path = "tests/**"
1009# disable_rules = ["generic/long-function"]
1010
1011[allow]
1012# Approved patterns that won't trigger alerts
1013settimeout_string = false
1014settimeout_function = true
1015innerhtml_paths = []
1016eval_paths = []
1017unsafe_rust_paths = []
1018approved_secrets = []
1019
1020[baseline]
1021# Baseline file for tracking legacy issues
1022file = ".rma/baseline.json"
1023# Mode: "all" or "new-only"
1024mode = "all"
1025
1026# =============================================================================
1027# ANALYSIS PROVIDERS
1028# =============================================================================
1029# RMA supports external analysis providers for extended language coverage.
1030# Providers can be enabled/disabled individually.
1031
1032[providers]
1033# List of enabled providers (default: only "rma" built-in rules)
1034enabled = ["rma"]
1035# To enable PMD for Java: enabled = ["rma", "pmd"]
1036# To enable Oxlint for JS/TS: enabled = ["rma", "oxlint"]
1037
1038# -----------------------------------------------------------------------------
1039# PMD Provider - Java Static Analysis (optional)
1040# -----------------------------------------------------------------------------
1041# PMD provides comprehensive Java security and quality analysis.
1042# Requires: Java runtime and PMD installation
1043#
1044# [providers.pmd]
1045# configured = true
1046# java_path = "java"                    # Path to java binary
1047# pmd_path = ""                         # Path to pmd binary (or leave empty to use PATH)
1048# rulesets = [
1049#     "category/java/security.xml",
1050#     "category/java/bestpractices.xml",
1051#     "category/java/errorprone.xml",
1052# ]
1053# timeout_ms = 600000                   # 10 minutes timeout
1054# include_patterns = ["**/*.java"]
1055# exclude_patterns = ["**/target/**", "**/build/**", "**/generated/**"]
1056# fail_on_error = false                 # Continue scan if PMD fails
1057# min_priority = 5                      # Report all priorities (1-5)
1058# extra_args = []                       # Additional PMD CLI arguments
1059
1060# [providers.pmd.severity_map]
1061# # Map PMD priority (1-5) to RMA severity
1062# "1" = "critical"
1063# "2" = "error"
1064# "3" = "warning"
1065# "4" = "info"
1066# "5" = "info"
1067
1068# -----------------------------------------------------------------------------
1069# Oxlint Provider - Fast JavaScript/TypeScript Linting (optional)
1070# -----------------------------------------------------------------------------
1071# [providers.oxlint]
1072# configured = true
1073# binary_path = ""                      # Path to oxlint binary (or leave empty to use PATH)
1074# timeout_ms = 300000                   # 5 minutes timeout
1075# extra_args = []
1076"#,
1077            profile = profile,
1078            max_function_lines = thresholds.max_function_lines,
1079            max_complexity = thresholds.max_complexity,
1080            max_cognitive_complexity = thresholds.max_cognitive_complexity,
1081        )
1082    }
1083}
1084
1085/// Type of allowlist check
1086#[derive(Debug, Clone, Copy)]
1087pub enum AllowType {
1088    InnerHtml,
1089    Eval,
1090    UnsafeRust,
1091}
1092
1093/// Source of a configuration value (for precedence tracking)
1094#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1095#[serde(rename_all = "kebab-case")]
1096pub enum ConfigSource {
1097    /// Built-in default value
1098    Default,
1099    /// From rma.toml configuration file
1100    ConfigFile,
1101    /// From CLI flag or environment variable
1102    CliFlag,
1103}
1104
1105impl std::fmt::Display for ConfigSource {
1106    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1107        match self {
1108            ConfigSource::Default => write!(f, "default"),
1109            ConfigSource::ConfigFile => write!(f, "config-file"),
1110            ConfigSource::CliFlag => write!(f, "cli-flag"),
1111        }
1112    }
1113}
1114
1115/// Effective (resolved) configuration after applying precedence
1116///
1117/// Precedence order (highest to lowest):
1118/// 1. CLI flags (--config, --profile, --baseline-mode)
1119/// 2. rma.toml in repo root (or explicit --config path)
1120/// 3. Built-in defaults
1121#[derive(Debug, Clone, Serialize, Deserialize)]
1122pub struct EffectiveConfig {
1123    /// Source of the configuration file (if any)
1124    pub config_file: Option<PathBuf>,
1125
1126    /// Active profile
1127    pub profile: Profile,
1128
1129    /// Where the profile came from
1130    pub profile_source: ConfigSource,
1131
1132    /// Resolved thresholds
1133    pub thresholds: ProfileThresholds,
1134
1135    /// Number of enabled rules
1136    pub enabled_rules_count: usize,
1137
1138    /// Number of disabled rules
1139    pub disabled_rules_count: usize,
1140
1141    /// Number of severity overrides
1142    pub severity_overrides_count: usize,
1143
1144    /// Threshold overrides (paths in order)
1145    pub threshold_override_paths: Vec<String>,
1146
1147    /// Baseline mode
1148    pub baseline_mode: BaselineMode,
1149
1150    /// Where baseline mode came from
1151    pub baseline_mode_source: ConfigSource,
1152
1153    /// Exclude patterns
1154    pub exclude_patterns: Vec<String>,
1155
1156    /// Include patterns
1157    pub include_patterns: Vec<String>,
1158}
1159
1160impl EffectiveConfig {
1161    /// Build effective config from sources with proper precedence
1162    pub fn resolve(
1163        toml_config: Option<&RmaTomlConfig>,
1164        config_path: Option<&Path>,
1165        cli_profile: Option<Profile>,
1166        cli_baseline_mode: bool,
1167    ) -> Self {
1168        // Resolve profile: CLI > config > default
1169        let (profile, profile_source) = if let Some(p) = cli_profile {
1170            (p, ConfigSource::CliFlag)
1171        } else if let Some(cfg) = toml_config {
1172            (cfg.profiles.default, ConfigSource::ConfigFile)
1173        } else {
1174            (Profile::default(), ConfigSource::Default)
1175        };
1176
1177        // Resolve baseline mode: CLI > config > default
1178        let (baseline_mode, baseline_mode_source) = if cli_baseline_mode {
1179            (BaselineMode::NewOnly, ConfigSource::CliFlag)
1180        } else if let Some(cfg) = toml_config {
1181            (cfg.baseline.mode, ConfigSource::ConfigFile)
1182        } else {
1183            (BaselineMode::default(), ConfigSource::Default)
1184        };
1185
1186        // Get thresholds for profile
1187        let thresholds = toml_config
1188            .map(|cfg| cfg.profiles.get_thresholds(profile).clone())
1189            .unwrap_or_else(|| ProfileThresholds::for_profile(profile));
1190
1191        // Count rules
1192        let (enabled_rules_count, disabled_rules_count) = toml_config
1193            .map(|cfg| (cfg.rules.enable.len(), cfg.rules.disable.len()))
1194            .unwrap_or((1, 0)); // default: enable = ["*"]
1195
1196        // Severity overrides
1197        let severity_overrides_count = toml_config.map(|cfg| cfg.severity.len()).unwrap_or(0);
1198
1199        // Threshold override paths
1200        let threshold_override_paths = toml_config
1201            .map(|cfg| {
1202                cfg.threshold_overrides
1203                    .iter()
1204                    .map(|o| o.path.clone())
1205                    .collect()
1206            })
1207            .unwrap_or_default();
1208
1209        // Patterns
1210        let exclude_patterns = toml_config
1211            .map(|cfg| cfg.scan.exclude.clone())
1212            .unwrap_or_default();
1213
1214        let include_patterns = toml_config
1215            .map(|cfg| cfg.scan.include.clone())
1216            .unwrap_or_default();
1217
1218        Self {
1219            config_file: config_path.map(|p| p.to_path_buf()),
1220            profile,
1221            profile_source,
1222            thresholds,
1223            enabled_rules_count,
1224            disabled_rules_count,
1225            severity_overrides_count,
1226            threshold_override_paths,
1227            baseline_mode,
1228            baseline_mode_source,
1229            exclude_patterns,
1230            include_patterns,
1231        }
1232    }
1233
1234    /// Format as human-readable text
1235    pub fn to_text(&self) -> String {
1236        let mut out = String::new();
1237
1238        out.push_str("Effective Configuration\n");
1239        out.push_str("═══════════════════════════════════════════════════════════\n\n");
1240
1241        // Config file
1242        out.push_str("  Config file:        ");
1243        match &self.config_file {
1244            Some(p) => out.push_str(&format!("{}\n", p.display())),
1245            None => out.push_str("(none - using defaults)\n"),
1246        }
1247
1248        // Profile
1249        out.push_str(&format!(
1250            "  Profile:            {} (from {})\n",
1251            self.profile, self.profile_source
1252        ));
1253
1254        // Thresholds
1255        out.push_str("\n  Thresholds:\n");
1256        out.push_str(&format!(
1257            "    max_function_lines:     {}\n",
1258            self.thresholds.max_function_lines
1259        ));
1260        out.push_str(&format!(
1261            "    max_complexity:         {}\n",
1262            self.thresholds.max_complexity
1263        ));
1264        out.push_str(&format!(
1265            "    max_cognitive_complexity: {}\n",
1266            self.thresholds.max_cognitive_complexity
1267        ));
1268        out.push_str(&format!(
1269            "    max_file_lines:         {}\n",
1270            self.thresholds.max_file_lines
1271        ));
1272
1273        // Rules
1274        out.push_str("\n  Rules:\n");
1275        out.push_str(&format!(
1276            "    enabled patterns:       {}\n",
1277            self.enabled_rules_count
1278        ));
1279        out.push_str(&format!(
1280            "    disabled patterns:      {}\n",
1281            self.disabled_rules_count
1282        ));
1283        out.push_str(&format!(
1284            "    severity overrides:     {}\n",
1285            self.severity_overrides_count
1286        ));
1287
1288        // Threshold overrides
1289        if !self.threshold_override_paths.is_empty() {
1290            out.push_str("\n  Threshold overrides:\n");
1291            for path in &self.threshold_override_paths {
1292                out.push_str(&format!("    - {}\n", path));
1293            }
1294        }
1295
1296        // Baseline
1297        out.push_str(&format!(
1298            "\n  Baseline mode:      {:?} (from {})\n",
1299            self.baseline_mode, self.baseline_mode_source
1300        ));
1301
1302        out
1303    }
1304
1305    /// Format as JSON
1306    pub fn to_json(&self) -> Result<String, serde_json::Error> {
1307        serde_json::to_string_pretty(self)
1308    }
1309}
1310
1311/// Configuration warning or error
1312#[derive(Debug, Clone)]
1313pub struct ConfigWarning {
1314    pub level: WarningLevel,
1315    pub message: String,
1316}
1317
1318#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1319pub enum WarningLevel {
1320    Warning,
1321    Error,
1322}
1323
1324/// Inline suppression comment parsed from source code
1325#[derive(Debug, Clone, PartialEq, Eq)]
1326pub struct InlineSuppression {
1327    /// The rule ID to suppress
1328    pub rule_id: String,
1329    /// The reason for suppression (required in strict profile)
1330    pub reason: Option<String>,
1331    /// Line number where the suppression comment appears
1332    pub line: usize,
1333    /// Type of suppression
1334    pub suppression_type: SuppressionType,
1335}
1336
1337/// Type of inline suppression
1338#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1339pub enum SuppressionType {
1340    /// Suppresses the next line only
1341    NextLine,
1342    /// Suppresses until end of block/function (or file-level until blank line)
1343    Block,
1344}
1345
1346impl InlineSuppression {
1347    /// Parse a suppression comment from a line of code
1348    ///
1349    /// Supported formats:
1350    /// - `// rma-ignore-next-line <rule_id> reason="<text>"`
1351    /// - `// rma-ignore <rule_id> reason="<text>"`
1352    /// - `# rma-ignore-next-line <rule_id> reason="<text>"` (Python)
1353    pub fn parse(line: &str, line_number: usize) -> Option<Self> {
1354        let trimmed = line.trim();
1355
1356        // Check for comment prefixes
1357        let comment_body = if let Some(rest) = trimmed.strip_prefix("//") {
1358            rest.trim()
1359        } else if let Some(rest) = trimmed.strip_prefix('#') {
1360            rest.trim()
1361        } else {
1362            return None;
1363        };
1364
1365        // Check for rma-ignore-next-line
1366        if let Some(rest) = comment_body.strip_prefix("rma-ignore-next-line") {
1367            return Self::parse_suppression_body(
1368                rest.trim(),
1369                line_number,
1370                SuppressionType::NextLine,
1371            );
1372        }
1373
1374        // Check for rma-ignore (block level)
1375        if let Some(rest) = comment_body.strip_prefix("rma-ignore") {
1376            return Self::parse_suppression_body(rest.trim(), line_number, SuppressionType::Block);
1377        }
1378
1379        None
1380    }
1381
1382    fn parse_suppression_body(
1383        body: &str,
1384        line_number: usize,
1385        suppression_type: SuppressionType,
1386    ) -> Option<Self> {
1387        if body.is_empty() {
1388            return None;
1389        }
1390
1391        // Parse: <rule_id> [reason="<text>"]
1392        let mut parts = body.splitn(2, ' ');
1393        let rule_id = parts.next()?.trim().to_string();
1394
1395        if rule_id.is_empty() {
1396            return None;
1397        }
1398
1399        let reason = parts.next().and_then(|rest| {
1400            // Look for reason="..."
1401            if let Some(start) = rest.find("reason=\"") {
1402                let after_quote = &rest[start + 8..];
1403                if let Some(end) = after_quote.find('"') {
1404                    return Some(after_quote[..end].to_string());
1405                }
1406            }
1407            None
1408        });
1409
1410        Some(Self {
1411            rule_id,
1412            reason,
1413            line: line_number,
1414            suppression_type,
1415        })
1416    }
1417
1418    /// Check if this suppression applies to a finding at the given line
1419    pub fn applies_to(&self, finding_line: usize, rule_id: &str) -> bool {
1420        if self.rule_id != rule_id && self.rule_id != "*" {
1421            return false;
1422        }
1423
1424        match self.suppression_type {
1425            SuppressionType::NextLine => finding_line == self.line + 1,
1426            SuppressionType::Block => finding_line >= self.line,
1427        }
1428    }
1429
1430    /// Validate suppression (check if reason is required and present)
1431    pub fn validate(&self, require_reason: bool) -> Result<(), String> {
1432        if require_reason && self.reason.is_none() {
1433            return Err(format!(
1434                "Suppression for '{}' at line {} requires a reason in strict profile",
1435                self.rule_id, self.line
1436            ));
1437        }
1438        Ok(())
1439    }
1440}
1441
1442/// Parse all inline suppressions from source code
1443pub fn parse_inline_suppressions(content: &str) -> Vec<InlineSuppression> {
1444    content
1445        .lines()
1446        .enumerate()
1447        .filter_map(|(i, line)| InlineSuppression::parse(line, i + 1))
1448        .collect()
1449}
1450
1451/// Stable fingerprint for a finding
1452///
1453/// Fingerprints are designed to survive:
1454/// - Line number changes (refactoring moves code)
1455/// - Minor whitespace changes
1456/// - Non-semantic message text changes
1457///
1458/// Fingerprint inputs (in order):
1459/// 1. rule_id (e.g., "js/innerhtml-xss")
1460/// 2. file path (normalized, unix separators)
1461/// 3. normalized snippet (trimmed, collapsed whitespace)
1462#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
1463pub struct Fingerprint(String);
1464
1465impl Fingerprint {
1466    /// Generate a stable fingerprint for a finding
1467    pub fn generate(rule_id: &str, file_path: &Path, snippet: &str) -> Self {
1468        use sha2::{Digest, Sha256};
1469
1470        let mut hasher = Sha256::new();
1471
1472        // 1. Rule ID
1473        hasher.update(rule_id.as_bytes());
1474        hasher.update(b"|");
1475
1476        // 2. Normalized file path (unix separators, lowercase for case-insensitive FS)
1477        let normalized_path = file_path
1478            .to_string_lossy()
1479            .replace('\\', "/")
1480            .to_lowercase();
1481        hasher.update(normalized_path.as_bytes());
1482        hasher.update(b"|");
1483
1484        // 3. Normalized snippet (collapse whitespace, trim)
1485        let normalized_snippet = Self::normalize_snippet(snippet);
1486        hasher.update(normalized_snippet.as_bytes());
1487
1488        let hash = hasher.finalize();
1489        Self(format!("sha256:{:x}", hash))
1490    }
1491
1492    /// Normalize snippet for stable fingerprinting
1493    fn normalize_snippet(snippet: &str) -> String {
1494        snippet
1495            .split_whitespace()
1496            .collect::<Vec<_>>()
1497            .join(" ")
1498            .trim()
1499            .to_string()
1500    }
1501
1502    /// Get the fingerprint string
1503    pub fn as_str(&self) -> &str {
1504        &self.0
1505    }
1506
1507    /// Create from existing fingerprint string
1508    pub fn from_string(s: String) -> Self {
1509        Self(s)
1510    }
1511}
1512
1513impl std::fmt::Display for Fingerprint {
1514    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1515        write!(f, "{}", self.0)
1516    }
1517}
1518
1519impl From<Fingerprint> for String {
1520    fn from(fp: Fingerprint) -> String {
1521        fp.0
1522    }
1523}
1524
1525/// Baseline entry for a finding
1526#[derive(Debug, Clone, Serialize, Deserialize)]
1527pub struct BaselineEntry {
1528    pub rule_id: String,
1529    pub file: PathBuf,
1530    #[serde(default)]
1531    pub line: usize,
1532    pub fingerprint: String,
1533    pub first_seen: String,
1534    #[serde(default)]
1535    pub suppressed: bool,
1536    #[serde(default)]
1537    pub comment: Option<String>,
1538}
1539
1540/// Baseline file containing known findings
1541#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1542pub struct Baseline {
1543    pub version: String,
1544    pub created: String,
1545    pub entries: Vec<BaselineEntry>,
1546}
1547
1548impl Baseline {
1549    pub fn new() -> Self {
1550        Self {
1551            version: "1.0".to_string(),
1552            created: chrono::Utc::now().to_rfc3339(),
1553            entries: Vec::new(),
1554        }
1555    }
1556
1557    pub fn load(path: &Path) -> Result<Self, String> {
1558        let content = std::fs::read_to_string(path)
1559            .map_err(|e| format!("Failed to read baseline file: {}", e))?;
1560
1561        serde_json::from_str(&content).map_err(|e| format!("Failed to parse baseline: {}", e))
1562    }
1563
1564    pub fn save(&self, path: &Path) -> Result<(), String> {
1565        if let Some(parent) = path.parent() {
1566            std::fs::create_dir_all(parent)
1567                .map_err(|e| format!("Failed to create directory: {}", e))?;
1568        }
1569
1570        let content = serde_json::to_string_pretty(self)
1571            .map_err(|e| format!("Failed to serialize baseline: {}", e))?;
1572
1573        std::fs::write(path, content).map_err(|e| format!("Failed to write baseline: {}", e))
1574    }
1575
1576    /// Check if a finding is in the baseline by fingerprint
1577    pub fn contains_fingerprint(&self, fingerprint: &Fingerprint) -> bool {
1578        self.entries
1579            .iter()
1580            .any(|e| e.fingerprint == fingerprint.as_str())
1581    }
1582
1583    /// Check if a finding is in the baseline (legacy method)
1584    pub fn contains(&self, rule_id: &str, file: &Path, fingerprint: &str) -> bool {
1585        self.entries
1586            .iter()
1587            .any(|e| e.rule_id == rule_id && e.file == file && e.fingerprint == fingerprint)
1588    }
1589
1590    /// Add a finding to the baseline using stable fingerprint
1591    pub fn add_with_fingerprint(
1592        &mut self,
1593        rule_id: String,
1594        file: PathBuf,
1595        line: usize,
1596        fingerprint: Fingerprint,
1597    ) {
1598        if !self.contains_fingerprint(&fingerprint) {
1599            self.entries.push(BaselineEntry {
1600                rule_id,
1601                file,
1602                line,
1603                fingerprint: fingerprint.into(),
1604                first_seen: chrono::Utc::now().to_rfc3339(),
1605                suppressed: false,
1606                comment: None,
1607            });
1608        }
1609    }
1610
1611    /// Add a finding to the baseline (legacy method)
1612    pub fn add(&mut self, rule_id: String, file: PathBuf, line: usize, fingerprint: String) {
1613        if !self.contains(&rule_id, &file, &fingerprint) {
1614            self.entries.push(BaselineEntry {
1615                rule_id,
1616                file,
1617                line,
1618                fingerprint,
1619                first_seen: chrono::Utc::now().to_rfc3339(),
1620                suppressed: false,
1621                comment: None,
1622            });
1623        }
1624    }
1625}
1626
1627#[cfg(test)]
1628mod tests {
1629    use super::*;
1630    use std::str::FromStr;
1631
1632    #[test]
1633    fn test_profile_parsing() {
1634        assert_eq!(Profile::from_str("fast").unwrap(), Profile::Fast);
1635        assert_eq!(Profile::from_str("balanced").unwrap(), Profile::Balanced);
1636        assert_eq!(Profile::from_str("strict").unwrap(), Profile::Strict);
1637        assert!(Profile::from_str("unknown").is_err());
1638    }
1639
1640    #[test]
1641    fn test_rule_matching() {
1642        assert!(RmaTomlConfig::matches_pattern("security/xss", "*"));
1643        assert!(RmaTomlConfig::matches_pattern("security/xss", "security/*"));
1644        assert!(!RmaTomlConfig::matches_pattern(
1645            "generic/long",
1646            "security/*"
1647        ));
1648        assert!(RmaTomlConfig::matches_pattern(
1649            "security/xss",
1650            "security/xss"
1651        ));
1652    }
1653
1654    #[test]
1655    fn test_default_config_parses() {
1656        let toml = RmaTomlConfig::default_toml(Profile::Balanced);
1657        let config: RmaTomlConfig = toml::from_str(&toml).expect("Default config should parse");
1658        assert_eq!(config.profiles.default, Profile::Balanced);
1659    }
1660
1661    #[test]
1662    fn test_thresholds_for_profile() {
1663        let fast = ProfileThresholds::for_profile(Profile::Fast);
1664        let strict = ProfileThresholds::for_profile(Profile::Strict);
1665
1666        assert!(fast.max_function_lines > strict.max_function_lines);
1667        assert!(fast.max_complexity > strict.max_complexity);
1668    }
1669
1670    #[test]
1671    fn test_fingerprint_stable_across_line_changes() {
1672        // Same finding at different line numbers should yield same fingerprint
1673        let fp1 = Fingerprint::generate(
1674            "js/xss-sink",
1675            Path::new("src/app.js"),
1676            "element.textContent = userInput;",
1677        );
1678        let fp2 = Fingerprint::generate(
1679            "js/xss-sink",
1680            Path::new("src/app.js"),
1681            "element.textContent = userInput;",
1682        );
1683
1684        assert_eq!(fp1, fp2);
1685    }
1686
1687    #[test]
1688    fn test_fingerprint_stable_with_whitespace_changes() {
1689        // Minor whitespace changes shouldn't affect fingerprint
1690        let fp1 = Fingerprint::generate(
1691            "generic/long-function",
1692            Path::new("src/utils.rs"),
1693            "fn very_long_function() {",
1694        );
1695        let fp2 = Fingerprint::generate(
1696            "generic/long-function",
1697            Path::new("src/utils.rs"),
1698            "fn   very_long_function()   {",
1699        );
1700        let fp3 = Fingerprint::generate(
1701            "generic/long-function",
1702            Path::new("src/utils.rs"),
1703            "  fn very_long_function() {  ",
1704        );
1705
1706        assert_eq!(fp1, fp2);
1707        assert_eq!(fp2, fp3);
1708    }
1709
1710    #[test]
1711    fn test_fingerprint_different_for_different_rules() {
1712        let fp1 = Fingerprint::generate("js/xss-sink", Path::new("src/app.js"), "element.x = val;");
1713        let fp2 = Fingerprint::generate("js/eval", Path::new("src/app.js"), "element.x = val;");
1714
1715        assert_ne!(fp1, fp2);
1716    }
1717
1718    #[test]
1719    fn test_fingerprint_different_for_different_files() {
1720        let fp1 = Fingerprint::generate("js/xss-sink", Path::new("src/app.js"), "element.x = val;");
1721        let fp2 =
1722            Fingerprint::generate("js/xss-sink", Path::new("src/other.js"), "element.x = val;");
1723
1724        assert_ne!(fp1, fp2);
1725    }
1726
1727    #[test]
1728    fn test_fingerprint_path_normalization() {
1729        // Windows and Unix paths should normalize to same fingerprint
1730        let fp1 = Fingerprint::generate("js/xss-sink", Path::new("src/components/App.js"), "x");
1731        let fp2 = Fingerprint::generate("js/xss-sink", Path::new("src\\components\\App.js"), "x");
1732
1733        assert_eq!(fp1, fp2);
1734    }
1735
1736    #[test]
1737    fn test_effective_config_precedence() {
1738        // Test CLI overrides config
1739        let toml_config = RmaTomlConfig::default();
1740        let effective = EffectiveConfig::resolve(
1741            Some(&toml_config),
1742            Some(Path::new("rma.toml")),
1743            Some(Profile::Strict), // CLI override
1744            false,
1745        );
1746
1747        assert_eq!(effective.profile, Profile::Strict);
1748        assert_eq!(effective.profile_source, ConfigSource::CliFlag);
1749    }
1750
1751    #[test]
1752    fn test_effective_config_defaults() {
1753        // No config, no CLI flags = defaults
1754        let effective = EffectiveConfig::resolve(None, None, None, false);
1755
1756        assert_eq!(effective.profile, Profile::Balanced);
1757        assert_eq!(effective.profile_source, ConfigSource::Default);
1758        assert!(effective.config_file.is_none());
1759    }
1760
1761    #[test]
1762    fn test_effective_config_from_file() {
1763        // Config file with no CLI override
1764        let mut toml_config = RmaTomlConfig::default();
1765        toml_config.profiles.default = Profile::Fast;
1766
1767        let effective =
1768            EffectiveConfig::resolve(Some(&toml_config), Some(Path::new("rma.toml")), None, false);
1769
1770        assert_eq!(effective.profile, Profile::Fast);
1771        assert_eq!(effective.profile_source, ConfigSource::ConfigFile);
1772    }
1773
1774    #[test]
1775    fn test_config_version_missing_warns() {
1776        let toml = r#"
1777[profiles]
1778default = "balanced"
1779"#;
1780        let config: RmaTomlConfig = toml::from_str(toml).unwrap();
1781        assert!(config.config_version.is_none());
1782        assert!(!config.has_version());
1783        assert_eq!(config.effective_version(), 1);
1784
1785        let warnings = config.validate();
1786        assert!(
1787            warnings
1788                .iter()
1789                .any(|w| w.message.contains("Missing 'config_version'"))
1790        );
1791    }
1792
1793    #[test]
1794    fn test_config_version_1_ok() {
1795        let toml = r#"
1796config_version = 1
1797
1798[profiles]
1799default = "balanced"
1800"#;
1801        let config: RmaTomlConfig = toml::from_str(toml).unwrap();
1802        assert_eq!(config.config_version, Some(1));
1803        assert!(config.has_version());
1804        assert_eq!(config.effective_version(), 1);
1805
1806        let warnings = config.validate();
1807        assert!(
1808            !warnings
1809                .iter()
1810                .any(|w| w.message.contains("config_version"))
1811        );
1812    }
1813
1814    #[test]
1815    fn test_config_version_999_fails() {
1816        let toml = r#"
1817config_version = 999
1818
1819[profiles]
1820default = "balanced"
1821"#;
1822        let config: RmaTomlConfig = toml::from_str(toml).unwrap();
1823        assert_eq!(config.config_version, Some(999));
1824
1825        let warnings = config.validate();
1826        let error = warnings.iter().find(|w| w.level == WarningLevel::Error);
1827        assert!(error.is_some());
1828        assert!(
1829            error
1830                .unwrap()
1831                .message
1832                .contains("Unsupported config version: 999")
1833        );
1834    }
1835
1836    #[test]
1837    fn test_default_toml_includes_version() {
1838        let toml = RmaTomlConfig::default_toml(Profile::Balanced);
1839        assert!(toml.contains("config_version = 1"));
1840
1841        // Verify it parses correctly
1842        let config: RmaTomlConfig = toml::from_str(&toml).unwrap();
1843        assert_eq!(config.config_version, Some(1));
1844    }
1845
1846    #[test]
1847    fn test_ruleset_security() {
1848        let toml = r#"
1849config_version = 1
1850
1851[rulesets]
1852security = ["js/innerhtml-xss", "js/timer-string-eval"]
1853maintainability = ["generic/long-function"]
1854
1855[rules]
1856enable = ["*"]
1857"#;
1858        let config: RmaTomlConfig = toml::from_str(toml).unwrap();
1859
1860        // With security ruleset, only security rules are enabled
1861        assert!(config.is_rule_enabled_with_ruleset("js/innerhtml-xss", Some("security")));
1862        assert!(config.is_rule_enabled_with_ruleset("js/timer-string-eval", Some("security")));
1863        assert!(!config.is_rule_enabled_with_ruleset("generic/long-function", Some("security")));
1864
1865        // Without ruleset, normal enable/disable applies
1866        assert!(config.is_rule_enabled("generic/long-function"));
1867    }
1868
1869    #[test]
1870    fn test_ruleset_with_disable() {
1871        let toml = r#"
1872config_version = 1
1873
1874[rulesets]
1875security = ["js/innerhtml-xss", "js/timer-string-eval"]
1876
1877[rules]
1878enable = ["*"]
1879disable = ["js/timer-string-eval"]
1880"#;
1881        let config: RmaTomlConfig = toml::from_str(toml).unwrap();
1882
1883        // Disable takes precedence even with ruleset
1884        assert!(config.is_rule_enabled_with_ruleset("js/innerhtml-xss", Some("security")));
1885        assert!(!config.is_rule_enabled_with_ruleset("js/timer-string-eval", Some("security")));
1886    }
1887
1888    #[test]
1889    fn test_get_ruleset_names() {
1890        let toml = r#"
1891config_version = 1
1892
1893[rulesets]
1894security = ["js/innerhtml-xss"]
1895maintainability = ["generic/long-function"]
1896"#;
1897        let config: RmaTomlConfig = toml::from_str(toml).unwrap();
1898        let names = config.get_ruleset_names();
1899
1900        assert!(names.contains(&"security".to_string()));
1901        assert!(names.contains(&"maintainability".to_string()));
1902    }
1903
1904    #[test]
1905    fn test_default_toml_includes_rulesets() {
1906        let toml = RmaTomlConfig::default_toml(Profile::Balanced);
1907        assert!(toml.contains("[rulesets]"));
1908        assert!(toml.contains("security = "));
1909        assert!(toml.contains("maintainability = "));
1910    }
1911
1912    #[test]
1913    fn test_inline_suppression_next_line() {
1914        let suppression = InlineSuppression::parse(
1915            "// rma-ignore-next-line js/innerhtml-xss reason=\"sanitized input\"",
1916            10,
1917        );
1918        assert!(suppression.is_some());
1919        let s = suppression.unwrap();
1920        assert_eq!(s.rule_id, "js/innerhtml-xss");
1921        assert_eq!(s.reason, Some("sanitized input".to_string()));
1922        assert_eq!(s.line, 10);
1923        assert_eq!(s.suppression_type, SuppressionType::NextLine);
1924
1925        // Should apply to line 11
1926        assert!(s.applies_to(11, "js/innerhtml-xss"));
1927        // Should NOT apply to line 12
1928        assert!(!s.applies_to(12, "js/innerhtml-xss"));
1929        // Should NOT apply to other rules
1930        assert!(!s.applies_to(11, "js/console-log"));
1931    }
1932
1933    #[test]
1934    fn test_inline_suppression_block() {
1935        let suppression = InlineSuppression::parse(
1936            "// rma-ignore generic/long-function reason=\"legacy code\"",
1937            5,
1938        );
1939        assert!(suppression.is_some());
1940        let s = suppression.unwrap();
1941        assert_eq!(s.rule_id, "generic/long-function");
1942        assert_eq!(s.suppression_type, SuppressionType::Block);
1943
1944        // Should apply to line 5 and beyond
1945        assert!(s.applies_to(5, "generic/long-function"));
1946        assert!(s.applies_to(10, "generic/long-function"));
1947        assert!(s.applies_to(100, "generic/long-function"));
1948    }
1949
1950    #[test]
1951    fn test_inline_suppression_without_reason() {
1952        let suppression = InlineSuppression::parse("// rma-ignore-next-line js/console-log", 1);
1953        assert!(suppression.is_some());
1954        let s = suppression.unwrap();
1955        assert_eq!(s.rule_id, "js/console-log");
1956        assert!(s.reason.is_none());
1957    }
1958
1959    #[test]
1960    fn test_inline_suppression_python_style() {
1961        let suppression = InlineSuppression::parse(
1962            "# rma-ignore-next-line python/hardcoded-secret reason=\"test data\"",
1963            3,
1964        );
1965        assert!(suppression.is_some());
1966        let s = suppression.unwrap();
1967        assert_eq!(s.rule_id, "python/hardcoded-secret");
1968        assert_eq!(s.reason, Some("test data".to_string()));
1969    }
1970
1971    #[test]
1972    fn test_inline_suppression_validation_strict() {
1973        let s = InlineSuppression {
1974            rule_id: "js/xss".to_string(),
1975            reason: None,
1976            line: 1,
1977            suppression_type: SuppressionType::NextLine,
1978        };
1979
1980        // Without reason, strict validation fails
1981        assert!(s.validate(true).is_err());
1982        // Without reason, non-strict validation passes
1983        assert!(s.validate(false).is_ok());
1984
1985        let s_with_reason = InlineSuppression {
1986            rule_id: "js/xss".to_string(),
1987            reason: Some("approved".to_string()),
1988            line: 1,
1989            suppression_type: SuppressionType::NextLine,
1990        };
1991
1992        // With reason, both pass
1993        assert!(s_with_reason.validate(true).is_ok());
1994        assert!(s_with_reason.validate(false).is_ok());
1995    }
1996
1997    #[test]
1998    fn test_parse_inline_suppressions() {
1999        let content = r#"
2000function foo() {
2001    // rma-ignore-next-line js/console-log reason="debugging"
2002    console.log("test");
2003
2004    // rma-ignore generic/long-function reason="complex algorithm"
2005    // ... lots of code ...
2006}
2007"#;
2008        let suppressions = parse_inline_suppressions(content);
2009        assert_eq!(suppressions.len(), 2);
2010        assert_eq!(suppressions[0].rule_id, "js/console-log");
2011        assert_eq!(suppressions[1].rule_id, "generic/long-function");
2012    }
2013
2014    #[test]
2015    fn test_suppression_does_not_affect_other_rules() {
2016        let suppression = InlineSuppression::parse(
2017            "// rma-ignore-next-line js/innerhtml-xss reason=\"safe\"",
2018            10,
2019        )
2020        .unwrap();
2021
2022        // Applies to the specific rule
2023        assert!(suppression.applies_to(11, "js/innerhtml-xss"));
2024        // Does NOT apply to other rules
2025        assert!(!suppression.applies_to(11, "js/console-log"));
2026        assert!(!suppression.applies_to(11, "generic/long-function"));
2027    }
2028}