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/// Baseline configuration
281#[derive(Debug, Clone, Serialize, Deserialize, Default)]
282pub struct BaselineConfig {
283    /// Path to baseline file
284    #[serde(default = "default_baseline_file")]
285    pub file: PathBuf,
286
287    /// Baseline mode
288    #[serde(default)]
289    pub mode: BaselineMode,
290}
291
292fn default_baseline_file() -> PathBuf {
293    PathBuf::from(".rma/baseline.json")
294}
295
296/// Current supported config version
297pub const CURRENT_CONFIG_VERSION: u32 = 1;
298
299/// Complete RMA TOML configuration
300#[derive(Debug, Clone, Serialize, Deserialize, Default)]
301pub struct RmaTomlConfig {
302    /// Config format version (for future compatibility)
303    #[serde(default)]
304    pub config_version: Option<u32>,
305
306    /// Scan path configuration
307    #[serde(default)]
308    pub scan: ScanConfig,
309
310    /// Rules configuration
311    #[serde(default)]
312    pub rules: RulesConfig,
313
314    /// Rulesets configuration (named groups of rules)
315    #[serde(default)]
316    pub rulesets: RulesetsConfig,
317
318    /// Profiles configuration
319    #[serde(default)]
320    pub profiles: ProfilesConfig,
321
322    /// Severity overrides by rule ID
323    #[serde(default)]
324    pub severity: HashMap<String, Severity>,
325
326    /// Path-specific threshold overrides
327    #[serde(default)]
328    pub threshold_overrides: Vec<ThresholdOverride>,
329
330    /// Allowlist configuration
331    #[serde(default)]
332    pub allow: AllowConfig,
333
334    /// Baseline configuration
335    #[serde(default)]
336    pub baseline: BaselineConfig,
337}
338
339/// Result of loading a config file
340#[derive(Debug)]
341pub struct ConfigLoadResult {
342    /// The loaded configuration
343    pub config: RmaTomlConfig,
344    /// Warning if version was missing
345    pub version_warning: Option<String>,
346}
347
348impl RmaTomlConfig {
349    /// Load configuration from file with version validation
350    pub fn load(path: &Path) -> Result<Self, String> {
351        let result = Self::load_with_validation(path)?;
352        Ok(result.config)
353    }
354
355    /// Load configuration from file with full validation info
356    pub fn load_with_validation(path: &Path) -> Result<ConfigLoadResult, String> {
357        let content = std::fs::read_to_string(path)
358            .map_err(|e| format!("Failed to read config file: {}", e))?;
359
360        let config: RmaTomlConfig =
361            toml::from_str(&content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
362
363        // Validate version
364        let version_warning = config.validate_version()?;
365
366        Ok(ConfigLoadResult {
367            config,
368            version_warning,
369        })
370    }
371
372    /// Validate config version, returns warning message if version is missing
373    fn validate_version(&self) -> Result<Option<String>, String> {
374        match self.config_version {
375            Some(CURRENT_CONFIG_VERSION) => Ok(None),
376            Some(version) if version > CURRENT_CONFIG_VERSION => Err(format!(
377                "Unsupported config version: {}. Maximum supported version is {}. \
378                     Please upgrade RMA or use a compatible config format.",
379                version, CURRENT_CONFIG_VERSION
380            )),
381            Some(version) => {
382                // Version 0 or any future "older than current" version
383                Err(format!(
384                    "Invalid config version: {}. Expected version {}.",
385                    version, CURRENT_CONFIG_VERSION
386                ))
387            }
388            None => Ok(Some(
389                "Config file is missing 'config_version'. Assuming version 1. \
390                 Add 'config_version = 1' to suppress this warning."
391                    .to_string(),
392            )),
393        }
394    }
395
396    /// Check if config version is present
397    pub fn has_version(&self) -> bool {
398        self.config_version.is_some()
399    }
400
401    /// Get the effective config version (defaults to 1 if missing)
402    pub fn effective_version(&self) -> u32 {
403        self.config_version.unwrap_or(CURRENT_CONFIG_VERSION)
404    }
405
406    /// Find and load configuration from standard locations
407    pub fn discover(start_path: &Path) -> Option<(PathBuf, Self)> {
408        let candidates = [
409            start_path.join("rma.toml"),
410            start_path.join(".rma/rma.toml"),
411            start_path.join(".rma.toml"),
412        ];
413
414        for candidate in &candidates {
415            if candidate.exists()
416                && let Ok(config) = Self::load(candidate)
417            {
418                return Some((candidate.clone(), config));
419            }
420        }
421
422        // Check parent directories up to 5 levels
423        let mut current = start_path.to_path_buf();
424        for _ in 0..5 {
425            if let Some(parent) = current.parent() {
426                let config_path = parent.join("rma.toml");
427                if config_path.exists()
428                    && let Ok(config) = Self::load(&config_path)
429                {
430                    return Some((config_path, config));
431                }
432                current = parent.to_path_buf();
433            } else {
434                break;
435            }
436        }
437
438        None
439    }
440
441    /// Validate configuration for errors and conflicts
442    pub fn validate(&self) -> Vec<ConfigWarning> {
443        let mut warnings = Vec::new();
444
445        // Check config version
446        if self.config_version.is_none() {
447            warnings.push(ConfigWarning {
448                level: WarningLevel::Warning,
449                message: "Missing 'config_version'. Add 'config_version = 1' to your config file."
450                    .to_string(),
451            });
452        } else if let Some(version) = self.config_version
453            && version > CURRENT_CONFIG_VERSION
454        {
455            warnings.push(ConfigWarning {
456                level: WarningLevel::Error,
457                message: format!(
458                    "Unsupported config version: {}. Maximum supported is {}.",
459                    version, CURRENT_CONFIG_VERSION
460                ),
461            });
462        }
463
464        // Check for conflicting enable/disable rules
465        for disabled in &self.rules.disable {
466            for enabled in &self.rules.enable {
467                if enabled == disabled {
468                    warnings.push(ConfigWarning {
469                        level: WarningLevel::Warning,
470                        message: format!(
471                            "Rule '{}' is both enabled and disabled (disable takes precedence)",
472                            disabled
473                        ),
474                    });
475                }
476            }
477        }
478
479        // Check threshold overrides have valid patterns
480        for (i, override_) in self.threshold_overrides.iter().enumerate() {
481            if override_.path.is_empty() {
482                warnings.push(ConfigWarning {
483                    level: WarningLevel::Error,
484                    message: format!("threshold_overrides[{}]: path cannot be empty", i),
485                });
486            }
487        }
488
489        // Check baseline file path
490        if self.baseline.mode == BaselineMode::NewOnly && !self.baseline.file.exists() {
491            warnings.push(ConfigWarning {
492                level: WarningLevel::Warning,
493                message: format!(
494                    "Baseline mode is 'new-only' but baseline file '{}' does not exist. Run 'rma baseline' first.",
495                    self.baseline.file.display()
496                ),
497            });
498        }
499
500        // Check severity overrides reference valid severities
501        for rule_id in self.severity.keys() {
502            if rule_id.is_empty() {
503                warnings.push(ConfigWarning {
504                    level: WarningLevel::Error,
505                    message: "Empty rule ID in severity overrides".to_string(),
506                });
507            }
508        }
509
510        warnings
511    }
512
513    /// Check if a rule is enabled (without ruleset filtering)
514    pub fn is_rule_enabled(&self, rule_id: &str) -> bool {
515        self.is_rule_enabled_with_ruleset(rule_id, None)
516    }
517
518    /// Check if a rule is enabled with optional ruleset filter
519    ///
520    /// If a ruleset is specified, only rules in that ruleset are considered enabled.
521    /// Explicit disable always takes precedence.
522    pub fn is_rule_enabled_with_ruleset(&self, rule_id: &str, ruleset: Option<&str>) -> bool {
523        // Check if explicitly disabled - always takes precedence
524        for pattern in &self.rules.disable {
525            if Self::matches_pattern(rule_id, pattern) {
526                return false;
527            }
528        }
529
530        // If a ruleset is specified, check if rule is in that ruleset
531        if let Some(ruleset_name) = ruleset {
532            let ruleset_rules = self.get_ruleset_rules(ruleset_name);
533            if !ruleset_rules.is_empty() {
534                // Rule must be in the ruleset to be enabled
535                return ruleset_rules
536                    .iter()
537                    .any(|r| Self::matches_pattern(rule_id, r));
538            }
539        }
540
541        // Check if explicitly enabled
542        for pattern in &self.rules.enable {
543            if Self::matches_pattern(rule_id, pattern) {
544                return true;
545            }
546        }
547
548        false
549    }
550
551    /// Get rules for a named ruleset
552    pub fn get_ruleset_rules(&self, name: &str) -> Vec<String> {
553        match name {
554            "security" => self.rulesets.security.clone(),
555            "maintainability" => self.rulesets.maintainability.clone(),
556            _ => self.rulesets.custom.get(name).cloned().unwrap_or_default(),
557        }
558    }
559
560    /// Get all available ruleset names
561    pub fn get_ruleset_names(&self) -> Vec<String> {
562        let mut names = Vec::new();
563        if !self.rulesets.security.is_empty() {
564            names.push("security".to_string());
565        }
566        if !self.rulesets.maintainability.is_empty() {
567            names.push("maintainability".to_string());
568        }
569        names.extend(self.rulesets.custom.keys().cloned());
570        names
571    }
572
573    /// Get severity override for a rule
574    pub fn get_severity_override(&self, rule_id: &str) -> Option<Severity> {
575        self.severity.get(rule_id).copied()
576    }
577
578    /// Get thresholds for a path, applying overrides
579    pub fn get_thresholds_for_path(&self, path: &Path, profile: Profile) -> ProfileThresholds {
580        let mut thresholds = self.profiles.get_thresholds(profile).clone();
581
582        // Apply path-specific overrides
583        let path_str = path.to_string_lossy();
584        for override_ in &self.threshold_overrides {
585            if Self::matches_glob(&path_str, &override_.path) {
586                if let Some(v) = override_.max_function_lines {
587                    thresholds.max_function_lines = v;
588                }
589                if let Some(v) = override_.max_complexity {
590                    thresholds.max_complexity = v;
591                }
592                if let Some(v) = override_.max_cognitive_complexity {
593                    thresholds.max_cognitive_complexity = v;
594                }
595            }
596        }
597
598        thresholds
599    }
600
601    /// Check if a path is allowed for a specific rule
602    pub fn is_path_allowed(&self, path: &Path, rule_type: AllowType) -> bool {
603        let path_str = path.to_string_lossy();
604        let allowed_paths = match rule_type {
605            AllowType::InnerHtml => &self.allow.innerhtml_paths,
606            AllowType::Eval => &self.allow.eval_paths,
607            AllowType::UnsafeRust => &self.allow.unsafe_rust_paths,
608        };
609
610        for pattern in allowed_paths {
611            if Self::matches_glob(&path_str, pattern) {
612                return true;
613            }
614        }
615
616        false
617    }
618
619    fn matches_pattern(rule_id: &str, pattern: &str) -> bool {
620        if pattern == "*" {
621            return true;
622        }
623
624        if let Some(prefix) = pattern.strip_suffix("/*") {
625            return rule_id.starts_with(prefix);
626        }
627
628        rule_id == pattern
629    }
630
631    fn matches_glob(path: &str, pattern: &str) -> bool {
632        // Simple glob matching (supports * and **)
633        let pattern = pattern
634            .replace("**", "§")
635            .replace('*', "[^/]*")
636            .replace('§', ".*");
637        regex::Regex::new(&format!("^{}$", pattern))
638            .map(|re| re.is_match(path))
639            .unwrap_or(false)
640    }
641
642    /// Generate default configuration as TOML string
643    pub fn default_toml(profile: Profile) -> String {
644        let thresholds = ProfileThresholds::for_profile(profile);
645
646        format!(
647            r#"# RMA Configuration
648# Documentation: https://github.com/bumahkib7/rust-monorepo-analyzer
649
650# Config format version (required for future compatibility)
651config_version = 1
652
653[scan]
654# Paths to include in scanning (default: all supported files)
655include = ["src/**", "lib/**", "scripts/**"]
656
657# Paths to exclude from scanning
658exclude = [
659    "node_modules/**",
660    "target/**",
661    "dist/**",
662    "build/**",
663    "vendor/**",
664    "**/*.min.js",
665    "**/*.bundle.js",
666]
667
668# Maximum file size to scan (10MB default)
669max_file_size = 10485760
670
671[rules]
672# Rules to enable (wildcards supported)
673enable = ["*"]
674
675# Rules to disable (takes precedence over enable)
676disable = []
677
678[profiles]
679# Default profile: fast, balanced, or strict
680default = "{profile}"
681
682[profiles.fast]
683max_function_lines = 200
684max_complexity = 25
685max_cognitive_complexity = 35
686max_file_lines = 2000
687
688[profiles.balanced]
689max_function_lines = {max_function_lines}
690max_complexity = {max_complexity}
691max_cognitive_complexity = {max_cognitive_complexity}
692max_file_lines = 1000
693
694[profiles.strict]
695max_function_lines = 50
696max_complexity = 10
697max_cognitive_complexity = 15
698max_file_lines = 500
699
700[rulesets]
701# Named rule groups for targeted scanning
702security = ["js/innerhtml-xss", "js/timer-string-eval", "js/dynamic-code-execution", "rust/unsafe-block", "python/shell-injection"]
703maintainability = ["generic/long-function", "generic/high-complexity", "js/console-log"]
704
705[severity]
706# Override severity for specific rules
707# "generic/long-function" = "warning"
708# "js/innerhtml-xss" = "error"
709# "rust/unsafe-block" = "warning"
710
711# [[threshold_overrides]]
712# path = "src/legacy/**"
713# max_function_lines = 300
714# max_complexity = 30
715
716# [[threshold_overrides]]
717# path = "tests/**"
718# disable_rules = ["generic/long-function"]
719
720[allow]
721# Approved patterns that won't trigger alerts
722settimeout_string = false
723settimeout_function = true
724innerhtml_paths = []
725eval_paths = []
726unsafe_rust_paths = []
727approved_secrets = []
728
729[baseline]
730# Baseline file for tracking legacy issues
731file = ".rma/baseline.json"
732# Mode: "all" or "new-only"
733mode = "all"
734"#,
735            profile = profile,
736            max_function_lines = thresholds.max_function_lines,
737            max_complexity = thresholds.max_complexity,
738            max_cognitive_complexity = thresholds.max_cognitive_complexity,
739        )
740    }
741}
742
743/// Type of allowlist check
744#[derive(Debug, Clone, Copy)]
745pub enum AllowType {
746    InnerHtml,
747    Eval,
748    UnsafeRust,
749}
750
751/// Source of a configuration value (for precedence tracking)
752#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
753#[serde(rename_all = "kebab-case")]
754pub enum ConfigSource {
755    /// Built-in default value
756    Default,
757    /// From rma.toml configuration file
758    ConfigFile,
759    /// From CLI flag or environment variable
760    CliFlag,
761}
762
763impl std::fmt::Display for ConfigSource {
764    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
765        match self {
766            ConfigSource::Default => write!(f, "default"),
767            ConfigSource::ConfigFile => write!(f, "config-file"),
768            ConfigSource::CliFlag => write!(f, "cli-flag"),
769        }
770    }
771}
772
773/// Effective (resolved) configuration after applying precedence
774///
775/// Precedence order (highest to lowest):
776/// 1. CLI flags (--config, --profile, --baseline-mode)
777/// 2. rma.toml in repo root (or explicit --config path)
778/// 3. Built-in defaults
779#[derive(Debug, Clone, Serialize, Deserialize)]
780pub struct EffectiveConfig {
781    /// Source of the configuration file (if any)
782    pub config_file: Option<PathBuf>,
783
784    /// Active profile
785    pub profile: Profile,
786
787    /// Where the profile came from
788    pub profile_source: ConfigSource,
789
790    /// Resolved thresholds
791    pub thresholds: ProfileThresholds,
792
793    /// Number of enabled rules
794    pub enabled_rules_count: usize,
795
796    /// Number of disabled rules
797    pub disabled_rules_count: usize,
798
799    /// Number of severity overrides
800    pub severity_overrides_count: usize,
801
802    /// Threshold overrides (paths in order)
803    pub threshold_override_paths: Vec<String>,
804
805    /// Baseline mode
806    pub baseline_mode: BaselineMode,
807
808    /// Where baseline mode came from
809    pub baseline_mode_source: ConfigSource,
810
811    /// Exclude patterns
812    pub exclude_patterns: Vec<String>,
813
814    /// Include patterns
815    pub include_patterns: Vec<String>,
816}
817
818impl EffectiveConfig {
819    /// Build effective config from sources with proper precedence
820    pub fn resolve(
821        toml_config: Option<&RmaTomlConfig>,
822        config_path: Option<&Path>,
823        cli_profile: Option<Profile>,
824        cli_baseline_mode: bool,
825    ) -> Self {
826        // Resolve profile: CLI > config > default
827        let (profile, profile_source) = if let Some(p) = cli_profile {
828            (p, ConfigSource::CliFlag)
829        } else if let Some(cfg) = toml_config {
830            (cfg.profiles.default, ConfigSource::ConfigFile)
831        } else {
832            (Profile::default(), ConfigSource::Default)
833        };
834
835        // Resolve baseline mode: CLI > config > default
836        let (baseline_mode, baseline_mode_source) = if cli_baseline_mode {
837            (BaselineMode::NewOnly, ConfigSource::CliFlag)
838        } else if let Some(cfg) = toml_config {
839            (cfg.baseline.mode, ConfigSource::ConfigFile)
840        } else {
841            (BaselineMode::default(), ConfigSource::Default)
842        };
843
844        // Get thresholds for profile
845        let thresholds = toml_config
846            .map(|cfg| cfg.profiles.get_thresholds(profile).clone())
847            .unwrap_or_else(|| ProfileThresholds::for_profile(profile));
848
849        // Count rules
850        let (enabled_rules_count, disabled_rules_count) = toml_config
851            .map(|cfg| (cfg.rules.enable.len(), cfg.rules.disable.len()))
852            .unwrap_or((1, 0)); // default: enable = ["*"]
853
854        // Severity overrides
855        let severity_overrides_count = toml_config.map(|cfg| cfg.severity.len()).unwrap_or(0);
856
857        // Threshold override paths
858        let threshold_override_paths = toml_config
859            .map(|cfg| {
860                cfg.threshold_overrides
861                    .iter()
862                    .map(|o| o.path.clone())
863                    .collect()
864            })
865            .unwrap_or_default();
866
867        // Patterns
868        let exclude_patterns = toml_config
869            .map(|cfg| cfg.scan.exclude.clone())
870            .unwrap_or_default();
871
872        let include_patterns = toml_config
873            .map(|cfg| cfg.scan.include.clone())
874            .unwrap_or_default();
875
876        Self {
877            config_file: config_path.map(|p| p.to_path_buf()),
878            profile,
879            profile_source,
880            thresholds,
881            enabled_rules_count,
882            disabled_rules_count,
883            severity_overrides_count,
884            threshold_override_paths,
885            baseline_mode,
886            baseline_mode_source,
887            exclude_patterns,
888            include_patterns,
889        }
890    }
891
892    /// Format as human-readable text
893    pub fn to_text(&self) -> String {
894        let mut out = String::new();
895
896        out.push_str("Effective Configuration\n");
897        out.push_str("═══════════════════════════════════════════════════════════\n\n");
898
899        // Config file
900        out.push_str("  Config file:        ");
901        match &self.config_file {
902            Some(p) => out.push_str(&format!("{}\n", p.display())),
903            None => out.push_str("(none - using defaults)\n"),
904        }
905
906        // Profile
907        out.push_str(&format!(
908            "  Profile:            {} (from {})\n",
909            self.profile, self.profile_source
910        ));
911
912        // Thresholds
913        out.push_str("\n  Thresholds:\n");
914        out.push_str(&format!(
915            "    max_function_lines:     {}\n",
916            self.thresholds.max_function_lines
917        ));
918        out.push_str(&format!(
919            "    max_complexity:         {}\n",
920            self.thresholds.max_complexity
921        ));
922        out.push_str(&format!(
923            "    max_cognitive_complexity: {}\n",
924            self.thresholds.max_cognitive_complexity
925        ));
926        out.push_str(&format!(
927            "    max_file_lines:         {}\n",
928            self.thresholds.max_file_lines
929        ));
930
931        // Rules
932        out.push_str("\n  Rules:\n");
933        out.push_str(&format!(
934            "    enabled patterns:       {}\n",
935            self.enabled_rules_count
936        ));
937        out.push_str(&format!(
938            "    disabled patterns:      {}\n",
939            self.disabled_rules_count
940        ));
941        out.push_str(&format!(
942            "    severity overrides:     {}\n",
943            self.severity_overrides_count
944        ));
945
946        // Threshold overrides
947        if !self.threshold_override_paths.is_empty() {
948            out.push_str("\n  Threshold overrides:\n");
949            for path in &self.threshold_override_paths {
950                out.push_str(&format!("    - {}\n", path));
951            }
952        }
953
954        // Baseline
955        out.push_str(&format!(
956            "\n  Baseline mode:      {:?} (from {})\n",
957            self.baseline_mode, self.baseline_mode_source
958        ));
959
960        out
961    }
962
963    /// Format as JSON
964    pub fn to_json(&self) -> Result<String, serde_json::Error> {
965        serde_json::to_string_pretty(self)
966    }
967}
968
969/// Configuration warning or error
970#[derive(Debug, Clone)]
971pub struct ConfigWarning {
972    pub level: WarningLevel,
973    pub message: String,
974}
975
976#[derive(Debug, Clone, Copy, PartialEq, Eq)]
977pub enum WarningLevel {
978    Warning,
979    Error,
980}
981
982/// Inline suppression comment parsed from source code
983#[derive(Debug, Clone, PartialEq, Eq)]
984pub struct InlineSuppression {
985    /// The rule ID to suppress
986    pub rule_id: String,
987    /// The reason for suppression (required in strict profile)
988    pub reason: Option<String>,
989    /// Line number where the suppression comment appears
990    pub line: usize,
991    /// Type of suppression
992    pub suppression_type: SuppressionType,
993}
994
995/// Type of inline suppression
996#[derive(Debug, Clone, Copy, PartialEq, Eq)]
997pub enum SuppressionType {
998    /// Suppresses the next line only
999    NextLine,
1000    /// Suppresses until end of block/function (or file-level until blank line)
1001    Block,
1002}
1003
1004impl InlineSuppression {
1005    /// Parse a suppression comment from a line of code
1006    ///
1007    /// Supported formats:
1008    /// - `// rma-ignore-next-line <rule_id> reason="<text>"`
1009    /// - `// rma-ignore <rule_id> reason="<text>"`
1010    /// - `# rma-ignore-next-line <rule_id> reason="<text>"` (Python)
1011    pub fn parse(line: &str, line_number: usize) -> Option<Self> {
1012        let trimmed = line.trim();
1013
1014        // Check for comment prefixes
1015        let comment_body = if let Some(rest) = trimmed.strip_prefix("//") {
1016            rest.trim()
1017        } else if let Some(rest) = trimmed.strip_prefix('#') {
1018            rest.trim()
1019        } else {
1020            return None;
1021        };
1022
1023        // Check for rma-ignore-next-line
1024        if let Some(rest) = comment_body.strip_prefix("rma-ignore-next-line") {
1025            return Self::parse_suppression_body(
1026                rest.trim(),
1027                line_number,
1028                SuppressionType::NextLine,
1029            );
1030        }
1031
1032        // Check for rma-ignore (block level)
1033        if let Some(rest) = comment_body.strip_prefix("rma-ignore") {
1034            return Self::parse_suppression_body(rest.trim(), line_number, SuppressionType::Block);
1035        }
1036
1037        None
1038    }
1039
1040    fn parse_suppression_body(
1041        body: &str,
1042        line_number: usize,
1043        suppression_type: SuppressionType,
1044    ) -> Option<Self> {
1045        if body.is_empty() {
1046            return None;
1047        }
1048
1049        // Parse: <rule_id> [reason="<text>"]
1050        let mut parts = body.splitn(2, ' ');
1051        let rule_id = parts.next()?.trim().to_string();
1052
1053        if rule_id.is_empty() {
1054            return None;
1055        }
1056
1057        let reason = parts.next().and_then(|rest| {
1058            // Look for reason="..."
1059            if let Some(start) = rest.find("reason=\"") {
1060                let after_quote = &rest[start + 8..];
1061                if let Some(end) = after_quote.find('"') {
1062                    return Some(after_quote[..end].to_string());
1063                }
1064            }
1065            None
1066        });
1067
1068        Some(Self {
1069            rule_id,
1070            reason,
1071            line: line_number,
1072            suppression_type,
1073        })
1074    }
1075
1076    /// Check if this suppression applies to a finding at the given line
1077    pub fn applies_to(&self, finding_line: usize, rule_id: &str) -> bool {
1078        if self.rule_id != rule_id && self.rule_id != "*" {
1079            return false;
1080        }
1081
1082        match self.suppression_type {
1083            SuppressionType::NextLine => finding_line == self.line + 1,
1084            SuppressionType::Block => finding_line >= self.line,
1085        }
1086    }
1087
1088    /// Validate suppression (check if reason is required and present)
1089    pub fn validate(&self, require_reason: bool) -> Result<(), String> {
1090        if require_reason && self.reason.is_none() {
1091            return Err(format!(
1092                "Suppression for '{}' at line {} requires a reason in strict profile",
1093                self.rule_id, self.line
1094            ));
1095        }
1096        Ok(())
1097    }
1098}
1099
1100/// Parse all inline suppressions from source code
1101pub fn parse_inline_suppressions(content: &str) -> Vec<InlineSuppression> {
1102    content
1103        .lines()
1104        .enumerate()
1105        .filter_map(|(i, line)| InlineSuppression::parse(line, i + 1))
1106        .collect()
1107}
1108
1109/// Stable fingerprint for a finding
1110///
1111/// Fingerprints are designed to survive:
1112/// - Line number changes (refactoring moves code)
1113/// - Minor whitespace changes
1114/// - Non-semantic message text changes
1115///
1116/// Fingerprint inputs (in order):
1117/// 1. rule_id (e.g., "js/innerhtml-xss")
1118/// 2. file path (normalized, unix separators)
1119/// 3. normalized snippet (trimmed, collapsed whitespace)
1120#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
1121pub struct Fingerprint(String);
1122
1123impl Fingerprint {
1124    /// Generate a stable fingerprint for a finding
1125    pub fn generate(rule_id: &str, file_path: &Path, snippet: &str) -> Self {
1126        use sha2::{Digest, Sha256};
1127
1128        let mut hasher = Sha256::new();
1129
1130        // 1. Rule ID
1131        hasher.update(rule_id.as_bytes());
1132        hasher.update(b"|");
1133
1134        // 2. Normalized file path (unix separators, lowercase for case-insensitive FS)
1135        let normalized_path = file_path
1136            .to_string_lossy()
1137            .replace('\\', "/")
1138            .to_lowercase();
1139        hasher.update(normalized_path.as_bytes());
1140        hasher.update(b"|");
1141
1142        // 3. Normalized snippet (collapse whitespace, trim)
1143        let normalized_snippet = Self::normalize_snippet(snippet);
1144        hasher.update(normalized_snippet.as_bytes());
1145
1146        let hash = hasher.finalize();
1147        Self(format!("sha256:{:x}", hash))
1148    }
1149
1150    /// Normalize snippet for stable fingerprinting
1151    fn normalize_snippet(snippet: &str) -> String {
1152        snippet
1153            .split_whitespace()
1154            .collect::<Vec<_>>()
1155            .join(" ")
1156            .trim()
1157            .to_string()
1158    }
1159
1160    /// Get the fingerprint string
1161    pub fn as_str(&self) -> &str {
1162        &self.0
1163    }
1164
1165    /// Create from existing fingerprint string
1166    pub fn from_string(s: String) -> Self {
1167        Self(s)
1168    }
1169}
1170
1171impl std::fmt::Display for Fingerprint {
1172    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1173        write!(f, "{}", self.0)
1174    }
1175}
1176
1177impl From<Fingerprint> for String {
1178    fn from(fp: Fingerprint) -> String {
1179        fp.0
1180    }
1181}
1182
1183/// Baseline entry for a finding
1184#[derive(Debug, Clone, Serialize, Deserialize)]
1185pub struct BaselineEntry {
1186    pub rule_id: String,
1187    pub file: PathBuf,
1188    #[serde(default)]
1189    pub line: usize,
1190    pub fingerprint: String,
1191    pub first_seen: String,
1192    #[serde(default)]
1193    pub suppressed: bool,
1194    #[serde(default)]
1195    pub comment: Option<String>,
1196}
1197
1198/// Baseline file containing known findings
1199#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1200pub struct Baseline {
1201    pub version: String,
1202    pub created: String,
1203    pub entries: Vec<BaselineEntry>,
1204}
1205
1206impl Baseline {
1207    pub fn new() -> Self {
1208        Self {
1209            version: "1.0".to_string(),
1210            created: chrono::Utc::now().to_rfc3339(),
1211            entries: Vec::new(),
1212        }
1213    }
1214
1215    pub fn load(path: &Path) -> Result<Self, String> {
1216        let content = std::fs::read_to_string(path)
1217            .map_err(|e| format!("Failed to read baseline file: {}", e))?;
1218
1219        serde_json::from_str(&content).map_err(|e| format!("Failed to parse baseline: {}", e))
1220    }
1221
1222    pub fn save(&self, path: &Path) -> Result<(), String> {
1223        if let Some(parent) = path.parent() {
1224            std::fs::create_dir_all(parent)
1225                .map_err(|e| format!("Failed to create directory: {}", e))?;
1226        }
1227
1228        let content = serde_json::to_string_pretty(self)
1229            .map_err(|e| format!("Failed to serialize baseline: {}", e))?;
1230
1231        std::fs::write(path, content).map_err(|e| format!("Failed to write baseline: {}", e))
1232    }
1233
1234    /// Check if a finding is in the baseline by fingerprint
1235    pub fn contains_fingerprint(&self, fingerprint: &Fingerprint) -> bool {
1236        self.entries
1237            .iter()
1238            .any(|e| e.fingerprint == fingerprint.as_str())
1239    }
1240
1241    /// Check if a finding is in the baseline (legacy method)
1242    pub fn contains(&self, rule_id: &str, file: &Path, fingerprint: &str) -> bool {
1243        self.entries
1244            .iter()
1245            .any(|e| e.rule_id == rule_id && e.file == file && e.fingerprint == fingerprint)
1246    }
1247
1248    /// Add a finding to the baseline using stable fingerprint
1249    pub fn add_with_fingerprint(
1250        &mut self,
1251        rule_id: String,
1252        file: PathBuf,
1253        line: usize,
1254        fingerprint: Fingerprint,
1255    ) {
1256        if !self.contains_fingerprint(&fingerprint) {
1257            self.entries.push(BaselineEntry {
1258                rule_id,
1259                file,
1260                line,
1261                fingerprint: fingerprint.into(),
1262                first_seen: chrono::Utc::now().to_rfc3339(),
1263                suppressed: false,
1264                comment: None,
1265            });
1266        }
1267    }
1268
1269    /// Add a finding to the baseline (legacy method)
1270    pub fn add(&mut self, rule_id: String, file: PathBuf, line: usize, fingerprint: String) {
1271        if !self.contains(&rule_id, &file, &fingerprint) {
1272            self.entries.push(BaselineEntry {
1273                rule_id,
1274                file,
1275                line,
1276                fingerprint,
1277                first_seen: chrono::Utc::now().to_rfc3339(),
1278                suppressed: false,
1279                comment: None,
1280            });
1281        }
1282    }
1283}
1284
1285#[cfg(test)]
1286mod tests {
1287    use super::*;
1288    use std::str::FromStr;
1289
1290    #[test]
1291    fn test_profile_parsing() {
1292        assert_eq!(Profile::from_str("fast").unwrap(), Profile::Fast);
1293        assert_eq!(Profile::from_str("balanced").unwrap(), Profile::Balanced);
1294        assert_eq!(Profile::from_str("strict").unwrap(), Profile::Strict);
1295        assert!(Profile::from_str("unknown").is_err());
1296    }
1297
1298    #[test]
1299    fn test_rule_matching() {
1300        assert!(RmaTomlConfig::matches_pattern("security/xss", "*"));
1301        assert!(RmaTomlConfig::matches_pattern("security/xss", "security/*"));
1302        assert!(!RmaTomlConfig::matches_pattern(
1303            "generic/long",
1304            "security/*"
1305        ));
1306        assert!(RmaTomlConfig::matches_pattern(
1307            "security/xss",
1308            "security/xss"
1309        ));
1310    }
1311
1312    #[test]
1313    fn test_default_config_parses() {
1314        let toml = RmaTomlConfig::default_toml(Profile::Balanced);
1315        let config: RmaTomlConfig = toml::from_str(&toml).expect("Default config should parse");
1316        assert_eq!(config.profiles.default, Profile::Balanced);
1317    }
1318
1319    #[test]
1320    fn test_thresholds_for_profile() {
1321        let fast = ProfileThresholds::for_profile(Profile::Fast);
1322        let strict = ProfileThresholds::for_profile(Profile::Strict);
1323
1324        assert!(fast.max_function_lines > strict.max_function_lines);
1325        assert!(fast.max_complexity > strict.max_complexity);
1326    }
1327
1328    #[test]
1329    fn test_fingerprint_stable_across_line_changes() {
1330        // Same finding at different line numbers should yield same fingerprint
1331        let fp1 = Fingerprint::generate(
1332            "js/xss-sink",
1333            Path::new("src/app.js"),
1334            "element.textContent = userInput;",
1335        );
1336        let fp2 = Fingerprint::generate(
1337            "js/xss-sink",
1338            Path::new("src/app.js"),
1339            "element.textContent = userInput;",
1340        );
1341
1342        assert_eq!(fp1, fp2);
1343    }
1344
1345    #[test]
1346    fn test_fingerprint_stable_with_whitespace_changes() {
1347        // Minor whitespace changes shouldn't affect fingerprint
1348        let fp1 = Fingerprint::generate(
1349            "generic/long-function",
1350            Path::new("src/utils.rs"),
1351            "fn very_long_function() {",
1352        );
1353        let fp2 = Fingerprint::generate(
1354            "generic/long-function",
1355            Path::new("src/utils.rs"),
1356            "fn   very_long_function()   {",
1357        );
1358        let fp3 = Fingerprint::generate(
1359            "generic/long-function",
1360            Path::new("src/utils.rs"),
1361            "  fn very_long_function() {  ",
1362        );
1363
1364        assert_eq!(fp1, fp2);
1365        assert_eq!(fp2, fp3);
1366    }
1367
1368    #[test]
1369    fn test_fingerprint_different_for_different_rules() {
1370        let fp1 = Fingerprint::generate("js/xss-sink", Path::new("src/app.js"), "element.x = val;");
1371        let fp2 = Fingerprint::generate("js/eval", Path::new("src/app.js"), "element.x = val;");
1372
1373        assert_ne!(fp1, fp2);
1374    }
1375
1376    #[test]
1377    fn test_fingerprint_different_for_different_files() {
1378        let fp1 = Fingerprint::generate("js/xss-sink", Path::new("src/app.js"), "element.x = val;");
1379        let fp2 =
1380            Fingerprint::generate("js/xss-sink", Path::new("src/other.js"), "element.x = val;");
1381
1382        assert_ne!(fp1, fp2);
1383    }
1384
1385    #[test]
1386    fn test_fingerprint_path_normalization() {
1387        // Windows and Unix paths should normalize to same fingerprint
1388        let fp1 = Fingerprint::generate("js/xss-sink", Path::new("src/components/App.js"), "x");
1389        let fp2 = Fingerprint::generate("js/xss-sink", Path::new("src\\components\\App.js"), "x");
1390
1391        assert_eq!(fp1, fp2);
1392    }
1393
1394    #[test]
1395    fn test_effective_config_precedence() {
1396        // Test CLI overrides config
1397        let toml_config = RmaTomlConfig::default();
1398        let effective = EffectiveConfig::resolve(
1399            Some(&toml_config),
1400            Some(Path::new("rma.toml")),
1401            Some(Profile::Strict), // CLI override
1402            false,
1403        );
1404
1405        assert_eq!(effective.profile, Profile::Strict);
1406        assert_eq!(effective.profile_source, ConfigSource::CliFlag);
1407    }
1408
1409    #[test]
1410    fn test_effective_config_defaults() {
1411        // No config, no CLI flags = defaults
1412        let effective = EffectiveConfig::resolve(None, None, None, false);
1413
1414        assert_eq!(effective.profile, Profile::Balanced);
1415        assert_eq!(effective.profile_source, ConfigSource::Default);
1416        assert!(effective.config_file.is_none());
1417    }
1418
1419    #[test]
1420    fn test_effective_config_from_file() {
1421        // Config file with no CLI override
1422        let mut toml_config = RmaTomlConfig::default();
1423        toml_config.profiles.default = Profile::Fast;
1424
1425        let effective =
1426            EffectiveConfig::resolve(Some(&toml_config), Some(Path::new("rma.toml")), None, false);
1427
1428        assert_eq!(effective.profile, Profile::Fast);
1429        assert_eq!(effective.profile_source, ConfigSource::ConfigFile);
1430    }
1431
1432    #[test]
1433    fn test_config_version_missing_warns() {
1434        let toml = r#"
1435[profiles]
1436default = "balanced"
1437"#;
1438        let config: RmaTomlConfig = toml::from_str(toml).unwrap();
1439        assert!(config.config_version.is_none());
1440        assert!(!config.has_version());
1441        assert_eq!(config.effective_version(), 1);
1442
1443        let warnings = config.validate();
1444        assert!(
1445            warnings
1446                .iter()
1447                .any(|w| w.message.contains("Missing 'config_version'"))
1448        );
1449    }
1450
1451    #[test]
1452    fn test_config_version_1_ok() {
1453        let toml = r#"
1454config_version = 1
1455
1456[profiles]
1457default = "balanced"
1458"#;
1459        let config: RmaTomlConfig = toml::from_str(toml).unwrap();
1460        assert_eq!(config.config_version, Some(1));
1461        assert!(config.has_version());
1462        assert_eq!(config.effective_version(), 1);
1463
1464        let warnings = config.validate();
1465        assert!(
1466            !warnings
1467                .iter()
1468                .any(|w| w.message.contains("config_version"))
1469        );
1470    }
1471
1472    #[test]
1473    fn test_config_version_999_fails() {
1474        let toml = r#"
1475config_version = 999
1476
1477[profiles]
1478default = "balanced"
1479"#;
1480        let config: RmaTomlConfig = toml::from_str(toml).unwrap();
1481        assert_eq!(config.config_version, Some(999));
1482
1483        let warnings = config.validate();
1484        let error = warnings.iter().find(|w| w.level == WarningLevel::Error);
1485        assert!(error.is_some());
1486        assert!(
1487            error
1488                .unwrap()
1489                .message
1490                .contains("Unsupported config version: 999")
1491        );
1492    }
1493
1494    #[test]
1495    fn test_default_toml_includes_version() {
1496        let toml = RmaTomlConfig::default_toml(Profile::Balanced);
1497        assert!(toml.contains("config_version = 1"));
1498
1499        // Verify it parses correctly
1500        let config: RmaTomlConfig = toml::from_str(&toml).unwrap();
1501        assert_eq!(config.config_version, Some(1));
1502    }
1503
1504    #[test]
1505    fn test_ruleset_security() {
1506        let toml = r#"
1507config_version = 1
1508
1509[rulesets]
1510security = ["js/innerhtml-xss", "js/timer-string-eval"]
1511maintainability = ["generic/long-function"]
1512
1513[rules]
1514enable = ["*"]
1515"#;
1516        let config: RmaTomlConfig = toml::from_str(toml).unwrap();
1517
1518        // With security ruleset, only security rules are enabled
1519        assert!(config.is_rule_enabled_with_ruleset("js/innerhtml-xss", Some("security")));
1520        assert!(config.is_rule_enabled_with_ruleset("js/timer-string-eval", Some("security")));
1521        assert!(!config.is_rule_enabled_with_ruleset("generic/long-function", Some("security")));
1522
1523        // Without ruleset, normal enable/disable applies
1524        assert!(config.is_rule_enabled("generic/long-function"));
1525    }
1526
1527    #[test]
1528    fn test_ruleset_with_disable() {
1529        let toml = r#"
1530config_version = 1
1531
1532[rulesets]
1533security = ["js/innerhtml-xss", "js/timer-string-eval"]
1534
1535[rules]
1536enable = ["*"]
1537disable = ["js/timer-string-eval"]
1538"#;
1539        let config: RmaTomlConfig = toml::from_str(toml).unwrap();
1540
1541        // Disable takes precedence even with ruleset
1542        assert!(config.is_rule_enabled_with_ruleset("js/innerhtml-xss", Some("security")));
1543        assert!(!config.is_rule_enabled_with_ruleset("js/timer-string-eval", Some("security")));
1544    }
1545
1546    #[test]
1547    fn test_get_ruleset_names() {
1548        let toml = r#"
1549config_version = 1
1550
1551[rulesets]
1552security = ["js/innerhtml-xss"]
1553maintainability = ["generic/long-function"]
1554"#;
1555        let config: RmaTomlConfig = toml::from_str(toml).unwrap();
1556        let names = config.get_ruleset_names();
1557
1558        assert!(names.contains(&"security".to_string()));
1559        assert!(names.contains(&"maintainability".to_string()));
1560    }
1561
1562    #[test]
1563    fn test_default_toml_includes_rulesets() {
1564        let toml = RmaTomlConfig::default_toml(Profile::Balanced);
1565        assert!(toml.contains("[rulesets]"));
1566        assert!(toml.contains("security = "));
1567        assert!(toml.contains("maintainability = "));
1568    }
1569
1570    #[test]
1571    fn test_inline_suppression_next_line() {
1572        let suppression = InlineSuppression::parse(
1573            "// rma-ignore-next-line js/innerhtml-xss reason=\"sanitized input\"",
1574            10,
1575        );
1576        assert!(suppression.is_some());
1577        let s = suppression.unwrap();
1578        assert_eq!(s.rule_id, "js/innerhtml-xss");
1579        assert_eq!(s.reason, Some("sanitized input".to_string()));
1580        assert_eq!(s.line, 10);
1581        assert_eq!(s.suppression_type, SuppressionType::NextLine);
1582
1583        // Should apply to line 11
1584        assert!(s.applies_to(11, "js/innerhtml-xss"));
1585        // Should NOT apply to line 12
1586        assert!(!s.applies_to(12, "js/innerhtml-xss"));
1587        // Should NOT apply to other rules
1588        assert!(!s.applies_to(11, "js/console-log"));
1589    }
1590
1591    #[test]
1592    fn test_inline_suppression_block() {
1593        let suppression = InlineSuppression::parse(
1594            "// rma-ignore generic/long-function reason=\"legacy code\"",
1595            5,
1596        );
1597        assert!(suppression.is_some());
1598        let s = suppression.unwrap();
1599        assert_eq!(s.rule_id, "generic/long-function");
1600        assert_eq!(s.suppression_type, SuppressionType::Block);
1601
1602        // Should apply to line 5 and beyond
1603        assert!(s.applies_to(5, "generic/long-function"));
1604        assert!(s.applies_to(10, "generic/long-function"));
1605        assert!(s.applies_to(100, "generic/long-function"));
1606    }
1607
1608    #[test]
1609    fn test_inline_suppression_without_reason() {
1610        let suppression = InlineSuppression::parse("// rma-ignore-next-line js/console-log", 1);
1611        assert!(suppression.is_some());
1612        let s = suppression.unwrap();
1613        assert_eq!(s.rule_id, "js/console-log");
1614        assert!(s.reason.is_none());
1615    }
1616
1617    #[test]
1618    fn test_inline_suppression_python_style() {
1619        let suppression = InlineSuppression::parse(
1620            "# rma-ignore-next-line python/hardcoded-secret reason=\"test data\"",
1621            3,
1622        );
1623        assert!(suppression.is_some());
1624        let s = suppression.unwrap();
1625        assert_eq!(s.rule_id, "python/hardcoded-secret");
1626        assert_eq!(s.reason, Some("test data".to_string()));
1627    }
1628
1629    #[test]
1630    fn test_inline_suppression_validation_strict() {
1631        let s = InlineSuppression {
1632            rule_id: "js/xss".to_string(),
1633            reason: None,
1634            line: 1,
1635            suppression_type: SuppressionType::NextLine,
1636        };
1637
1638        // Without reason, strict validation fails
1639        assert!(s.validate(true).is_err());
1640        // Without reason, non-strict validation passes
1641        assert!(s.validate(false).is_ok());
1642
1643        let s_with_reason = InlineSuppression {
1644            rule_id: "js/xss".to_string(),
1645            reason: Some("approved".to_string()),
1646            line: 1,
1647            suppression_type: SuppressionType::NextLine,
1648        };
1649
1650        // With reason, both pass
1651        assert!(s_with_reason.validate(true).is_ok());
1652        assert!(s_with_reason.validate(false).is_ok());
1653    }
1654
1655    #[test]
1656    fn test_parse_inline_suppressions() {
1657        let content = r#"
1658function foo() {
1659    // rma-ignore-next-line js/console-log reason="debugging"
1660    console.log("test");
1661
1662    // rma-ignore generic/long-function reason="complex algorithm"
1663    // ... lots of code ...
1664}
1665"#;
1666        let suppressions = parse_inline_suppressions(content);
1667        assert_eq!(suppressions.len(), 2);
1668        assert_eq!(suppressions[0].rule_id, "js/console-log");
1669        assert_eq!(suppressions[1].rule_id, "generic/long-function");
1670    }
1671
1672    #[test]
1673    fn test_suppression_does_not_affect_other_rules() {
1674        let suppression = InlineSuppression::parse(
1675            "// rma-ignore-next-line js/innerhtml-xss reason=\"safe\"",
1676            10,
1677        )
1678        .unwrap();
1679
1680        // Applies to the specific rule
1681        assert!(suppression.applies_to(11, "js/innerhtml-xss"));
1682        // Does NOT apply to other rules
1683        assert!(!suppression.applies_to(11, "js/console-log"));
1684        assert!(!suppression.applies_to(11, "generic/long-function"));
1685    }
1686}