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