Skip to main content

cc_audit/
config.rs

1use crate::malware_db::MalwareSignature;
2use crate::rules::RuleSeverity;
3use crate::rules::custom::YamlRule;
4use serde::{Deserialize, Serialize};
5use std::collections::HashSet;
6use std::fs;
7use std::path::Path;
8
9/// Main configuration structure for cc-audit
10#[derive(Debug, Clone, Default, Serialize, Deserialize)]
11#[serde(default)]
12pub struct Config {
13    /// Scan configuration (CLI options)
14    pub scan: ScanConfig,
15    /// Watch mode configuration
16    pub watch: WatchConfig,
17    /// Text file detection configuration
18    pub text_files: TextFilesConfig,
19    /// Ignore configuration for scanning
20    pub ignore: IgnoreConfig,
21    /// Baseline configuration for drift detection
22    #[serde(default)]
23    pub baseline: BaselineConfig,
24    /// Rule severity configuration (v0.5.0)
25    #[serde(default)]
26    pub severity: SeverityConfig,
27    /// Rule IDs to disable
28    #[serde(default)]
29    pub disabled_rules: HashSet<String>,
30    /// Custom rules defined in config file
31    #[serde(default)]
32    pub rules: Vec<YamlRule>,
33    /// Custom malware signatures defined in config file
34    #[serde(default)]
35    pub malware_signatures: Vec<MalwareSignature>,
36}
37
38/// Rule severity configuration - controls how findings affect CI exit code.
39///
40/// Priority: ignore > warn > default
41///
42/// Example:
43/// ```yaml
44/// severity:
45///   default: error      # All rules are errors by default
46///   warn:
47///     - PI-001          # Treat as warning only
48///     - PI-002
49///   ignore:
50///     - OP-001          # Completely ignore
51/// ```
52#[derive(Debug, Clone, Serialize, Deserialize)]
53#[serde(default)]
54pub struct SeverityConfig {
55    /// Default severity for all rules (error by default)
56    pub default: RuleSeverity,
57    /// Rule IDs to treat as warnings (report only, exit 0)
58    #[serde(default)]
59    pub warn: HashSet<String>,
60    /// Rule IDs to ignore completely (no report)
61    /// Note: These are merged with disabled_rules
62    #[serde(default)]
63    pub ignore: HashSet<String>,
64}
65
66impl Default for SeverityConfig {
67    fn default() -> Self {
68        Self {
69            default: RuleSeverity::Error,
70            warn: HashSet::new(),
71            ignore: HashSet::new(),
72        }
73    }
74}
75
76impl SeverityConfig {
77    /// Get the effective RuleSeverity for a rule ID.
78    /// Returns None if the rule should be ignored.
79    pub fn get_rule_severity(&self, rule_id: &str) -> Option<RuleSeverity> {
80        // Priority: ignore > warn > default
81        if self.ignore.contains(rule_id) {
82            return None; // Ignore this rule
83        }
84        if self.warn.contains(rule_id) {
85            return Some(RuleSeverity::Warn);
86        }
87        Some(self.default)
88    }
89}
90
91/// Scan configuration (corresponds to CLI options)
92#[derive(Debug, Clone, Default, Serialize, Deserialize)]
93#[serde(default)]
94pub struct ScanConfig {
95    /// Output format: "terminal", "json", "sarif", "html"
96    pub format: Option<String>,
97    /// Strict mode: show medium/low severity findings and treat warnings as errors
98    pub strict: bool,
99    /// Scan type: "skill", "hook", "mcp", "command", "rules", "docker", "dependency", "subagent", "plugin"
100    pub scan_type: Option<String>,
101    /// Recursive scan
102    pub recursive: bool,
103    /// CI mode: non-interactive output
104    pub ci: bool,
105    /// Verbose output
106    pub verbose: bool,
107    /// Minimum confidence level: "tentative", "firm", "certain"
108    pub min_confidence: Option<String>,
109    /// Skip comment lines when scanning
110    pub skip_comments: bool,
111    /// Show fix hints in terminal output
112    pub fix_hint: bool,
113    /// Disable malware signature scanning
114    pub no_malware_scan: bool,
115    /// Watch mode: continuously monitor files for changes
116    pub watch: bool,
117    /// Path to a custom malware signatures database (JSON)
118    pub malware_db: Option<String>,
119    /// Path to a custom rules file (YAML format)
120    pub custom_rules: Option<String>,
121    /// Output file path (for HTML/JSON/SARIF output)
122    pub output: Option<String>,
123    /// Enable deep scan with deobfuscation
124    pub deep_scan: bool,
125    /// Auto-fix issues (where possible)
126    pub fix: bool,
127    /// Preview auto-fix changes without applying them
128    pub fix_dry_run: bool,
129}
130
131impl Config {
132    /// Load configuration from a file
133    pub fn from_file(path: &Path) -> Result<Self, ConfigError> {
134        let content = fs::read_to_string(path).map_err(|e| ConfigError::ReadFile {
135            path: path.display().to_string(),
136            source: e,
137        })?;
138
139        let ext = path
140            .extension()
141            .and_then(|e| e.to_str())
142            .unwrap_or("")
143            .to_lowercase();
144
145        match ext.as_str() {
146            "yaml" | "yml" => serde_yaml::from_str(&content).map_err(|e| ConfigError::ParseYaml {
147                path: path.display().to_string(),
148                source: e,
149            }),
150            "json" => serde_json::from_str(&content).map_err(|e| ConfigError::ParseJson {
151                path: path.display().to_string(),
152                source: e,
153            }),
154            "toml" => toml::from_str(&content).map_err(|e| ConfigError::ParseToml {
155                path: path.display().to_string(),
156                source: e,
157            }),
158            _ => Err(ConfigError::UnsupportedFormat(
159                path.display().to_string(),
160                ext,
161            )),
162        }
163    }
164
165    /// Load configuration from the project directory or global config
166    ///
167    /// Search order:
168    /// 1. `.cc-audit.yaml` in project root
169    /// 2. `.cc-audit.json` in project root
170    /// 3. `.cc-audit.toml` in project root
171    /// 4. `~/.config/cc-audit/config.yaml`
172    /// 5. Default configuration
173    pub fn load(project_root: Option<&Path>) -> Self {
174        // Try project-level config files
175        if let Some(root) = project_root {
176            for filename in &[
177                ".cc-audit.yaml",
178                ".cc-audit.yml",
179                ".cc-audit.json",
180                ".cc-audit.toml",
181            ] {
182                let path = root.join(filename);
183                if path.exists()
184                    && let Ok(config) = Self::from_file(&path)
185                {
186                    return config;
187                }
188            }
189        }
190
191        // Try global config
192        if let Some(config_dir) = dirs::config_dir() {
193            let global_config = config_dir.join("cc-audit").join("config.yaml");
194            if global_config.exists()
195                && let Ok(config) = Self::from_file(&global_config)
196            {
197                return config;
198            }
199        }
200
201        // Return default
202        Self::default()
203    }
204
205    /// Get the effective set of disabled rules (merges severity.ignore and disabled_rules)
206    pub fn effective_disabled_rules(&self) -> HashSet<String> {
207        let mut disabled = self.disabled_rules.clone();
208        disabled.extend(self.severity.ignore.iter().cloned());
209        disabled
210    }
211
212    /// Check if a rule should be ignored based on both disabled_rules and severity.ignore
213    pub fn is_rule_disabled(&self, rule_id: &str) -> bool {
214        self.disabled_rules.contains(rule_id) || self.severity.ignore.contains(rule_id)
215    }
216
217    /// Get the RuleSeverity for a rule, considering both severity config and disabled_rules
218    pub fn get_rule_severity(&self, rule_id: &str) -> Option<crate::rules::RuleSeverity> {
219        if self.is_rule_disabled(rule_id) {
220            return None;
221        }
222        self.severity.get_rule_severity(rule_id)
223    }
224
225    /// Generate a YAML configuration template with comments
226    pub fn generate_template() -> String {
227        r#"# cc-audit Configuration File
228# Place this file as .cc-audit.yaml in your project root
229
230# =============================================================================
231# RULE SEVERITY CONFIGURATION (v0.5.0)
232# =============================================================================
233# Controls how findings affect CI exit code.
234# - error: Causes CI failure (exit 1) - DEFAULT for all rules
235# - warn: Report only, does not cause CI failure (exit 0)
236# - ignore: Completely skip the rule (no report)
237#
238# Priority: ignore > warn > default
239
240severity:
241  # Default severity for all rules
242  default: error
243
244  # Rules to treat as warnings only (report but don't fail CI)
245  # warn:
246  #   - PI-001    # Prompt injection patterns
247  #   - PI-002
248  #   - OB-001    # Obfuscation patterns
249
250  # Rules to completely ignore (no report)
251  # ignore:
252  #   - OP-001    # Overpermission
253
254# =============================================================================
255# SCAN CONFIGURATION
256# =============================================================================
257scan:
258  # Output format: terminal, json, sarif, html
259  # format: terminal
260
261  # Strict mode: show medium/low severity findings and treat warnings as errors
262  strict: false
263
264  # Scan type: skill, hook, mcp, command, rules, docker, dependency, subagent, plugin
265  # scan_type: skill
266
267  # Recursive scan
268  recursive: false
269
270  # CI mode: non-interactive output
271  ci: false
272
273  # Verbose output
274  verbose: false
275
276  # Minimum confidence level: tentative, firm, certain
277  # min_confidence: tentative
278
279  # Skip comment lines when scanning
280  skip_comments: false
281
282  # Show fix hints in terminal output
283  fix_hint: false
284
285  # Disable malware signature scanning
286  no_malware_scan: false
287
288  # Watch mode: continuously monitor files for changes
289  watch: false
290
291  # Path to a custom malware signatures database (JSON)
292  # malware_db: ./custom-malware.json
293
294  # Path to a custom rules file (YAML format)
295  # custom_rules: ./custom-rules.yaml
296
297  # Output file path (for HTML/JSON/SARIF output)
298  # output: ./report.html
299
300  # Enable deep scan with deobfuscation
301  deep_scan: false
302
303  # Auto-fix issues (where possible)
304  fix: false
305
306  # Preview auto-fix changes without applying them
307  fix_dry_run: false
308
309# =============================================================================
310# BASELINE CONFIGURATION (Drift Detection / Rug Pull Prevention)
311# =============================================================================
312baseline:
313  # Create a baseline snapshot when scanning
314  enabled: false
315
316  # Check for drift against saved baseline
317  check_drift: false
318
319  # Path to save baseline to
320  # save_to: ./.cc-audit-baseline.json
321
322  # Path to baseline file to compare against
323  # compare_with: ./.cc-audit-baseline.json
324
325# =============================================================================
326# WATCH MODE CONFIGURATION
327# =============================================================================
328watch:
329  # Debounce duration in milliseconds
330  debounce_ms: 300
331
332  # Poll interval in milliseconds
333  poll_interval_ms: 500
334
335# =============================================================================
336# IGNORE CONFIGURATION
337# =============================================================================
338ignore:
339  # Directories to ignore (overwrites defaults if specified)
340  # directories:
341  #   - node_modules
342  #   - target
343  #   - .git
344  #   - dist
345  #   - build
346
347  # Glob patterns to ignore
348  # patterns:
349  #   - "*.log"
350  #   - "temp/**"
351
352  # Include test directories in scan
353  include_tests: false
354
355  # Include node_modules in scan
356  include_node_modules: false
357
358  # Include vendor directories in scan
359  include_vendor: false
360
361# =============================================================================
362# RULE CONFIGURATION
363# =============================================================================
364
365# Rule IDs to disable
366# disabled_rules:
367#   - "PE-001"
368#   - "EX-002"
369
370# Text file detection configuration
371# text_files:
372#   # Additional file extensions to treat as text
373#   extensions:
374#     - custom
375#     - special
376#
377#   # Additional special file names
378#   special_names:
379#     - CUSTOMFILE
380
381# Custom rules (YAML format)
382# rules:
383#   - id: "CUSTOM-001"
384#     name: "Custom Rule Name"
385#     severity: "high"  # critical, high, medium, low, info
386#     category: "exfiltration"  # exfiltration, privilege_escalation, persistence, etc.
387#     patterns:
388#       - 'pattern_to_match'
389#     message: "Description of the issue"
390#     confidence: "firm"  # tentative, firm, certain
391#     fix_hint: "How to fix this issue"
392
393# Custom malware signatures
394# malware_signatures:
395#   - id: "MW-CUSTOM-001"
396#     name: "Custom Malware Signature"
397#     description: "Description of what this detects"
398#     pattern: "malware_pattern"
399#     severity: "critical"
400#     category: "exfiltration"
401#     confidence: "firm"
402"#
403        .to_string()
404    }
405}
406
407/// Watch mode configuration
408#[derive(Debug, Clone, Serialize, Deserialize)]
409#[serde(default)]
410pub struct WatchConfig {
411    /// Debounce duration in milliseconds
412    pub debounce_ms: u64,
413    /// Poll interval in milliseconds
414    pub poll_interval_ms: u64,
415}
416
417impl Default for WatchConfig {
418    fn default() -> Self {
419        Self {
420            debounce_ms: 300,
421            poll_interval_ms: 500,
422        }
423    }
424}
425
426/// Baseline configuration for drift detection (rug pull prevention)
427#[derive(Debug, Clone, Default, Serialize, Deserialize)]
428#[serde(default)]
429pub struct BaselineConfig {
430    /// Create a baseline snapshot when scanning
431    pub enabled: bool,
432    /// Check for drift against saved baseline
433    pub check_drift: bool,
434    /// Path to save baseline to
435    pub save_to: Option<String>,
436    /// Path to baseline file to compare against
437    pub compare_with: Option<String>,
438}
439
440/// Text file detection configuration
441#[derive(Debug, Clone, Serialize, Deserialize)]
442#[serde(default)]
443pub struct TextFilesConfig {
444    /// File extensions that should be treated as text
445    pub extensions: HashSet<String>,
446    /// Special file names that should be treated as text (without extension)
447    pub special_names: HashSet<String>,
448}
449
450impl Default for TextFilesConfig {
451    fn default() -> Self {
452        let extensions: HashSet<String> = [
453            // Markdown and text
454            "md",
455            "txt",
456            "rst",
457            // Configuration
458            "json",
459            "yaml",
460            "yml",
461            "toml",
462            "xml",
463            "ini",
464            "conf",
465            "cfg",
466            "env",
467            // Shell
468            "sh",
469            "bash",
470            "zsh",
471            "fish",
472            // Scripting
473            "py",
474            "rb",
475            "pl",
476            "pm",
477            "lua",
478            "r",
479            // Web
480            "js",
481            "ts",
482            "jsx",
483            "tsx",
484            "html",
485            "css",
486            "scss",
487            "sass",
488            "less",
489            // Systems
490            "rs",
491            "go",
492            "c",
493            "cpp",
494            "h",
495            "hpp",
496            "cc",
497            "cxx",
498            // JVM
499            "java",
500            "kt",
501            "kts",
502            "scala",
503            "clj",
504            "groovy",
505            // .NET
506            "cs",
507            "fs",
508            "vb",
509            // Mobile
510            "swift",
511            "m",
512            "mm",
513            // Other languages
514            "php",
515            "ex",
516            "exs",
517            "hs",
518            "ml",
519            "vim",
520            "el",
521            "lisp",
522            // Docker
523            "dockerfile",
524            // Build
525            "makefile",
526            "cmake",
527            "gradle",
528        ]
529        .into_iter()
530        .map(String::from)
531        .collect();
532
533        let special_names: HashSet<String> = [
534            "Dockerfile",
535            "Makefile",
536            "Rakefile",
537            "Gemfile",
538            "Podfile",
539            "Vagrantfile",
540            "Procfile",
541            "LICENSE",
542            "README",
543            "CHANGELOG",
544            "CONTRIBUTING",
545            "AUTHORS",
546            "CMakeLists.txt",
547            "Justfile",
548        ]
549        .into_iter()
550        .map(String::from)
551        .collect();
552
553        Self {
554            extensions,
555            special_names,
556        }
557    }
558}
559
560/// Ignore configuration for scanning
561#[derive(Debug, Clone, Serialize, Deserialize)]
562#[serde(default)]
563pub struct IgnoreConfig {
564    /// Directories to ignore (e.g., ["node_modules", "target", ".git"])
565    pub directories: HashSet<String>,
566    /// Glob patterns to ignore (e.g., ["*.log", "build/**"])
567    pub patterns: Vec<String>,
568    /// Whether to include test directories in scan
569    pub include_tests: bool,
570    /// Whether to include node_modules in scan
571    pub include_node_modules: bool,
572    /// Whether to include vendor directories in scan
573    pub include_vendor: bool,
574}
575
576impl Default for IgnoreConfig {
577    fn default() -> Self {
578        let directories: HashSet<String> = [
579            // Common build output directories
580            "target",
581            "dist",
582            "build",
583            "out",
584            // Package manager directories
585            "node_modules",
586            ".pnpm",
587            ".yarn",
588            // Version control
589            ".git",
590            ".svn",
591            ".hg",
592            // IDE directories
593            ".idea",
594            ".vscode",
595            // Cache directories
596            ".cache",
597            "__pycache__",
598            ".pytest_cache",
599            ".mypy_cache",
600            // Coverage directories
601            "coverage",
602            ".nyc_output",
603        ]
604        .into_iter()
605        .map(String::from)
606        .collect();
607
608        Self {
609            directories,
610            patterns: Vec::new(),
611            include_tests: false,
612            include_node_modules: false,
613            include_vendor: false,
614        }
615    }
616}
617
618impl TextFilesConfig {
619    /// Check if a path should be treated as a text file
620    pub fn is_text_file(&self, path: &Path) -> bool {
621        // Check by extension
622        if let Some(ext) = path.extension().and_then(|e| e.to_str())
623            && self.extensions.contains(&ext.to_lowercase())
624        {
625            return true;
626        }
627
628        // Check by filename (case-insensitive for special names)
629        if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
630            // Check exact match first
631            if self.special_names.contains(name) {
632                return true;
633            }
634            // Check case-insensitive match
635            let name_lower = name.to_lowercase();
636            if self
637                .special_names
638                .iter()
639                .any(|n| n.to_lowercase() == name_lower)
640            {
641                return true;
642            }
643        }
644
645        false
646    }
647}
648
649/// Configuration loading error
650#[derive(Debug, thiserror::Error)]
651pub enum ConfigError {
652    #[error("Failed to read config file {path}: {source}")]
653    ReadFile {
654        path: String,
655        #[source]
656        source: std::io::Error,
657    },
658
659    #[error("Failed to parse YAML config {path}: {source}")]
660    ParseYaml {
661        path: String,
662        #[source]
663        source: serde_yaml::Error,
664    },
665
666    #[error("Failed to parse JSON config {path}: {source}")]
667    ParseJson {
668        path: String,
669        #[source]
670        source: serde_json::Error,
671    },
672
673    #[error("Failed to parse TOML config {path}: {source}")]
674    ParseToml {
675        path: String,
676        #[source]
677        source: toml::de::Error,
678    },
679
680    #[error("Unsupported config format for {0}: .{1}")]
681    UnsupportedFormat(String, String),
682}
683
684#[cfg(test)]
685mod tests {
686    use super::*;
687    use tempfile::TempDir;
688
689    #[test]
690    fn test_default_config() {
691        let config = Config::default();
692        assert_eq!(config.watch.debounce_ms, 300);
693        assert_eq!(config.watch.poll_interval_ms, 500);
694        assert!(config.text_files.extensions.contains("md"));
695        assert!(config.text_files.extensions.contains("py"));
696    }
697
698    #[test]
699    fn test_is_text_file_by_extension() {
700        let config = TextFilesConfig::default();
701        assert!(config.is_text_file(Path::new("test.md")));
702        assert!(config.is_text_file(Path::new("test.py")));
703        assert!(config.is_text_file(Path::new("test.rs")));
704        assert!(config.is_text_file(Path::new("test.json")));
705        assert!(!config.is_text_file(Path::new("test.exe")));
706        assert!(!config.is_text_file(Path::new("test.bin")));
707    }
708
709    #[test]
710    fn test_is_text_file_by_name() {
711        let config = TextFilesConfig::default();
712        assert!(config.is_text_file(Path::new("Dockerfile")));
713        assert!(config.is_text_file(Path::new("Makefile")));
714        assert!(config.is_text_file(Path::new("LICENSE")));
715        assert!(!config.is_text_file(Path::new("unknown_file")));
716    }
717
718    #[test]
719    fn test_is_text_file_case_insensitive_extension() {
720        let config = TextFilesConfig::default();
721        assert!(config.is_text_file(Path::new("test.MD")));
722        assert!(config.is_text_file(Path::new("test.PY")));
723        assert!(config.is_text_file(Path::new("test.Json")));
724    }
725
726    #[test]
727    fn test_load_yaml_config() {
728        let dir = TempDir::new().unwrap();
729        let config_path = dir.path().join(".cc-audit.yaml");
730        fs::write(
731            &config_path,
732            r#"
733watch:
734  debounce_ms: 500
735  poll_interval_ms: 1000
736"#,
737        )
738        .unwrap();
739
740        let config = Config::from_file(&config_path).unwrap();
741        assert_eq!(config.watch.debounce_ms, 500);
742        assert_eq!(config.watch.poll_interval_ms, 1000);
743    }
744
745    #[test]
746    fn test_load_json_config() {
747        let dir = TempDir::new().unwrap();
748        let config_path = dir.path().join(".cc-audit.json");
749        fs::write(
750            &config_path,
751            r#"{"watch": {"debounce_ms": 200, "poll_interval_ms": 400}}"#,
752        )
753        .unwrap();
754
755        let config = Config::from_file(&config_path).unwrap();
756        assert_eq!(config.watch.debounce_ms, 200);
757        assert_eq!(config.watch.poll_interval_ms, 400);
758    }
759
760    #[test]
761    fn test_load_toml_config() {
762        let dir = TempDir::new().unwrap();
763        let config_path = dir.path().join(".cc-audit.toml");
764        fs::write(
765            &config_path,
766            r#"
767[watch]
768debounce_ms = 600
769poll_interval_ms = 800
770"#,
771        )
772        .unwrap();
773
774        let config = Config::from_file(&config_path).unwrap();
775        assert_eq!(config.watch.debounce_ms, 600);
776        assert_eq!(config.watch.poll_interval_ms, 800);
777    }
778
779    #[test]
780    fn test_load_with_project_config() {
781        let dir = TempDir::new().unwrap();
782        let config_path = dir.path().join(".cc-audit.yaml");
783        fs::write(
784            &config_path,
785            r#"
786watch:
787  debounce_ms: 100
788"#,
789        )
790        .unwrap();
791
792        let config = Config::load(Some(dir.path()));
793        assert_eq!(config.watch.debounce_ms, 100);
794    }
795
796    #[test]
797    fn test_load_fallback_to_default() {
798        let dir = TempDir::new().unwrap();
799        let config = Config::load(Some(dir.path()));
800        assert_eq!(config.watch.debounce_ms, 300); // Default value
801    }
802
803    #[test]
804    fn test_unsupported_format_error() {
805        let dir = TempDir::new().unwrap();
806        let config_path = dir.path().join(".cc-audit.xml");
807        fs::write(&config_path, "<config></config>").unwrap();
808
809        let result = Config::from_file(&config_path);
810        assert!(matches!(result, Err(ConfigError::UnsupportedFormat(_, _))));
811    }
812
813    #[test]
814    fn test_partial_config_with_defaults() {
815        let dir = TempDir::new().unwrap();
816        let config_path = dir.path().join(".cc-audit.yaml");
817        fs::write(
818            &config_path,
819            r#"
820watch:
821  debounce_ms: 999
822"#,
823        )
824        .unwrap();
825
826        let config = Config::from_file(&config_path).unwrap();
827        assert_eq!(config.watch.debounce_ms, 999);
828        // poll_interval_ms should use default
829        assert_eq!(config.watch.poll_interval_ms, 500);
830    }
831
832    #[test]
833    fn test_config_error_read_file() {
834        let result = Config::from_file(Path::new("/nonexistent/config.yaml"));
835        assert!(matches!(result, Err(ConfigError::ReadFile { .. })));
836    }
837
838    #[test]
839    fn test_custom_text_extensions() {
840        let dir = TempDir::new().unwrap();
841        let config_path = dir.path().join(".cc-audit.yaml");
842        fs::write(
843            &config_path,
844            r#"
845text_files:
846  extensions:
847    - custom
848    - special
849"#,
850        )
851        .unwrap();
852
853        let config = Config::from_file(&config_path).unwrap();
854        assert!(config.text_files.extensions.contains("custom"));
855        assert!(config.text_files.extensions.contains("special"));
856    }
857
858    #[test]
859    fn test_config_with_rules() {
860        let dir = TempDir::new().unwrap();
861        let config_path = dir.path().join(".cc-audit.yaml");
862        fs::write(
863            &config_path,
864            r#"
865rules:
866  - id: "CUSTOM-001"
867    name: "Test Rule"
868    severity: "high"
869    category: "exfiltration"
870    patterns:
871      - 'test_pattern'
872    message: "Test message"
873"#,
874        )
875        .unwrap();
876
877        let config = Config::from_file(&config_path).unwrap();
878        assert_eq!(config.rules.len(), 1);
879        assert_eq!(config.rules[0].id, "CUSTOM-001");
880        assert_eq!(config.rules[0].name, "Test Rule");
881        assert_eq!(config.rules[0].severity, "high");
882    }
883
884    #[test]
885    fn test_config_with_malware_signatures() {
886        let dir = TempDir::new().unwrap();
887        let config_path = dir.path().join(".cc-audit.yaml");
888        fs::write(
889            &config_path,
890            r#"
891malware_signatures:
892  - id: "MW-CUSTOM-001"
893    name: "Custom Malware"
894    description: "Test malware pattern"
895    pattern: "evil_pattern"
896    severity: "critical"
897    category: "exfiltration"
898    confidence: "firm"
899"#,
900        )
901        .unwrap();
902
903        let config = Config::from_file(&config_path).unwrap();
904        assert_eq!(config.malware_signatures.len(), 1);
905        assert_eq!(config.malware_signatures[0].id, "MW-CUSTOM-001");
906        assert_eq!(config.malware_signatures[0].name, "Custom Malware");
907        assert_eq!(config.malware_signatures[0].severity, "critical");
908    }
909
910    #[test]
911    fn test_config_with_rules_and_malware_signatures() {
912        let dir = TempDir::new().unwrap();
913        let config_path = dir.path().join(".cc-audit.yaml");
914        fs::write(
915            &config_path,
916            r#"
917watch:
918  debounce_ms: 100
919
920rules:
921  - id: "CUSTOM-001"
922    name: "Test Rule"
923    severity: "high"
924    category: "exfiltration"
925    patterns:
926      - 'test_pattern'
927    message: "Test message"
928
929malware_signatures:
930  - id: "MW-CUSTOM-001"
931    name: "Custom Malware"
932    description: "Test malware pattern"
933    pattern: "evil_pattern"
934    severity: "critical"
935    category: "exfiltration"
936    confidence: "firm"
937"#,
938        )
939        .unwrap();
940
941        let config = Config::from_file(&config_path).unwrap();
942        assert_eq!(config.watch.debounce_ms, 100);
943        assert_eq!(config.rules.len(), 1);
944        assert_eq!(config.malware_signatures.len(), 1);
945    }
946
947    #[test]
948    fn test_default_config_has_empty_rules() {
949        let config = Config::default();
950        assert!(config.rules.is_empty());
951        assert!(config.malware_signatures.is_empty());
952    }
953
954    #[test]
955    fn test_parse_yaml_error() {
956        let dir = TempDir::new().unwrap();
957        let config_path = dir.path().join(".cc-audit.yaml");
958        fs::write(&config_path, "invalid: yaml: content: [").unwrap();
959
960        let result = Config::from_file(&config_path);
961        assert!(matches!(result, Err(ConfigError::ParseYaml { .. })));
962    }
963
964    #[test]
965    fn test_parse_json_error() {
966        let dir = TempDir::new().unwrap();
967        let config_path = dir.path().join(".cc-audit.json");
968        fs::write(&config_path, "{invalid json}").unwrap();
969
970        let result = Config::from_file(&config_path);
971        assert!(matches!(result, Err(ConfigError::ParseJson { .. })));
972    }
973
974    #[test]
975    fn test_parse_toml_error() {
976        let dir = TempDir::new().unwrap();
977        let config_path = dir.path().join(".cc-audit.toml");
978        fs::write(&config_path, "[invalid toml\nkey = ").unwrap();
979
980        let result = Config::from_file(&config_path);
981        assert!(matches!(result, Err(ConfigError::ParseToml { .. })));
982    }
983
984    #[test]
985    fn test_load_with_invalid_config_falls_back() {
986        let dir = TempDir::new().unwrap();
987        let config_path = dir.path().join(".cc-audit.yaml");
988        // Write invalid YAML that will fail to parse
989        fs::write(&config_path, "invalid: yaml: [").unwrap();
990
991        // Should fall back to default
992        let config = Config::load(Some(dir.path()));
993        assert_eq!(config.watch.debounce_ms, 300);
994    }
995
996    #[test]
997    fn test_is_text_file_returns_false_for_unknown() {
998        let config = TextFilesConfig::default();
999        // No extension, not a special name
1000        assert!(!config.is_text_file(Path::new("somefile")));
1001        assert!(!config.is_text_file(Path::new("random_binary")));
1002    }
1003
1004    #[test]
1005    fn test_ignore_config_default() {
1006        let config = IgnoreConfig::default();
1007        // Check that common directories are in the default ignore list
1008        assert!(config.directories.contains("node_modules"));
1009        assert!(config.directories.contains("target"));
1010        assert!(config.directories.contains(".git"));
1011        assert!(config.directories.contains("dist"));
1012        assert!(config.directories.contains("build"));
1013        // Default flags
1014        assert!(!config.include_tests);
1015        assert!(!config.include_node_modules);
1016        assert!(!config.include_vendor);
1017        // No custom patterns by default
1018        assert!(config.patterns.is_empty());
1019    }
1020
1021    #[test]
1022    fn test_config_with_ignore_settings() {
1023        let dir = TempDir::new().unwrap();
1024        let config_path = dir.path().join(".cc-audit.yaml");
1025        fs::write(
1026            &config_path,
1027            r#"
1028ignore:
1029  directories:
1030    - custom_dir
1031    - my_cache
1032  patterns:
1033    - "*.log"
1034    - "temp/**"
1035  include_tests: true
1036  include_node_modules: false
1037  include_vendor: true
1038"#,
1039        )
1040        .unwrap();
1041
1042        let config = Config::from_file(&config_path).unwrap();
1043        assert!(config.ignore.directories.contains("custom_dir"));
1044        assert!(config.ignore.directories.contains("my_cache"));
1045        assert_eq!(config.ignore.patterns.len(), 2);
1046        assert!(config.ignore.patterns.contains(&"*.log".to_string()));
1047        assert!(config.ignore.patterns.contains(&"temp/**".to_string()));
1048        assert!(config.ignore.include_tests);
1049        assert!(!config.ignore.include_node_modules);
1050        assert!(config.ignore.include_vendor);
1051    }
1052
1053    #[test]
1054    fn test_config_with_disabled_rules() {
1055        let dir = TempDir::new().unwrap();
1056        let config_path = dir.path().join(".cc-audit.yaml");
1057        fs::write(
1058            &config_path,
1059            r#"
1060disabled_rules:
1061  - "PE-001"
1062  - "EX-002"
1063  - "CUSTOM-RULE"
1064"#,
1065        )
1066        .unwrap();
1067
1068        let config = Config::from_file(&config_path).unwrap();
1069        assert_eq!(config.disabled_rules.len(), 3);
1070        assert!(config.disabled_rules.contains("PE-001"));
1071        assert!(config.disabled_rules.contains("EX-002"));
1072        assert!(config.disabled_rules.contains("CUSTOM-RULE"));
1073    }
1074
1075    #[test]
1076    fn test_default_config_has_empty_disabled_rules() {
1077        let config = Config::default();
1078        assert!(config.disabled_rules.is_empty());
1079    }
1080
1081    #[test]
1082    fn test_config_ignore_default_when_partial() {
1083        let dir = TempDir::new().unwrap();
1084        let config_path = dir.path().join(".cc-audit.yaml");
1085        fs::write(
1086            &config_path,
1087            r#"
1088ignore:
1089  include_tests: true
1090"#,
1091        )
1092        .unwrap();
1093
1094        let config = Config::from_file(&config_path).unwrap();
1095        // include_tests is set to true
1096        assert!(config.ignore.include_tests);
1097        // Other values should be default
1098        assert!(!config.ignore.include_node_modules);
1099        assert!(!config.ignore.include_vendor);
1100    }
1101
1102    #[test]
1103    fn test_scan_config_default() {
1104        let config = ScanConfig::default();
1105        assert!(config.format.is_none());
1106        assert!(!config.strict);
1107        assert!(config.scan_type.is_none());
1108        assert!(!config.recursive);
1109        assert!(!config.ci);
1110        assert!(!config.verbose);
1111        assert!(config.min_confidence.is_none());
1112        assert!(!config.skip_comments);
1113        assert!(!config.fix_hint);
1114        assert!(!config.no_malware_scan);
1115        // New fields
1116        assert!(!config.watch);
1117        assert!(config.malware_db.is_none());
1118        assert!(config.custom_rules.is_none());
1119        assert!(config.output.is_none());
1120        assert!(!config.deep_scan);
1121        assert!(!config.fix);
1122        assert!(!config.fix_dry_run);
1123    }
1124
1125    #[test]
1126    fn test_config_with_scan_settings() {
1127        let dir = TempDir::new().unwrap();
1128        let config_path = dir.path().join(".cc-audit.yaml");
1129        fs::write(
1130            &config_path,
1131            r#"
1132scan:
1133  format: json
1134  strict: true
1135  scan_type: docker
1136  recursive: true
1137  ci: true
1138  verbose: true
1139  min_confidence: firm
1140  skip_comments: true
1141  fix_hint: true
1142  no_malware_scan: true
1143  watch: true
1144  malware_db: ./custom-malware.json
1145  custom_rules: ./custom-rules.yaml
1146  output: ./report.html
1147  deep_scan: true
1148  fix: true
1149  fix_dry_run: true
1150"#,
1151        )
1152        .unwrap();
1153
1154        let config = Config::from_file(&config_path).unwrap();
1155        assert_eq!(config.scan.format, Some("json".to_string()));
1156        assert!(config.scan.strict);
1157        assert_eq!(config.scan.scan_type, Some("docker".to_string()));
1158        assert!(config.scan.recursive);
1159        assert!(config.scan.ci);
1160        assert!(config.scan.verbose);
1161        assert_eq!(config.scan.min_confidence, Some("firm".to_string()));
1162        assert!(config.scan.skip_comments);
1163        assert!(config.scan.fix_hint);
1164        assert!(config.scan.no_malware_scan);
1165        // New fields
1166        assert!(config.scan.watch);
1167        assert_eq!(
1168            config.scan.malware_db,
1169            Some("./custom-malware.json".to_string())
1170        );
1171        assert_eq!(
1172            config.scan.custom_rules,
1173            Some("./custom-rules.yaml".to_string())
1174        );
1175        assert_eq!(config.scan.output, Some("./report.html".to_string()));
1176        assert!(config.scan.deep_scan);
1177        assert!(config.scan.fix);
1178        assert!(config.scan.fix_dry_run);
1179    }
1180
1181    #[test]
1182    fn test_config_with_partial_scan_settings() {
1183        let dir = TempDir::new().unwrap();
1184        let config_path = dir.path().join(".cc-audit.yaml");
1185        fs::write(
1186            &config_path,
1187            r#"
1188scan:
1189  strict: true
1190  verbose: true
1191"#,
1192        )
1193        .unwrap();
1194
1195        let config = Config::from_file(&config_path).unwrap();
1196        // Set values
1197        assert!(config.scan.strict);
1198        assert!(config.scan.verbose);
1199        // Default values
1200        assert!(config.scan.format.is_none());
1201        assert!(config.scan.scan_type.is_none());
1202        assert!(!config.scan.recursive);
1203        assert!(!config.scan.ci);
1204        assert!(config.scan.min_confidence.is_none());
1205        assert!(!config.scan.skip_comments);
1206        assert!(!config.scan.fix_hint);
1207        assert!(!config.scan.no_malware_scan);
1208    }
1209
1210    #[test]
1211    fn test_default_config_has_default_scan() {
1212        let config = Config::default();
1213        assert!(!config.scan.strict);
1214        assert!(!config.scan.verbose);
1215        assert!(config.scan.format.is_none());
1216    }
1217
1218    #[test]
1219    fn test_generate_template() {
1220        let template = Config::generate_template();
1221        // Check that template contains key sections
1222        assert!(template.contains("# cc-audit Configuration File"));
1223        assert!(template.contains("severity:"));
1224        assert!(template.contains("default: error"));
1225        assert!(template.contains("scan:"));
1226        assert!(template.contains("watch:"));
1227        assert!(template.contains("ignore:"));
1228        assert!(template.contains("# disabled_rules:"));
1229        assert!(template.contains("# rules:"));
1230        assert!(template.contains("# malware_signatures:"));
1231    }
1232
1233    #[test]
1234    fn test_generate_template_is_valid_yaml() {
1235        let template = Config::generate_template();
1236        // The template should be parseable as YAML (comments are ignored)
1237        let result: Result<Config, _> = serde_yaml::from_str(&template);
1238        assert!(result.is_ok(), "Template should be valid YAML");
1239    }
1240
1241    // ========== SeverityConfig Tests ==========
1242
1243    #[test]
1244    fn test_severity_config_default() {
1245        let config = SeverityConfig::default();
1246        assert_eq!(config.default, crate::rules::RuleSeverity::Error);
1247        assert!(config.warn.is_empty());
1248        assert!(config.ignore.is_empty());
1249    }
1250
1251    #[test]
1252    fn test_severity_config_get_rule_severity_default() {
1253        let config = SeverityConfig::default();
1254        assert_eq!(
1255            config.get_rule_severity("EX-001"),
1256            Some(crate::rules::RuleSeverity::Error)
1257        );
1258    }
1259
1260    #[test]
1261    fn test_severity_config_get_rule_severity_warn() {
1262        let mut config = SeverityConfig::default();
1263        config.warn.insert("PI-001".to_string());
1264
1265        assert_eq!(
1266            config.get_rule_severity("PI-001"),
1267            Some(crate::rules::RuleSeverity::Warn)
1268        );
1269        assert_eq!(
1270            config.get_rule_severity("EX-001"),
1271            Some(crate::rules::RuleSeverity::Error)
1272        );
1273    }
1274
1275    #[test]
1276    fn test_severity_config_get_rule_severity_ignore() {
1277        let mut config = SeverityConfig::default();
1278        config.ignore.insert("OP-001".to_string());
1279
1280        assert_eq!(config.get_rule_severity("OP-001"), None);
1281        assert_eq!(
1282            config.get_rule_severity("EX-001"),
1283            Some(crate::rules::RuleSeverity::Error)
1284        );
1285    }
1286
1287    #[test]
1288    fn test_severity_config_priority_ignore_over_warn() {
1289        let mut config = SeverityConfig::default();
1290        config.warn.insert("RULE-001".to_string());
1291        config.ignore.insert("RULE-001".to_string());
1292
1293        // ignore takes priority over warn
1294        assert_eq!(config.get_rule_severity("RULE-001"), None);
1295    }
1296
1297    #[test]
1298    fn test_config_severity_parsing() {
1299        let dir = TempDir::new().unwrap();
1300        let config_path = dir.path().join(".cc-audit.yaml");
1301        fs::write(
1302            &config_path,
1303            r#"
1304severity:
1305  default: warn
1306  warn:
1307    - PI-001
1308    - PI-002
1309  ignore:
1310    - OP-001
1311"#,
1312        )
1313        .unwrap();
1314
1315        let config = Config::from_file(&config_path).unwrap();
1316        assert_eq!(config.severity.default, crate::rules::RuleSeverity::Warn);
1317        assert!(config.severity.warn.contains("PI-001"));
1318        assert!(config.severity.warn.contains("PI-002"));
1319        assert!(config.severity.ignore.contains("OP-001"));
1320    }
1321
1322    #[test]
1323    fn test_config_effective_disabled_rules() {
1324        let dir = TempDir::new().unwrap();
1325        let config_path = dir.path().join(".cc-audit.yaml");
1326        fs::write(
1327            &config_path,
1328            r#"
1329disabled_rules:
1330  - RULE-A
1331  - RULE-B
1332severity:
1333  ignore:
1334    - RULE-C
1335    - RULE-D
1336"#,
1337        )
1338        .unwrap();
1339
1340        let config = Config::from_file(&config_path).unwrap();
1341        let effective = config.effective_disabled_rules();
1342
1343        // Both disabled_rules and severity.ignore should be merged
1344        assert!(effective.contains("RULE-A"));
1345        assert!(effective.contains("RULE-B"));
1346        assert!(effective.contains("RULE-C"));
1347        assert!(effective.contains("RULE-D"));
1348        assert_eq!(effective.len(), 4);
1349    }
1350
1351    #[test]
1352    fn test_config_is_rule_disabled() {
1353        let dir = TempDir::new().unwrap();
1354        let config_path = dir.path().join(".cc-audit.yaml");
1355        fs::write(
1356            &config_path,
1357            r#"
1358disabled_rules:
1359  - RULE-A
1360severity:
1361  ignore:
1362    - RULE-B
1363"#,
1364        )
1365        .unwrap();
1366
1367        let config = Config::from_file(&config_path).unwrap();
1368        assert!(config.is_rule_disabled("RULE-A"));
1369        assert!(config.is_rule_disabled("RULE-B"));
1370        assert!(!config.is_rule_disabled("RULE-C"));
1371    }
1372
1373    #[test]
1374    fn test_config_get_rule_severity() {
1375        let dir = TempDir::new().unwrap();
1376        let config_path = dir.path().join(".cc-audit.yaml");
1377        fs::write(
1378            &config_path,
1379            r#"
1380disabled_rules:
1381  - RULE-A
1382severity:
1383  default: error
1384  warn:
1385    - RULE-B
1386  ignore:
1387    - RULE-C
1388"#,
1389        )
1390        .unwrap();
1391
1392        let config = Config::from_file(&config_path).unwrap();
1393
1394        // RULE-A is in disabled_rules
1395        assert_eq!(config.get_rule_severity("RULE-A"), None);
1396
1397        // RULE-B is in severity.warn
1398        assert_eq!(
1399            config.get_rule_severity("RULE-B"),
1400            Some(crate::rules::RuleSeverity::Warn)
1401        );
1402
1403        // RULE-C is in severity.ignore
1404        assert_eq!(config.get_rule_severity("RULE-C"), None);
1405
1406        // RULE-D uses default (error)
1407        assert_eq!(
1408            config.get_rule_severity("RULE-D"),
1409            Some(crate::rules::RuleSeverity::Error)
1410        );
1411    }
1412
1413    // ========== BaselineConfig Tests ==========
1414
1415    #[test]
1416    fn test_baseline_config_default() {
1417        let config = BaselineConfig::default();
1418        assert!(!config.enabled);
1419        assert!(!config.check_drift);
1420        assert!(config.save_to.is_none());
1421        assert!(config.compare_with.is_none());
1422    }
1423
1424    #[test]
1425    fn test_config_with_baseline_settings() {
1426        let dir = TempDir::new().unwrap();
1427        let config_path = dir.path().join(".cc-audit.yaml");
1428        fs::write(
1429            &config_path,
1430            r#"
1431baseline:
1432  enabled: true
1433  check_drift: true
1434  save_to: ./.cc-audit-baseline.json
1435  compare_with: ./previous-baseline.json
1436"#,
1437        )
1438        .unwrap();
1439
1440        let config = Config::from_file(&config_path).unwrap();
1441        assert!(config.baseline.enabled);
1442        assert!(config.baseline.check_drift);
1443        assert_eq!(
1444            config.baseline.save_to,
1445            Some("./.cc-audit-baseline.json".to_string())
1446        );
1447        assert_eq!(
1448            config.baseline.compare_with,
1449            Some("./previous-baseline.json".to_string())
1450        );
1451    }
1452
1453    #[test]
1454    fn test_default_config_has_default_baseline() {
1455        let config = Config::default();
1456        assert!(!config.baseline.enabled);
1457        assert!(!config.baseline.check_drift);
1458        assert!(config.baseline.save_to.is_none());
1459        assert!(config.baseline.compare_with.is_none());
1460    }
1461
1462    #[test]
1463    fn test_generate_template_contains_new_sections() {
1464        let template = Config::generate_template();
1465        // Check that template contains new sections
1466        assert!(template.contains("baseline:"));
1467        assert!(template.contains("deep_scan:"));
1468        assert!(template.contains("fix:"));
1469        assert!(template.contains("fix_dry_run:"));
1470        assert!(template.contains("malware_db:"));
1471        assert!(template.contains("custom_rules:"));
1472        assert!(template.contains("output:"));
1473        assert!(template.contains("subagent"));
1474        assert!(template.contains("plugin"));
1475    }
1476}