Skip to main content

cc_audit/
cli.rs

1use crate::client::ClientType;
2use crate::rules::{Confidence, ParseEnumError, RuleSeverity, Severity};
3use clap::{Parser, ValueEnum};
4use serde::{Deserialize, Serialize};
5use std::path::PathBuf;
6
7#[derive(Debug, Clone, Copy, ValueEnum, Default, PartialEq, Eq, Serialize, Deserialize)]
8#[serde(rename_all = "lowercase")]
9pub enum OutputFormat {
10    #[default]
11    Terminal,
12    Json,
13    Sarif,
14    Html,
15    Markdown,
16}
17
18impl std::str::FromStr for OutputFormat {
19    type Err = ParseEnumError;
20
21    fn from_str(s: &str) -> Result<Self, Self::Err> {
22        match s.to_lowercase().as_str() {
23            "terminal" | "term" => Ok(OutputFormat::Terminal),
24            "json" => Ok(OutputFormat::Json),
25            "sarif" => Ok(OutputFormat::Sarif),
26            "html" => Ok(OutputFormat::Html),
27            "markdown" | "md" => Ok(OutputFormat::Markdown),
28            _ => Err(ParseEnumError::invalid("OutputFormat", s)),
29        }
30    }
31}
32
33/// Badge output format for security badges
34#[derive(Debug, Clone, Copy, ValueEnum, Default, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(rename_all = "lowercase")]
36pub enum BadgeFormat {
37    /// shields.io URL only
38    Url,
39    /// Markdown badge with link
40    #[default]
41    Markdown,
42    /// HTML image tag
43    Html,
44}
45
46impl std::str::FromStr for BadgeFormat {
47    type Err = ParseEnumError;
48
49    fn from_str(s: &str) -> Result<Self, Self::Err> {
50        match s.to_lowercase().as_str() {
51            "url" => Ok(BadgeFormat::Url),
52            "markdown" | "md" => Ok(BadgeFormat::Markdown),
53            "html" => Ok(BadgeFormat::Html),
54            _ => Err(ParseEnumError::invalid("BadgeFormat", s)),
55        }
56    }
57}
58
59#[derive(Debug, Clone, Copy, ValueEnum, Default, PartialEq, Eq, Serialize, Deserialize)]
60#[serde(rename_all = "lowercase")]
61pub enum ScanType {
62    #[default]
63    Skill,
64    Hook,
65    Mcp,
66    Command,
67    Rules,
68    Docker,
69    Dependency,
70    /// Scan .claude/agents/ subagent definitions
71    Subagent,
72    /// Scan marketplace.json plugin definitions
73    Plugin,
74}
75
76impl std::str::FromStr for ScanType {
77    type Err = ParseEnumError;
78
79    fn from_str(s: &str) -> Result<Self, Self::Err> {
80        match s.to_lowercase().as_str() {
81            "skill" => Ok(ScanType::Skill),
82            "hook" => Ok(ScanType::Hook),
83            "mcp" => Ok(ScanType::Mcp),
84            "command" | "cmd" => Ok(ScanType::Command),
85            "rules" => Ok(ScanType::Rules),
86            "docker" => Ok(ScanType::Docker),
87            "dependency" | "dep" | "deps" => Ok(ScanType::Dependency),
88            "subagent" | "agent" => Ok(ScanType::Subagent),
89            "plugin" => Ok(ScanType::Plugin),
90            _ => Err(ParseEnumError::invalid("ScanType", s)),
91        }
92    }
93}
94
95#[derive(Parser, Debug)]
96#[command(
97    name = "cc-audit",
98    version,
99    about = "Security auditor for Claude Code skills, hooks, and MCP servers",
100    long_about = "cc-audit scans Claude Code skills, hooks, and MCP servers for security vulnerabilities before installation."
101)]
102pub struct Cli {
103    /// Paths to scan (files or directories)
104    #[arg(required_unless_present_any = ["remote", "remote_list", "awesome_claude_code", "init", "all_clients", "client", "hook_mode", "mcp_server"])]
105    pub paths: Vec<PathBuf>,
106
107    /// Scan all installed AI coding clients (Claude, Cursor, Windsurf, VS Code)
108    #[arg(long, conflicts_with_all = ["remote", "remote_list", "awesome_claude_code", "client"])]
109    pub all_clients: bool,
110
111    /// Scan a specific AI coding client
112    #[arg(long, value_enum, conflicts_with_all = ["remote", "remote_list", "awesome_claude_code", "all_clients"])]
113    pub client: Option<ClientType>,
114
115    /// Remote repository URL to scan (e.g., `https://github.com/user/repo`)
116    #[arg(long, value_name = "URL")]
117    pub remote: Option<String>,
118
119    /// Git ref (branch, tag, or commit) for remote scan
120    #[arg(long, default_value = "HEAD")]
121    pub git_ref: String,
122
123    /// GitHub token for authentication (or use GITHUB_TOKEN env var)
124    #[arg(long, env = "GITHUB_TOKEN", value_name = "TOKEN")]
125    pub remote_auth: Option<String>,
126
127    /// File containing list of repository URLs to scan (one per line)
128    #[arg(long, conflicts_with = "remote", value_name = "FILE")]
129    pub remote_list: Option<PathBuf>,
130
131    /// Scan all repositories from awesome-claude-code
132    #[arg(long, conflicts_with_all = ["remote", "remote_list"])]
133    pub awesome_claude_code: bool,
134
135    /// Maximum number of parallel repository clones
136    #[arg(long, default_value = "4")]
137    pub parallel_clones: usize,
138
139    /// Generate security badge
140    #[arg(long)]
141    pub badge: bool,
142
143    /// Badge output format (url, markdown, html)
144    #[arg(long, value_enum, default_value_t = BadgeFormat::Markdown)]
145    pub badge_format: BadgeFormat,
146
147    /// Show summary only (for batch scans)
148    #[arg(long)]
149    pub summary: bool,
150
151    /// Output format
152    #[arg(short, long, value_enum, default_value_t = OutputFormat::Terminal)]
153    pub format: OutputFormat,
154
155    /// Strict mode: show medium/low severity findings and treat warnings as errors
156    #[arg(short, long)]
157    pub strict: bool,
158
159    /// Warn-only mode: treat all findings as warnings (exit code 0)
160    #[arg(long)]
161    pub warn_only: bool,
162
163    /// Minimum severity level to include in output (critical, high, medium, low)
164    #[arg(long, value_enum)]
165    pub min_severity: Option<Severity>,
166
167    /// Minimum rule severity to treat as errors (error, warn)
168    #[arg(long, value_enum)]
169    pub min_rule_severity: Option<RuleSeverity>,
170
171    /// Scan type
172    #[arg(short = 't', long = "type", value_enum, default_value_t = ScanType::Skill)]
173    pub scan_type: ScanType,
174
175    /// Recursive scan
176    #[arg(short, long)]
177    pub recursive: bool,
178
179    /// CI mode: non-interactive output
180    #[arg(long)]
181    pub ci: bool,
182
183    /// Verbose output
184    #[arg(short, long)]
185    pub verbose: bool,
186
187    /// Include test directories (tests/, spec/, __tests__, etc.) in scan
188    #[arg(long)]
189    pub include_tests: bool,
190
191    /// Include node_modules directories in scan
192    #[arg(long)]
193    pub include_node_modules: bool,
194
195    /// Include vendor directories (vendor/, third_party/) in scan
196    #[arg(long)]
197    pub include_vendor: bool,
198
199    /// Minimum confidence level for findings to be reported
200    #[arg(long, value_enum, default_value_t = Confidence::Tentative)]
201    pub min_confidence: Confidence,
202
203    /// Skip comment lines when scanning (lines starting with #, //, --, etc.)
204    #[arg(long)]
205    pub skip_comments: bool,
206
207    /// Strict secrets mode: disable dummy key heuristics for test files
208    /// By default, findings in test files have their confidence downgraded.
209    /// This option disables that behavior and treats all secrets equally.
210    #[arg(long)]
211    pub strict_secrets: bool,
212
213    /// Show fix hints in terminal output
214    #[arg(long)]
215    pub fix_hint: bool,
216
217    /// Use compact output format (disable friendly advice)
218    #[arg(long)]
219    pub compact: bool,
220
221    /// Watch mode: continuously monitor files for changes and re-scan
222    #[arg(short, long)]
223    pub watch: bool,
224
225    /// Install cc-audit pre-commit hook in the git repository
226    #[arg(long)]
227    pub init_hook: bool,
228
229    /// Remove cc-audit pre-commit hook from the git repository
230    #[arg(long)]
231    pub remove_hook: bool,
232
233    /// Path to a custom malware signatures database (JSON)
234    #[arg(long)]
235    pub malware_db: Option<PathBuf>,
236
237    /// Disable malware signature scanning
238    #[arg(long)]
239    pub no_malware_scan: bool,
240
241    /// Path to a custom CVE database (JSON)
242    #[arg(long)]
243    pub cve_db: Option<PathBuf>,
244
245    /// Disable CVE vulnerability scanning
246    #[arg(long)]
247    pub no_cve_scan: bool,
248
249    /// Path to a custom rules file (YAML format)
250    #[arg(long)]
251    pub custom_rules: Option<PathBuf>,
252
253    /// Create a baseline snapshot for drift detection (rug pull prevention)
254    #[arg(long)]
255    pub baseline: bool,
256
257    /// Check for drift against saved baseline
258    #[arg(long)]
259    pub check_drift: bool,
260
261    /// Generate a default configuration file template
262    #[arg(long)]
263    pub init: bool,
264
265    /// Output file path (for HTML/JSON output)
266    #[arg(short, long)]
267    pub output: Option<PathBuf>,
268
269    /// Save baseline to specified file
270    #[arg(long, value_name = "FILE")]
271    pub save_baseline: Option<PathBuf>,
272
273    /// Compare against baseline file (show only new findings)
274    #[arg(long, value_name = "FILE")]
275    pub baseline_file: Option<PathBuf>,
276
277    /// Compare two paths and show differences
278    #[arg(long, num_args = 2, value_names = ["PATH1", "PATH2"])]
279    pub compare: Option<Vec<PathBuf>>,
280
281    /// Auto-fix issues (where possible)
282    #[arg(long)]
283    pub fix: bool,
284
285    /// Preview auto-fix changes without applying them
286    #[arg(long)]
287    pub fix_dry_run: bool,
288
289    /// Run as MCP server
290    #[arg(long)]
291    pub mcp_server: bool,
292
293    /// Run as Claude Code Hook (reads from stdin, writes to stdout)
294    #[arg(long)]
295    pub hook_mode: bool,
296
297    /// Pin MCP tool configurations for rug-pull detection
298    #[arg(long)]
299    pub pin: bool,
300
301    /// Verify MCP tool pins against current configuration
302    #[arg(long)]
303    pub pin_verify: bool,
304
305    /// Update MCP tool pins with current configuration
306    #[arg(long)]
307    pub pin_update: bool,
308
309    /// Force overwrite existing pins
310    #[arg(long)]
311    pub pin_force: bool,
312
313    /// Skip pin verification during scan
314    #[arg(long)]
315    pub ignore_pin: bool,
316
317    /// Enable deep scan with deobfuscation
318    #[arg(long)]
319    pub deep_scan: bool,
320
321    /// Load settings from a named profile
322    #[arg(long, value_name = "NAME")]
323    pub profile: Option<String>,
324
325    /// Save current settings as a named profile
326    #[arg(long, value_name = "NAME")]
327    pub save_profile: Option<String>,
328
329    /// Report a false positive finding
330    #[arg(long)]
331    pub report_fp: bool,
332
333    /// Dry run mode for false positive reporting (print without submitting)
334    #[arg(long)]
335    pub report_fp_dry_run: bool,
336
337    /// Custom endpoint URL for false positive reporting
338    #[arg(long, value_name = "URL")]
339    pub report_fp_endpoint: Option<String>,
340
341    /// Disable telemetry and false positive reporting
342    #[arg(long)]
343    pub no_telemetry: bool,
344
345    /// Generate SBOM (Software Bill of Materials)
346    #[arg(long)]
347    pub sbom: bool,
348
349    /// SBOM output format (cyclonedx, spdx)
350    #[arg(long, value_name = "FORMAT")]
351    pub sbom_format: Option<String>,
352
353    /// Include npm dependencies in SBOM
354    #[arg(long)]
355    pub sbom_npm: bool,
356
357    /// Include Cargo dependencies in SBOM
358    #[arg(long)]
359    pub sbom_cargo: bool,
360
361    /// Enable proxy mode for runtime MCP monitoring
362    #[arg(long)]
363    pub proxy: bool,
364
365    /// Proxy listen port (default: 8080)
366    #[arg(long, value_name = "PORT")]
367    pub proxy_port: Option<u16>,
368
369    /// Target MCP server address (host:port)
370    #[arg(long, value_name = "HOST:PORT")]
371    pub proxy_target: Option<String>,
372
373    /// Enable TLS termination in proxy mode
374    #[arg(long)]
375    pub proxy_tls: bool,
376
377    /// Enable blocking mode (block messages with findings)
378    #[arg(long)]
379    pub proxy_block: bool,
380
381    /// Log file for proxy traffic (JSONL format)
382    #[arg(long, value_name = "FILE")]
383    pub proxy_log: Option<std::path::PathBuf>,
384}
385
386impl Default for Cli {
387    fn default() -> Self {
388        Self {
389            paths: Vec::new(),
390            all_clients: false,
391            client: None,
392            remote: None,
393            git_ref: "HEAD".to_string(),
394            remote_auth: None,
395            remote_list: None,
396            awesome_claude_code: false,
397            parallel_clones: 4,
398            badge: false,
399            badge_format: BadgeFormat::Markdown,
400            summary: false,
401            format: OutputFormat::Terminal,
402            strict: false,
403            warn_only: false,
404            min_severity: None,
405            min_rule_severity: None,
406            scan_type: ScanType::Skill,
407            recursive: false,
408            ci: false,
409            verbose: false,
410            include_tests: false,
411            include_node_modules: false,
412            include_vendor: false,
413            min_confidence: Confidence::Tentative,
414            skip_comments: false,
415            strict_secrets: false,
416            fix_hint: false,
417            compact: false,
418            watch: false,
419            init_hook: false,
420            remove_hook: false,
421            malware_db: None,
422            no_malware_scan: false,
423            cve_db: None,
424            no_cve_scan: false,
425            custom_rules: None,
426            baseline: false,
427            check_drift: false,
428            init: false,
429            output: None,
430            save_baseline: None,
431            baseline_file: None,
432            compare: None,
433            fix: false,
434            fix_dry_run: false,
435            mcp_server: false,
436            hook_mode: false,
437            pin: false,
438            pin_verify: false,
439            pin_update: false,
440            pin_force: false,
441            ignore_pin: false,
442            deep_scan: false,
443            profile: None,
444            save_profile: None,
445            report_fp: false,
446            report_fp_dry_run: false,
447            report_fp_endpoint: None,
448            no_telemetry: false,
449            sbom: false,
450            sbom_format: None,
451            sbom_npm: false,
452            sbom_cargo: false,
453            proxy: false,
454            proxy_port: None,
455            proxy_target: None,
456            proxy_tls: false,
457            proxy_block: false,
458            proxy_log: None,
459        }
460    }
461}
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466    use crate::rules::{Confidence, RuleSeverity, Severity};
467    use clap::CommandFactory;
468
469    #[test]
470    fn test_cli_valid() {
471        Cli::command().debug_assert();
472    }
473
474    #[test]
475    fn test_parse_basic_args() {
476        let cli = Cli::try_parse_from(["cc-audit", "./skill/"]).unwrap();
477        assert_eq!(cli.paths.len(), 1);
478        assert!(!cli.strict);
479        assert!(!cli.recursive);
480    }
481
482    #[test]
483    fn test_parse_multiple_paths() {
484        let cli = Cli::try_parse_from(["cc-audit", "./skill1/", "./skill2/"]).unwrap();
485        assert_eq!(cli.paths.len(), 2);
486    }
487
488    #[test]
489    fn test_parse_format_json() {
490        let cli = Cli::try_parse_from(["cc-audit", "--format", "json", "./skill/"]).unwrap();
491        assert!(matches!(cli.format, OutputFormat::Json));
492    }
493
494    #[test]
495    fn test_parse_strict_mode() {
496        let cli = Cli::try_parse_from(["cc-audit", "--strict", "./skill/"]).unwrap();
497        assert!(cli.strict);
498    }
499
500    #[test]
501    fn test_parse_recursive() {
502        let cli = Cli::try_parse_from(["cc-audit", "-r", "./skills/"]).unwrap();
503        assert!(cli.recursive);
504    }
505
506    #[test]
507    fn test_parse_format_sarif() {
508        let cli = Cli::try_parse_from(["cc-audit", "--format", "sarif", "./skill/"]).unwrap();
509        assert!(matches!(cli.format, OutputFormat::Sarif));
510    }
511
512    #[test]
513    fn test_parse_type_hook() {
514        let cli = Cli::try_parse_from(["cc-audit", "--type", "hook", "./settings.json"]).unwrap();
515        assert!(matches!(cli.scan_type, ScanType::Hook));
516    }
517
518    #[test]
519    fn test_parse_type_mcp() {
520        let cli = Cli::try_parse_from(["cc-audit", "--type", "mcp", "./mcp.json"]).unwrap();
521        assert!(matches!(cli.scan_type, ScanType::Mcp));
522    }
523
524    #[test]
525    fn test_parse_type_command() {
526        let cli = Cli::try_parse_from(["cc-audit", "--type", "command", "./"]).unwrap();
527        assert!(matches!(cli.scan_type, ScanType::Command));
528    }
529
530    #[test]
531    fn test_parse_type_rules() {
532        let cli = Cli::try_parse_from(["cc-audit", "--type", "rules", "./"]).unwrap();
533        assert!(matches!(cli.scan_type, ScanType::Rules));
534    }
535
536    #[test]
537    fn test_parse_type_docker() {
538        let cli = Cli::try_parse_from(["cc-audit", "--type", "docker", "./"]).unwrap();
539        assert!(matches!(cli.scan_type, ScanType::Docker));
540    }
541
542    #[test]
543    fn test_parse_type_dependency() {
544        let cli = Cli::try_parse_from(["cc-audit", "--type", "dependency", "./"]).unwrap();
545        assert!(matches!(cli.scan_type, ScanType::Dependency));
546    }
547
548    #[test]
549    fn test_parse_ci_mode() {
550        let cli = Cli::try_parse_from(["cc-audit", "--ci", "./skill/"]).unwrap();
551        assert!(cli.ci);
552    }
553
554    #[test]
555    fn test_parse_verbose() {
556        let cli = Cli::try_parse_from(["cc-audit", "-v", "./skill/"]).unwrap();
557        assert!(cli.verbose);
558    }
559
560    #[test]
561    fn test_parse_all_options() {
562        let cli = Cli::try_parse_from([
563            "cc-audit",
564            "--format",
565            "json",
566            "--strict",
567            "--type",
568            "hook",
569            "--recursive",
570            "--ci",
571            "--verbose",
572            "./path/",
573        ])
574        .unwrap();
575        assert!(matches!(cli.format, OutputFormat::Json));
576        assert!(cli.strict);
577        assert!(matches!(cli.scan_type, ScanType::Hook));
578        assert!(cli.recursive);
579        assert!(cli.ci);
580        assert!(cli.verbose);
581    }
582
583    #[test]
584    fn test_default_values() {
585        let cli = Cli::try_parse_from(["cc-audit", "./skill/"]).unwrap();
586        assert!(matches!(cli.format, OutputFormat::Terminal));
587        assert!(matches!(cli.scan_type, ScanType::Skill));
588        assert!(!cli.strict);
589        assert!(!cli.recursive);
590        assert!(!cli.ci);
591        assert!(!cli.verbose);
592        assert!(!cli.include_tests);
593        assert!(!cli.include_node_modules);
594        assert!(!cli.include_vendor);
595        assert!(matches!(cli.min_confidence, Confidence::Tentative));
596    }
597
598    #[test]
599    fn test_parse_include_tests() {
600        let cli = Cli::try_parse_from(["cc-audit", "--include-tests", "./skill/"]).unwrap();
601        assert!(cli.include_tests);
602    }
603
604    #[test]
605    fn test_parse_include_node_modules() {
606        let cli = Cli::try_parse_from(["cc-audit", "--include-node-modules", "./skill/"]).unwrap();
607        assert!(cli.include_node_modules);
608    }
609
610    #[test]
611    fn test_parse_include_vendor() {
612        let cli = Cli::try_parse_from(["cc-audit", "--include-vendor", "./skill/"]).unwrap();
613        assert!(cli.include_vendor);
614    }
615
616    #[test]
617    fn test_parse_all_include_options() {
618        let cli = Cli::try_parse_from([
619            "cc-audit",
620            "--include-tests",
621            "--include-node-modules",
622            "--include-vendor",
623            "./skill/",
624        ])
625        .unwrap();
626        assert!(cli.include_tests);
627        assert!(cli.include_node_modules);
628        assert!(cli.include_vendor);
629    }
630
631    #[test]
632    fn test_parse_min_confidence_tentative() {
633        let cli =
634            Cli::try_parse_from(["cc-audit", "--min-confidence", "tentative", "./skill/"]).unwrap();
635        assert!(matches!(cli.min_confidence, Confidence::Tentative));
636    }
637
638    #[test]
639    fn test_parse_min_confidence_firm() {
640        let cli =
641            Cli::try_parse_from(["cc-audit", "--min-confidence", "firm", "./skill/"]).unwrap();
642        assert!(matches!(cli.min_confidence, Confidence::Firm));
643    }
644
645    #[test]
646    fn test_parse_min_confidence_certain() {
647        let cli =
648            Cli::try_parse_from(["cc-audit", "--min-confidence", "certain", "./skill/"]).unwrap();
649        assert!(matches!(cli.min_confidence, Confidence::Certain));
650    }
651
652    #[test]
653    fn test_parse_skip_comments() {
654        let cli = Cli::try_parse_from(["cc-audit", "--skip-comments", "./skill/"]).unwrap();
655        assert!(cli.skip_comments);
656    }
657
658    #[test]
659    fn test_default_skip_comments_false() {
660        let cli = Cli::try_parse_from(["cc-audit", "./skill/"]).unwrap();
661        assert!(!cli.skip_comments);
662    }
663
664    #[test]
665    fn test_parse_fix_hint() {
666        let cli = Cli::try_parse_from(["cc-audit", "--fix-hint", "./skill/"]).unwrap();
667        assert!(cli.fix_hint);
668    }
669
670    #[test]
671    fn test_default_fix_hint_false() {
672        let cli = Cli::try_parse_from(["cc-audit", "./skill/"]).unwrap();
673        assert!(!cli.fix_hint);
674    }
675
676    #[test]
677    fn test_parse_watch() {
678        let cli = Cli::try_parse_from(["cc-audit", "--watch", "./skill/"]).unwrap();
679        assert!(cli.watch);
680    }
681
682    #[test]
683    fn test_parse_watch_short() {
684        let cli = Cli::try_parse_from(["cc-audit", "-w", "./skill/"]).unwrap();
685        assert!(cli.watch);
686    }
687
688    #[test]
689    fn test_default_watch_false() {
690        let cli = Cli::try_parse_from(["cc-audit", "./skill/"]).unwrap();
691        assert!(!cli.watch);
692    }
693
694    #[test]
695    fn test_parse_init_hook() {
696        let cli = Cli::try_parse_from(["cc-audit", "--init-hook", "./repo/"]).unwrap();
697        assert!(cli.init_hook);
698    }
699
700    #[test]
701    fn test_parse_remove_hook() {
702        let cli = Cli::try_parse_from(["cc-audit", "--remove-hook", "./repo/"]).unwrap();
703        assert!(cli.remove_hook);
704    }
705
706    #[test]
707    fn test_default_init_hook_false() {
708        let cli = Cli::try_parse_from(["cc-audit", "./skill/"]).unwrap();
709        assert!(!cli.init_hook);
710    }
711
712    #[test]
713    fn test_default_remove_hook_false() {
714        let cli = Cli::try_parse_from(["cc-audit", "./skill/"]).unwrap();
715        assert!(!cli.remove_hook);
716    }
717
718    #[test]
719    fn test_parse_malware_db() {
720        let cli =
721            Cli::try_parse_from(["cc-audit", "--malware-db", "./custom.json", "./skill/"]).unwrap();
722        assert!(cli.malware_db.is_some());
723        assert_eq!(cli.malware_db.unwrap().to_str().unwrap(), "./custom.json");
724    }
725
726    #[test]
727    fn test_default_malware_db_none() {
728        let cli = Cli::try_parse_from(["cc-audit", "./skill/"]).unwrap();
729        assert!(cli.malware_db.is_none());
730    }
731
732    #[test]
733    fn test_parse_no_malware_scan() {
734        let cli = Cli::try_parse_from(["cc-audit", "--no-malware-scan", "./skill/"]).unwrap();
735        assert!(cli.no_malware_scan);
736    }
737
738    #[test]
739    fn test_default_no_malware_scan_false() {
740        let cli = Cli::try_parse_from(["cc-audit", "./skill/"]).unwrap();
741        assert!(!cli.no_malware_scan);
742    }
743
744    #[test]
745    fn test_parse_custom_rules() {
746        let cli = Cli::try_parse_from(["cc-audit", "--custom-rules", "./rules.yaml", "./skill/"])
747            .unwrap();
748        assert!(cli.custom_rules.is_some());
749        assert_eq!(cli.custom_rules.unwrap().to_str().unwrap(), "./rules.yaml");
750    }
751
752    #[test]
753    fn test_default_custom_rules_none() {
754        let cli = Cli::try_parse_from(["cc-audit", "./skill/"]).unwrap();
755        assert!(cli.custom_rules.is_none());
756    }
757
758    #[test]
759    fn test_parse_init() {
760        let cli = Cli::try_parse_from(["cc-audit", "--init", "./"]).unwrap();
761        assert!(cli.init);
762    }
763
764    #[test]
765    fn test_default_init_false() {
766        let cli = Cli::try_parse_from(["cc-audit", "./skill/"]).unwrap();
767        assert!(!cli.init);
768    }
769
770    #[test]
771    fn test_parse_warn_only() {
772        let cli = Cli::try_parse_from(["cc-audit", "--warn-only", "./skill/"]).unwrap();
773        assert!(cli.warn_only);
774    }
775
776    #[test]
777    fn test_default_warn_only_false() {
778        let cli = Cli::try_parse_from(["cc-audit", "./skill/"]).unwrap();
779        assert!(!cli.warn_only);
780    }
781
782    #[test]
783    fn test_parse_min_severity_critical() {
784        let cli =
785            Cli::try_parse_from(["cc-audit", "--min-severity", "critical", "./skill/"]).unwrap();
786        assert_eq!(cli.min_severity, Some(Severity::Critical));
787    }
788
789    #[test]
790    fn test_parse_min_severity_high() {
791        let cli = Cli::try_parse_from(["cc-audit", "--min-severity", "high", "./skill/"]).unwrap();
792        assert_eq!(cli.min_severity, Some(Severity::High));
793    }
794
795    #[test]
796    fn test_parse_min_severity_medium() {
797        let cli =
798            Cli::try_parse_from(["cc-audit", "--min-severity", "medium", "./skill/"]).unwrap();
799        assert_eq!(cli.min_severity, Some(Severity::Medium));
800    }
801
802    #[test]
803    fn test_parse_min_severity_low() {
804        let cli = Cli::try_parse_from(["cc-audit", "--min-severity", "low", "./skill/"]).unwrap();
805        assert_eq!(cli.min_severity, Some(Severity::Low));
806    }
807
808    #[test]
809    fn test_default_min_severity_none() {
810        let cli = Cli::try_parse_from(["cc-audit", "./skill/"]).unwrap();
811        assert!(cli.min_severity.is_none());
812    }
813
814    #[test]
815    fn test_parse_min_rule_severity_error() {
816        let cli =
817            Cli::try_parse_from(["cc-audit", "--min-rule-severity", "error", "./skill/"]).unwrap();
818        assert_eq!(cli.min_rule_severity, Some(RuleSeverity::Error));
819    }
820
821    #[test]
822    fn test_parse_min_rule_severity_warn() {
823        let cli =
824            Cli::try_parse_from(["cc-audit", "--min-rule-severity", "warn", "./skill/"]).unwrap();
825        assert_eq!(cli.min_rule_severity, Some(RuleSeverity::Warn));
826    }
827
828    #[test]
829    fn test_default_min_rule_severity_none() {
830        let cli = Cli::try_parse_from(["cc-audit", "./skill/"]).unwrap();
831        assert!(cli.min_rule_severity.is_none());
832    }
833
834    #[test]
835    fn test_warn_only_with_strict_conflict() {
836        // Both options can be parsed, but logic will determine behavior
837        let cli = Cli::try_parse_from(["cc-audit", "--warn-only", "--strict", "./skill/"]).unwrap();
838        assert!(cli.warn_only);
839        assert!(cli.strict);
840    }
841
842    #[test]
843    fn test_parse_all_clients() {
844        let cli = Cli::try_parse_from(["cc-audit", "--all-clients"]).unwrap();
845        assert!(cli.all_clients);
846        assert!(cli.paths.is_empty());
847    }
848
849    #[test]
850    fn test_parse_client_claude() {
851        let cli = Cli::try_parse_from(["cc-audit", "--client", "claude"]).unwrap();
852        assert_eq!(cli.client, Some(ClientType::Claude));
853        assert!(cli.paths.is_empty());
854    }
855
856    #[test]
857    fn test_parse_client_cursor() {
858        let cli = Cli::try_parse_from(["cc-audit", "--client", "cursor"]).unwrap();
859        assert_eq!(cli.client, Some(ClientType::Cursor));
860    }
861
862    #[test]
863    fn test_parse_client_windsurf() {
864        let cli = Cli::try_parse_from(["cc-audit", "--client", "windsurf"]).unwrap();
865        assert_eq!(cli.client, Some(ClientType::Windsurf));
866    }
867
868    #[test]
869    fn test_parse_client_vscode() {
870        let cli = Cli::try_parse_from(["cc-audit", "--client", "vscode"]).unwrap();
871        assert_eq!(cli.client, Some(ClientType::Vscode));
872    }
873
874    #[test]
875    fn test_all_clients_conflicts_with_client() {
876        let result = Cli::try_parse_from(["cc-audit", "--all-clients", "--client", "claude"]);
877        assert!(result.is_err());
878    }
879
880    #[test]
881    fn test_all_clients_conflicts_with_remote() {
882        let result = Cli::try_parse_from([
883            "cc-audit",
884            "--all-clients",
885            "--remote",
886            "https://github.com/x/y",
887        ]);
888        assert!(result.is_err());
889    }
890
891    #[test]
892    fn test_default_client_none() {
893        let cli = Cli::try_parse_from(["cc-audit", "./skill/"]).unwrap();
894        assert!(cli.client.is_none());
895        assert!(!cli.all_clients);
896    }
897}