Skip to main content

cc_audit/
config.rs

1use crate::malware_db::MalwareSignature;
2use crate::rules::custom::YamlRule;
3use serde::{Deserialize, Serialize};
4use std::collections::HashSet;
5use std::fs;
6use std::path::Path;
7
8/// Main configuration structure for cc-audit
9#[derive(Debug, Clone, Default, Serialize, Deserialize)]
10#[serde(default)]
11pub struct Config {
12    /// Scan configuration (CLI options)
13    pub scan: ScanConfig,
14    /// Watch mode configuration
15    pub watch: WatchConfig,
16    /// Text file detection configuration
17    pub text_files: TextFilesConfig,
18    /// Ignore configuration for scanning
19    pub ignore: IgnoreConfig,
20    /// Rule IDs to disable
21    #[serde(default)]
22    pub disabled_rules: HashSet<String>,
23    /// Custom rules defined in config file
24    #[serde(default)]
25    pub rules: Vec<YamlRule>,
26    /// Custom malware signatures defined in config file
27    #[serde(default)]
28    pub malware_signatures: Vec<MalwareSignature>,
29}
30
31/// Scan configuration (corresponds to CLI options)
32#[derive(Debug, Clone, Default, Serialize, Deserialize)]
33#[serde(default)]
34pub struct ScanConfig {
35    /// Output format: "terminal", "json", "sarif", "html"
36    pub format: Option<String>,
37    /// Strict mode: show medium/low severity findings and treat warnings as errors
38    pub strict: bool,
39    /// Scan type: "skill", "hook", "mcp", "command", "rules", "docker", "dependency"
40    pub scan_type: Option<String>,
41    /// Recursive scan
42    pub recursive: bool,
43    /// CI mode: non-interactive output
44    pub ci: bool,
45    /// Verbose output
46    pub verbose: bool,
47    /// Minimum confidence level: "tentative", "firm", "certain"
48    pub min_confidence: Option<String>,
49    /// Skip comment lines when scanning
50    pub skip_comments: bool,
51    /// Show fix hints in terminal output
52    pub fix_hint: bool,
53    /// Disable malware signature scanning
54    pub no_malware_scan: bool,
55}
56
57impl Config {
58    /// Load configuration from a file
59    pub fn from_file(path: &Path) -> Result<Self, ConfigError> {
60        let content = fs::read_to_string(path).map_err(|e| ConfigError::ReadFile {
61            path: path.display().to_string(),
62            source: e,
63        })?;
64
65        let ext = path
66            .extension()
67            .and_then(|e| e.to_str())
68            .unwrap_or("")
69            .to_lowercase();
70
71        match ext.as_str() {
72            "yaml" | "yml" => serde_yaml::from_str(&content).map_err(|e| ConfigError::ParseYaml {
73                path: path.display().to_string(),
74                source: e,
75            }),
76            "json" => serde_json::from_str(&content).map_err(|e| ConfigError::ParseJson {
77                path: path.display().to_string(),
78                source: e,
79            }),
80            "toml" => toml::from_str(&content).map_err(|e| ConfigError::ParseToml {
81                path: path.display().to_string(),
82                source: e,
83            }),
84            _ => Err(ConfigError::UnsupportedFormat(
85                path.display().to_string(),
86                ext,
87            )),
88        }
89    }
90
91    /// Load configuration from the project directory or global config
92    ///
93    /// Search order:
94    /// 1. `.cc-audit.yaml` in project root
95    /// 2. `.cc-audit.json` in project root
96    /// 3. `.cc-audit.toml` in project root
97    /// 4. `~/.config/cc-audit/config.yaml`
98    /// 5. Default configuration
99    pub fn load(project_root: Option<&Path>) -> Self {
100        // Try project-level config files
101        if let Some(root) = project_root {
102            for filename in &[
103                ".cc-audit.yaml",
104                ".cc-audit.yml",
105                ".cc-audit.json",
106                ".cc-audit.toml",
107            ] {
108                let path = root.join(filename);
109                if path.exists()
110                    && let Ok(config) = Self::from_file(&path)
111                {
112                    return config;
113                }
114            }
115        }
116
117        // Try global config
118        if let Some(config_dir) = dirs::config_dir() {
119            let global_config = config_dir.join("cc-audit").join("config.yaml");
120            if global_config.exists()
121                && let Ok(config) = Self::from_file(&global_config)
122            {
123                return config;
124            }
125        }
126
127        // Return default
128        Self::default()
129    }
130
131    /// Generate a YAML configuration template with comments
132    pub fn generate_template() -> String {
133        r#"# cc-audit Configuration File
134# Place this file as .cc-audit.yaml in your project root
135
136# Scan configuration (CLI options can be set here as defaults)
137scan:
138  # Output format: terminal, json, sarif, html
139  # format: terminal
140
141  # Strict mode: show medium/low severity findings and treat warnings as errors
142  strict: false
143
144  # Scan type: skill, hook, mcp, command, rules, docker, dependency
145  # scan_type: skill
146
147  # Recursive scan
148  recursive: false
149
150  # CI mode: non-interactive output
151  ci: false
152
153  # Verbose output
154  verbose: false
155
156  # Minimum confidence level: tentative, firm, certain
157  # min_confidence: tentative
158
159  # Skip comment lines when scanning
160  skip_comments: false
161
162  # Show fix hints in terminal output
163  fix_hint: false
164
165  # Disable malware signature scanning
166  no_malware_scan: false
167
168# Watch mode configuration
169watch:
170  # Debounce duration in milliseconds
171  debounce_ms: 300
172
173  # Poll interval in milliseconds
174  poll_interval_ms: 500
175
176# Ignore configuration for scanning
177ignore:
178  # Directories to ignore (overwrites defaults if specified)
179  # directories:
180  #   - node_modules
181  #   - target
182  #   - .git
183  #   - dist
184  #   - build
185
186  # Glob patterns to ignore
187  # patterns:
188  #   - "*.log"
189  #   - "temp/**"
190
191  # Include test directories in scan
192  include_tests: false
193
194  # Include node_modules in scan
195  include_node_modules: false
196
197  # Include vendor directories in scan
198  include_vendor: false
199
200# Rule IDs to disable
201# disabled_rules:
202#   - "PE-001"
203#   - "EX-002"
204
205# Text file detection configuration
206# text_files:
207#   # Additional file extensions to treat as text
208#   extensions:
209#     - custom
210#     - special
211#
212#   # Additional special file names
213#   special_names:
214#     - CUSTOMFILE
215
216# Custom rules (YAML format)
217# rules:
218#   - id: "CUSTOM-001"
219#     name: "Custom Rule Name"
220#     severity: "high"  # critical, high, medium, low, info
221#     category: "exfiltration"  # exfiltration, privilege_escalation, persistence, etc.
222#     patterns:
223#       - 'pattern_to_match'
224#     message: "Description of the issue"
225#     confidence: "firm"  # tentative, firm, certain
226#     fix_hint: "How to fix this issue"
227
228# Custom malware signatures
229# malware_signatures:
230#   - id: "MW-CUSTOM-001"
231#     name: "Custom Malware Signature"
232#     description: "Description of what this detects"
233#     pattern: "malware_pattern"
234#     severity: "critical"
235#     category: "exfiltration"
236#     confidence: "firm"
237"#
238        .to_string()
239    }
240}
241
242/// Watch mode configuration
243#[derive(Debug, Clone, Serialize, Deserialize)]
244#[serde(default)]
245pub struct WatchConfig {
246    /// Debounce duration in milliseconds
247    pub debounce_ms: u64,
248    /// Poll interval in milliseconds
249    pub poll_interval_ms: u64,
250}
251
252impl Default for WatchConfig {
253    fn default() -> Self {
254        Self {
255            debounce_ms: 300,
256            poll_interval_ms: 500,
257        }
258    }
259}
260
261/// Text file detection configuration
262#[derive(Debug, Clone, Serialize, Deserialize)]
263#[serde(default)]
264pub struct TextFilesConfig {
265    /// File extensions that should be treated as text
266    pub extensions: HashSet<String>,
267    /// Special file names that should be treated as text (without extension)
268    pub special_names: HashSet<String>,
269}
270
271impl Default for TextFilesConfig {
272    fn default() -> Self {
273        let extensions: HashSet<String> = [
274            // Markdown and text
275            "md",
276            "txt",
277            "rst",
278            // Configuration
279            "json",
280            "yaml",
281            "yml",
282            "toml",
283            "xml",
284            "ini",
285            "conf",
286            "cfg",
287            "env",
288            // Shell
289            "sh",
290            "bash",
291            "zsh",
292            "fish",
293            // Scripting
294            "py",
295            "rb",
296            "pl",
297            "pm",
298            "lua",
299            "r",
300            // Web
301            "js",
302            "ts",
303            "jsx",
304            "tsx",
305            "html",
306            "css",
307            "scss",
308            "sass",
309            "less",
310            // Systems
311            "rs",
312            "go",
313            "c",
314            "cpp",
315            "h",
316            "hpp",
317            "cc",
318            "cxx",
319            // JVM
320            "java",
321            "kt",
322            "kts",
323            "scala",
324            "clj",
325            "groovy",
326            // .NET
327            "cs",
328            "fs",
329            "vb",
330            // Mobile
331            "swift",
332            "m",
333            "mm",
334            // Other languages
335            "php",
336            "ex",
337            "exs",
338            "hs",
339            "ml",
340            "vim",
341            "el",
342            "lisp",
343            // Docker
344            "dockerfile",
345            // Build
346            "makefile",
347            "cmake",
348            "gradle",
349        ]
350        .into_iter()
351        .map(String::from)
352        .collect();
353
354        let special_names: HashSet<String> = [
355            "Dockerfile",
356            "Makefile",
357            "Rakefile",
358            "Gemfile",
359            "Podfile",
360            "Vagrantfile",
361            "Procfile",
362            "LICENSE",
363            "README",
364            "CHANGELOG",
365            "CONTRIBUTING",
366            "AUTHORS",
367            "CMakeLists.txt",
368            "Justfile",
369        ]
370        .into_iter()
371        .map(String::from)
372        .collect();
373
374        Self {
375            extensions,
376            special_names,
377        }
378    }
379}
380
381/// Ignore configuration for scanning
382#[derive(Debug, Clone, Serialize, Deserialize)]
383#[serde(default)]
384pub struct IgnoreConfig {
385    /// Directories to ignore (e.g., ["node_modules", "target", ".git"])
386    pub directories: HashSet<String>,
387    /// Glob patterns to ignore (e.g., ["*.log", "build/**"])
388    pub patterns: Vec<String>,
389    /// Whether to include test directories in scan
390    pub include_tests: bool,
391    /// Whether to include node_modules in scan
392    pub include_node_modules: bool,
393    /// Whether to include vendor directories in scan
394    pub include_vendor: bool,
395}
396
397impl Default for IgnoreConfig {
398    fn default() -> Self {
399        let directories: HashSet<String> = [
400            // Common build output directories
401            "target",
402            "dist",
403            "build",
404            "out",
405            // Package manager directories
406            "node_modules",
407            ".pnpm",
408            ".yarn",
409            // Version control
410            ".git",
411            ".svn",
412            ".hg",
413            // IDE directories
414            ".idea",
415            ".vscode",
416            // Cache directories
417            ".cache",
418            "__pycache__",
419            ".pytest_cache",
420            ".mypy_cache",
421            // Coverage directories
422            "coverage",
423            ".nyc_output",
424        ]
425        .into_iter()
426        .map(String::from)
427        .collect();
428
429        Self {
430            directories,
431            patterns: Vec::new(),
432            include_tests: false,
433            include_node_modules: false,
434            include_vendor: false,
435        }
436    }
437}
438
439impl TextFilesConfig {
440    /// Check if a path should be treated as a text file
441    pub fn is_text_file(&self, path: &Path) -> bool {
442        // Check by extension
443        if let Some(ext) = path.extension().and_then(|e| e.to_str())
444            && self.extensions.contains(&ext.to_lowercase())
445        {
446            return true;
447        }
448
449        // Check by filename (case-insensitive for special names)
450        if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
451            // Check exact match first
452            if self.special_names.contains(name) {
453                return true;
454            }
455            // Check case-insensitive match
456            let name_lower = name.to_lowercase();
457            if self
458                .special_names
459                .iter()
460                .any(|n| n.to_lowercase() == name_lower)
461            {
462                return true;
463            }
464        }
465
466        false
467    }
468}
469
470/// Configuration loading error
471#[derive(Debug, thiserror::Error)]
472pub enum ConfigError {
473    #[error("Failed to read config file {path}: {source}")]
474    ReadFile {
475        path: String,
476        #[source]
477        source: std::io::Error,
478    },
479
480    #[error("Failed to parse YAML config {path}: {source}")]
481    ParseYaml {
482        path: String,
483        #[source]
484        source: serde_yaml::Error,
485    },
486
487    #[error("Failed to parse JSON config {path}: {source}")]
488    ParseJson {
489        path: String,
490        #[source]
491        source: serde_json::Error,
492    },
493
494    #[error("Failed to parse TOML config {path}: {source}")]
495    ParseToml {
496        path: String,
497        #[source]
498        source: toml::de::Error,
499    },
500
501    #[error("Unsupported config format for {0}: .{1}")]
502    UnsupportedFormat(String, String),
503}
504
505#[cfg(test)]
506mod tests {
507    use super::*;
508    use tempfile::TempDir;
509
510    #[test]
511    fn test_default_config() {
512        let config = Config::default();
513        assert_eq!(config.watch.debounce_ms, 300);
514        assert_eq!(config.watch.poll_interval_ms, 500);
515        assert!(config.text_files.extensions.contains("md"));
516        assert!(config.text_files.extensions.contains("py"));
517    }
518
519    #[test]
520    fn test_is_text_file_by_extension() {
521        let config = TextFilesConfig::default();
522        assert!(config.is_text_file(Path::new("test.md")));
523        assert!(config.is_text_file(Path::new("test.py")));
524        assert!(config.is_text_file(Path::new("test.rs")));
525        assert!(config.is_text_file(Path::new("test.json")));
526        assert!(!config.is_text_file(Path::new("test.exe")));
527        assert!(!config.is_text_file(Path::new("test.bin")));
528    }
529
530    #[test]
531    fn test_is_text_file_by_name() {
532        let config = TextFilesConfig::default();
533        assert!(config.is_text_file(Path::new("Dockerfile")));
534        assert!(config.is_text_file(Path::new("Makefile")));
535        assert!(config.is_text_file(Path::new("LICENSE")));
536        assert!(!config.is_text_file(Path::new("unknown_file")));
537    }
538
539    #[test]
540    fn test_is_text_file_case_insensitive_extension() {
541        let config = TextFilesConfig::default();
542        assert!(config.is_text_file(Path::new("test.MD")));
543        assert!(config.is_text_file(Path::new("test.PY")));
544        assert!(config.is_text_file(Path::new("test.Json")));
545    }
546
547    #[test]
548    fn test_load_yaml_config() {
549        let dir = TempDir::new().unwrap();
550        let config_path = dir.path().join(".cc-audit.yaml");
551        fs::write(
552            &config_path,
553            r#"
554watch:
555  debounce_ms: 500
556  poll_interval_ms: 1000
557"#,
558        )
559        .unwrap();
560
561        let config = Config::from_file(&config_path).unwrap();
562        assert_eq!(config.watch.debounce_ms, 500);
563        assert_eq!(config.watch.poll_interval_ms, 1000);
564    }
565
566    #[test]
567    fn test_load_json_config() {
568        let dir = TempDir::new().unwrap();
569        let config_path = dir.path().join(".cc-audit.json");
570        fs::write(
571            &config_path,
572            r#"{"watch": {"debounce_ms": 200, "poll_interval_ms": 400}}"#,
573        )
574        .unwrap();
575
576        let config = Config::from_file(&config_path).unwrap();
577        assert_eq!(config.watch.debounce_ms, 200);
578        assert_eq!(config.watch.poll_interval_ms, 400);
579    }
580
581    #[test]
582    fn test_load_toml_config() {
583        let dir = TempDir::new().unwrap();
584        let config_path = dir.path().join(".cc-audit.toml");
585        fs::write(
586            &config_path,
587            r#"
588[watch]
589debounce_ms = 600
590poll_interval_ms = 800
591"#,
592        )
593        .unwrap();
594
595        let config = Config::from_file(&config_path).unwrap();
596        assert_eq!(config.watch.debounce_ms, 600);
597        assert_eq!(config.watch.poll_interval_ms, 800);
598    }
599
600    #[test]
601    fn test_load_with_project_config() {
602        let dir = TempDir::new().unwrap();
603        let config_path = dir.path().join(".cc-audit.yaml");
604        fs::write(
605            &config_path,
606            r#"
607watch:
608  debounce_ms: 100
609"#,
610        )
611        .unwrap();
612
613        let config = Config::load(Some(dir.path()));
614        assert_eq!(config.watch.debounce_ms, 100);
615    }
616
617    #[test]
618    fn test_load_fallback_to_default() {
619        let dir = TempDir::new().unwrap();
620        let config = Config::load(Some(dir.path()));
621        assert_eq!(config.watch.debounce_ms, 300); // Default value
622    }
623
624    #[test]
625    fn test_unsupported_format_error() {
626        let dir = TempDir::new().unwrap();
627        let config_path = dir.path().join(".cc-audit.xml");
628        fs::write(&config_path, "<config></config>").unwrap();
629
630        let result = Config::from_file(&config_path);
631        assert!(matches!(result, Err(ConfigError::UnsupportedFormat(_, _))));
632    }
633
634    #[test]
635    fn test_partial_config_with_defaults() {
636        let dir = TempDir::new().unwrap();
637        let config_path = dir.path().join(".cc-audit.yaml");
638        fs::write(
639            &config_path,
640            r#"
641watch:
642  debounce_ms: 999
643"#,
644        )
645        .unwrap();
646
647        let config = Config::from_file(&config_path).unwrap();
648        assert_eq!(config.watch.debounce_ms, 999);
649        // poll_interval_ms should use default
650        assert_eq!(config.watch.poll_interval_ms, 500);
651    }
652
653    #[test]
654    fn test_config_error_read_file() {
655        let result = Config::from_file(Path::new("/nonexistent/config.yaml"));
656        assert!(matches!(result, Err(ConfigError::ReadFile { .. })));
657    }
658
659    #[test]
660    fn test_custom_text_extensions() {
661        let dir = TempDir::new().unwrap();
662        let config_path = dir.path().join(".cc-audit.yaml");
663        fs::write(
664            &config_path,
665            r#"
666text_files:
667  extensions:
668    - custom
669    - special
670"#,
671        )
672        .unwrap();
673
674        let config = Config::from_file(&config_path).unwrap();
675        assert!(config.text_files.extensions.contains("custom"));
676        assert!(config.text_files.extensions.contains("special"));
677    }
678
679    #[test]
680    fn test_config_with_rules() {
681        let dir = TempDir::new().unwrap();
682        let config_path = dir.path().join(".cc-audit.yaml");
683        fs::write(
684            &config_path,
685            r#"
686rules:
687  - id: "CUSTOM-001"
688    name: "Test Rule"
689    severity: "high"
690    category: "exfiltration"
691    patterns:
692      - 'test_pattern'
693    message: "Test message"
694"#,
695        )
696        .unwrap();
697
698        let config = Config::from_file(&config_path).unwrap();
699        assert_eq!(config.rules.len(), 1);
700        assert_eq!(config.rules[0].id, "CUSTOM-001");
701        assert_eq!(config.rules[0].name, "Test Rule");
702        assert_eq!(config.rules[0].severity, "high");
703    }
704
705    #[test]
706    fn test_config_with_malware_signatures() {
707        let dir = TempDir::new().unwrap();
708        let config_path = dir.path().join(".cc-audit.yaml");
709        fs::write(
710            &config_path,
711            r#"
712malware_signatures:
713  - id: "MW-CUSTOM-001"
714    name: "Custom Malware"
715    description: "Test malware pattern"
716    pattern: "evil_pattern"
717    severity: "critical"
718    category: "exfiltration"
719    confidence: "firm"
720"#,
721        )
722        .unwrap();
723
724        let config = Config::from_file(&config_path).unwrap();
725        assert_eq!(config.malware_signatures.len(), 1);
726        assert_eq!(config.malware_signatures[0].id, "MW-CUSTOM-001");
727        assert_eq!(config.malware_signatures[0].name, "Custom Malware");
728        assert_eq!(config.malware_signatures[0].severity, "critical");
729    }
730
731    #[test]
732    fn test_config_with_rules_and_malware_signatures() {
733        let dir = TempDir::new().unwrap();
734        let config_path = dir.path().join(".cc-audit.yaml");
735        fs::write(
736            &config_path,
737            r#"
738watch:
739  debounce_ms: 100
740
741rules:
742  - id: "CUSTOM-001"
743    name: "Test Rule"
744    severity: "high"
745    category: "exfiltration"
746    patterns:
747      - 'test_pattern'
748    message: "Test message"
749
750malware_signatures:
751  - id: "MW-CUSTOM-001"
752    name: "Custom Malware"
753    description: "Test malware pattern"
754    pattern: "evil_pattern"
755    severity: "critical"
756    category: "exfiltration"
757    confidence: "firm"
758"#,
759        )
760        .unwrap();
761
762        let config = Config::from_file(&config_path).unwrap();
763        assert_eq!(config.watch.debounce_ms, 100);
764        assert_eq!(config.rules.len(), 1);
765        assert_eq!(config.malware_signatures.len(), 1);
766    }
767
768    #[test]
769    fn test_default_config_has_empty_rules() {
770        let config = Config::default();
771        assert!(config.rules.is_empty());
772        assert!(config.malware_signatures.is_empty());
773    }
774
775    #[test]
776    fn test_parse_yaml_error() {
777        let dir = TempDir::new().unwrap();
778        let config_path = dir.path().join(".cc-audit.yaml");
779        fs::write(&config_path, "invalid: yaml: content: [").unwrap();
780
781        let result = Config::from_file(&config_path);
782        assert!(matches!(result, Err(ConfigError::ParseYaml { .. })));
783    }
784
785    #[test]
786    fn test_parse_json_error() {
787        let dir = TempDir::new().unwrap();
788        let config_path = dir.path().join(".cc-audit.json");
789        fs::write(&config_path, "{invalid json}").unwrap();
790
791        let result = Config::from_file(&config_path);
792        assert!(matches!(result, Err(ConfigError::ParseJson { .. })));
793    }
794
795    #[test]
796    fn test_parse_toml_error() {
797        let dir = TempDir::new().unwrap();
798        let config_path = dir.path().join(".cc-audit.toml");
799        fs::write(&config_path, "[invalid toml\nkey = ").unwrap();
800
801        let result = Config::from_file(&config_path);
802        assert!(matches!(result, Err(ConfigError::ParseToml { .. })));
803    }
804
805    #[test]
806    fn test_load_with_invalid_config_falls_back() {
807        let dir = TempDir::new().unwrap();
808        let config_path = dir.path().join(".cc-audit.yaml");
809        // Write invalid YAML that will fail to parse
810        fs::write(&config_path, "invalid: yaml: [").unwrap();
811
812        // Should fall back to default
813        let config = Config::load(Some(dir.path()));
814        assert_eq!(config.watch.debounce_ms, 300);
815    }
816
817    #[test]
818    fn test_is_text_file_returns_false_for_unknown() {
819        let config = TextFilesConfig::default();
820        // No extension, not a special name
821        assert!(!config.is_text_file(Path::new("somefile")));
822        assert!(!config.is_text_file(Path::new("random_binary")));
823    }
824
825    #[test]
826    fn test_ignore_config_default() {
827        let config = IgnoreConfig::default();
828        // Check that common directories are in the default ignore list
829        assert!(config.directories.contains("node_modules"));
830        assert!(config.directories.contains("target"));
831        assert!(config.directories.contains(".git"));
832        assert!(config.directories.contains("dist"));
833        assert!(config.directories.contains("build"));
834        // Default flags
835        assert!(!config.include_tests);
836        assert!(!config.include_node_modules);
837        assert!(!config.include_vendor);
838        // No custom patterns by default
839        assert!(config.patterns.is_empty());
840    }
841
842    #[test]
843    fn test_config_with_ignore_settings() {
844        let dir = TempDir::new().unwrap();
845        let config_path = dir.path().join(".cc-audit.yaml");
846        fs::write(
847            &config_path,
848            r#"
849ignore:
850  directories:
851    - custom_dir
852    - my_cache
853  patterns:
854    - "*.log"
855    - "temp/**"
856  include_tests: true
857  include_node_modules: false
858  include_vendor: true
859"#,
860        )
861        .unwrap();
862
863        let config = Config::from_file(&config_path).unwrap();
864        assert!(config.ignore.directories.contains("custom_dir"));
865        assert!(config.ignore.directories.contains("my_cache"));
866        assert_eq!(config.ignore.patterns.len(), 2);
867        assert!(config.ignore.patterns.contains(&"*.log".to_string()));
868        assert!(config.ignore.patterns.contains(&"temp/**".to_string()));
869        assert!(config.ignore.include_tests);
870        assert!(!config.ignore.include_node_modules);
871        assert!(config.ignore.include_vendor);
872    }
873
874    #[test]
875    fn test_config_with_disabled_rules() {
876        let dir = TempDir::new().unwrap();
877        let config_path = dir.path().join(".cc-audit.yaml");
878        fs::write(
879            &config_path,
880            r#"
881disabled_rules:
882  - "PE-001"
883  - "EX-002"
884  - "CUSTOM-RULE"
885"#,
886        )
887        .unwrap();
888
889        let config = Config::from_file(&config_path).unwrap();
890        assert_eq!(config.disabled_rules.len(), 3);
891        assert!(config.disabled_rules.contains("PE-001"));
892        assert!(config.disabled_rules.contains("EX-002"));
893        assert!(config.disabled_rules.contains("CUSTOM-RULE"));
894    }
895
896    #[test]
897    fn test_default_config_has_empty_disabled_rules() {
898        let config = Config::default();
899        assert!(config.disabled_rules.is_empty());
900    }
901
902    #[test]
903    fn test_config_ignore_default_when_partial() {
904        let dir = TempDir::new().unwrap();
905        let config_path = dir.path().join(".cc-audit.yaml");
906        fs::write(
907            &config_path,
908            r#"
909ignore:
910  include_tests: true
911"#,
912        )
913        .unwrap();
914
915        let config = Config::from_file(&config_path).unwrap();
916        // include_tests is set to true
917        assert!(config.ignore.include_tests);
918        // Other values should be default
919        assert!(!config.ignore.include_node_modules);
920        assert!(!config.ignore.include_vendor);
921    }
922
923    #[test]
924    fn test_scan_config_default() {
925        let config = ScanConfig::default();
926        assert!(config.format.is_none());
927        assert!(!config.strict);
928        assert!(config.scan_type.is_none());
929        assert!(!config.recursive);
930        assert!(!config.ci);
931        assert!(!config.verbose);
932        assert!(config.min_confidence.is_none());
933        assert!(!config.skip_comments);
934        assert!(!config.fix_hint);
935        assert!(!config.no_malware_scan);
936    }
937
938    #[test]
939    fn test_config_with_scan_settings() {
940        let dir = TempDir::new().unwrap();
941        let config_path = dir.path().join(".cc-audit.yaml");
942        fs::write(
943            &config_path,
944            r#"
945scan:
946  format: json
947  strict: true
948  scan_type: docker
949  recursive: true
950  ci: true
951  verbose: true
952  min_confidence: firm
953  skip_comments: true
954  fix_hint: true
955  no_malware_scan: true
956"#,
957        )
958        .unwrap();
959
960        let config = Config::from_file(&config_path).unwrap();
961        assert_eq!(config.scan.format, Some("json".to_string()));
962        assert!(config.scan.strict);
963        assert_eq!(config.scan.scan_type, Some("docker".to_string()));
964        assert!(config.scan.recursive);
965        assert!(config.scan.ci);
966        assert!(config.scan.verbose);
967        assert_eq!(config.scan.min_confidence, Some("firm".to_string()));
968        assert!(config.scan.skip_comments);
969        assert!(config.scan.fix_hint);
970        assert!(config.scan.no_malware_scan);
971    }
972
973    #[test]
974    fn test_config_with_partial_scan_settings() {
975        let dir = TempDir::new().unwrap();
976        let config_path = dir.path().join(".cc-audit.yaml");
977        fs::write(
978            &config_path,
979            r#"
980scan:
981  strict: true
982  verbose: true
983"#,
984        )
985        .unwrap();
986
987        let config = Config::from_file(&config_path).unwrap();
988        // Set values
989        assert!(config.scan.strict);
990        assert!(config.scan.verbose);
991        // Default values
992        assert!(config.scan.format.is_none());
993        assert!(config.scan.scan_type.is_none());
994        assert!(!config.scan.recursive);
995        assert!(!config.scan.ci);
996        assert!(config.scan.min_confidence.is_none());
997        assert!(!config.scan.skip_comments);
998        assert!(!config.scan.fix_hint);
999        assert!(!config.scan.no_malware_scan);
1000    }
1001
1002    #[test]
1003    fn test_default_config_has_default_scan() {
1004        let config = Config::default();
1005        assert!(!config.scan.strict);
1006        assert!(!config.scan.verbose);
1007        assert!(config.scan.format.is_none());
1008    }
1009
1010    #[test]
1011    fn test_generate_template() {
1012        let template = Config::generate_template();
1013        // Check that template contains key sections
1014        assert!(template.contains("# cc-audit Configuration File"));
1015        assert!(template.contains("scan:"));
1016        assert!(template.contains("watch:"));
1017        assert!(template.contains("ignore:"));
1018        assert!(template.contains("# disabled_rules:"));
1019        assert!(template.contains("# rules:"));
1020        assert!(template.contains("# malware_signatures:"));
1021    }
1022
1023    #[test]
1024    fn test_generate_template_is_valid_yaml() {
1025        let template = Config::generate_template();
1026        // The template should be parseable as YAML (comments are ignored)
1027        let result: Result<Config, _> = serde_yaml::from_str(&template);
1028        assert!(result.is_ok(), "Template should be valid YAML");
1029    }
1030}