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