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"])]
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    /// Show fix hints in terminal output
208    #[arg(long)]
209    pub fix_hint: bool,
210
211    /// Watch mode: continuously monitor files for changes and re-scan
212    #[arg(short, long)]
213    pub watch: bool,
214
215    /// Install cc-audit pre-commit hook in the git repository
216    #[arg(long)]
217    pub init_hook: bool,
218
219    /// Remove cc-audit pre-commit hook from the git repository
220    #[arg(long)]
221    pub remove_hook: bool,
222
223    /// Path to a custom malware signatures database (JSON)
224    #[arg(long)]
225    pub malware_db: Option<PathBuf>,
226
227    /// Disable malware signature scanning
228    #[arg(long)]
229    pub no_malware_scan: bool,
230
231    /// Path to a custom CVE database (JSON)
232    #[arg(long)]
233    pub cve_db: Option<PathBuf>,
234
235    /// Disable CVE vulnerability scanning
236    #[arg(long)]
237    pub no_cve_scan: bool,
238
239    /// Path to a custom rules file (YAML format)
240    #[arg(long)]
241    pub custom_rules: Option<PathBuf>,
242
243    /// Create a baseline snapshot for drift detection (rug pull prevention)
244    #[arg(long)]
245    pub baseline: bool,
246
247    /// Check for drift against saved baseline
248    #[arg(long)]
249    pub check_drift: bool,
250
251    /// Generate a default configuration file template
252    #[arg(long)]
253    pub init: bool,
254
255    /// Output file path (for HTML/JSON output)
256    #[arg(short, long)]
257    pub output: Option<PathBuf>,
258
259    /// Save baseline to specified file
260    #[arg(long, value_name = "FILE")]
261    pub save_baseline: Option<PathBuf>,
262
263    /// Compare against baseline file (show only new findings)
264    #[arg(long, value_name = "FILE")]
265    pub baseline_file: Option<PathBuf>,
266
267    /// Compare two paths and show differences
268    #[arg(long, num_args = 2, value_names = ["PATH1", "PATH2"])]
269    pub compare: Option<Vec<PathBuf>>,
270
271    /// Auto-fix issues (where possible)
272    #[arg(long)]
273    pub fix: bool,
274
275    /// Preview auto-fix changes without applying them
276    #[arg(long)]
277    pub fix_dry_run: bool,
278
279    /// Run as MCP server
280    #[arg(long)]
281    pub mcp_server: bool,
282
283    /// Enable deep scan with deobfuscation
284    #[arg(long)]
285    pub deep_scan: bool,
286
287    /// Load settings from a named profile
288    #[arg(long, value_name = "NAME")]
289    pub profile: Option<String>,
290
291    /// Save current settings as a named profile
292    #[arg(long, value_name = "NAME")]
293    pub save_profile: Option<String>,
294}
295
296impl Default for Cli {
297    fn default() -> Self {
298        Self {
299            paths: Vec::new(),
300            all_clients: false,
301            client: None,
302            remote: None,
303            git_ref: "HEAD".to_string(),
304            remote_auth: None,
305            remote_list: None,
306            awesome_claude_code: false,
307            parallel_clones: 4,
308            badge: false,
309            badge_format: BadgeFormat::Markdown,
310            summary: false,
311            format: OutputFormat::Terminal,
312            strict: false,
313            warn_only: false,
314            min_severity: None,
315            min_rule_severity: None,
316            scan_type: ScanType::Skill,
317            recursive: false,
318            ci: false,
319            verbose: false,
320            include_tests: false,
321            include_node_modules: false,
322            include_vendor: false,
323            min_confidence: Confidence::Tentative,
324            skip_comments: false,
325            fix_hint: false,
326            watch: false,
327            init_hook: false,
328            remove_hook: false,
329            malware_db: None,
330            no_malware_scan: false,
331            cve_db: None,
332            no_cve_scan: false,
333            custom_rules: None,
334            baseline: false,
335            check_drift: false,
336            init: false,
337            output: None,
338            save_baseline: None,
339            baseline_file: None,
340            compare: None,
341            fix: false,
342            fix_dry_run: false,
343            mcp_server: false,
344            deep_scan: false,
345            profile: None,
346            save_profile: None,
347        }
348    }
349}
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354    use crate::rules::{Confidence, RuleSeverity, Severity};
355    use clap::CommandFactory;
356
357    #[test]
358    fn test_cli_valid() {
359        Cli::command().debug_assert();
360    }
361
362    #[test]
363    fn test_parse_basic_args() {
364        let cli = Cli::try_parse_from(["cc-audit", "./skill/"]).unwrap();
365        assert_eq!(cli.paths.len(), 1);
366        assert!(!cli.strict);
367        assert!(!cli.recursive);
368    }
369
370    #[test]
371    fn test_parse_multiple_paths() {
372        let cli = Cli::try_parse_from(["cc-audit", "./skill1/", "./skill2/"]).unwrap();
373        assert_eq!(cli.paths.len(), 2);
374    }
375
376    #[test]
377    fn test_parse_format_json() {
378        let cli = Cli::try_parse_from(["cc-audit", "--format", "json", "./skill/"]).unwrap();
379        assert!(matches!(cli.format, OutputFormat::Json));
380    }
381
382    #[test]
383    fn test_parse_strict_mode() {
384        let cli = Cli::try_parse_from(["cc-audit", "--strict", "./skill/"]).unwrap();
385        assert!(cli.strict);
386    }
387
388    #[test]
389    fn test_parse_recursive() {
390        let cli = Cli::try_parse_from(["cc-audit", "-r", "./skills/"]).unwrap();
391        assert!(cli.recursive);
392    }
393
394    #[test]
395    fn test_parse_format_sarif() {
396        let cli = Cli::try_parse_from(["cc-audit", "--format", "sarif", "./skill/"]).unwrap();
397        assert!(matches!(cli.format, OutputFormat::Sarif));
398    }
399
400    #[test]
401    fn test_parse_type_hook() {
402        let cli = Cli::try_parse_from(["cc-audit", "--type", "hook", "./settings.json"]).unwrap();
403        assert!(matches!(cli.scan_type, ScanType::Hook));
404    }
405
406    #[test]
407    fn test_parse_type_mcp() {
408        let cli = Cli::try_parse_from(["cc-audit", "--type", "mcp", "./mcp.json"]).unwrap();
409        assert!(matches!(cli.scan_type, ScanType::Mcp));
410    }
411
412    #[test]
413    fn test_parse_type_command() {
414        let cli = Cli::try_parse_from(["cc-audit", "--type", "command", "./"]).unwrap();
415        assert!(matches!(cli.scan_type, ScanType::Command));
416    }
417
418    #[test]
419    fn test_parse_type_rules() {
420        let cli = Cli::try_parse_from(["cc-audit", "--type", "rules", "./"]).unwrap();
421        assert!(matches!(cli.scan_type, ScanType::Rules));
422    }
423
424    #[test]
425    fn test_parse_type_docker() {
426        let cli = Cli::try_parse_from(["cc-audit", "--type", "docker", "./"]).unwrap();
427        assert!(matches!(cli.scan_type, ScanType::Docker));
428    }
429
430    #[test]
431    fn test_parse_type_dependency() {
432        let cli = Cli::try_parse_from(["cc-audit", "--type", "dependency", "./"]).unwrap();
433        assert!(matches!(cli.scan_type, ScanType::Dependency));
434    }
435
436    #[test]
437    fn test_parse_ci_mode() {
438        let cli = Cli::try_parse_from(["cc-audit", "--ci", "./skill/"]).unwrap();
439        assert!(cli.ci);
440    }
441
442    #[test]
443    fn test_parse_verbose() {
444        let cli = Cli::try_parse_from(["cc-audit", "-v", "./skill/"]).unwrap();
445        assert!(cli.verbose);
446    }
447
448    #[test]
449    fn test_parse_all_options() {
450        let cli = Cli::try_parse_from([
451            "cc-audit",
452            "--format",
453            "json",
454            "--strict",
455            "--type",
456            "hook",
457            "--recursive",
458            "--ci",
459            "--verbose",
460            "./path/",
461        ])
462        .unwrap();
463        assert!(matches!(cli.format, OutputFormat::Json));
464        assert!(cli.strict);
465        assert!(matches!(cli.scan_type, ScanType::Hook));
466        assert!(cli.recursive);
467        assert!(cli.ci);
468        assert!(cli.verbose);
469    }
470
471    #[test]
472    fn test_default_values() {
473        let cli = Cli::try_parse_from(["cc-audit", "./skill/"]).unwrap();
474        assert!(matches!(cli.format, OutputFormat::Terminal));
475        assert!(matches!(cli.scan_type, ScanType::Skill));
476        assert!(!cli.strict);
477        assert!(!cli.recursive);
478        assert!(!cli.ci);
479        assert!(!cli.verbose);
480        assert!(!cli.include_tests);
481        assert!(!cli.include_node_modules);
482        assert!(!cli.include_vendor);
483        assert!(matches!(cli.min_confidence, Confidence::Tentative));
484    }
485
486    #[test]
487    fn test_parse_include_tests() {
488        let cli = Cli::try_parse_from(["cc-audit", "--include-tests", "./skill/"]).unwrap();
489        assert!(cli.include_tests);
490    }
491
492    #[test]
493    fn test_parse_include_node_modules() {
494        let cli = Cli::try_parse_from(["cc-audit", "--include-node-modules", "./skill/"]).unwrap();
495        assert!(cli.include_node_modules);
496    }
497
498    #[test]
499    fn test_parse_include_vendor() {
500        let cli = Cli::try_parse_from(["cc-audit", "--include-vendor", "./skill/"]).unwrap();
501        assert!(cli.include_vendor);
502    }
503
504    #[test]
505    fn test_parse_all_include_options() {
506        let cli = Cli::try_parse_from([
507            "cc-audit",
508            "--include-tests",
509            "--include-node-modules",
510            "--include-vendor",
511            "./skill/",
512        ])
513        .unwrap();
514        assert!(cli.include_tests);
515        assert!(cli.include_node_modules);
516        assert!(cli.include_vendor);
517    }
518
519    #[test]
520    fn test_parse_min_confidence_tentative() {
521        let cli =
522            Cli::try_parse_from(["cc-audit", "--min-confidence", "tentative", "./skill/"]).unwrap();
523        assert!(matches!(cli.min_confidence, Confidence::Tentative));
524    }
525
526    #[test]
527    fn test_parse_min_confidence_firm() {
528        let cli =
529            Cli::try_parse_from(["cc-audit", "--min-confidence", "firm", "./skill/"]).unwrap();
530        assert!(matches!(cli.min_confidence, Confidence::Firm));
531    }
532
533    #[test]
534    fn test_parse_min_confidence_certain() {
535        let cli =
536            Cli::try_parse_from(["cc-audit", "--min-confidence", "certain", "./skill/"]).unwrap();
537        assert!(matches!(cli.min_confidence, Confidence::Certain));
538    }
539
540    #[test]
541    fn test_parse_skip_comments() {
542        let cli = Cli::try_parse_from(["cc-audit", "--skip-comments", "./skill/"]).unwrap();
543        assert!(cli.skip_comments);
544    }
545
546    #[test]
547    fn test_default_skip_comments_false() {
548        let cli = Cli::try_parse_from(["cc-audit", "./skill/"]).unwrap();
549        assert!(!cli.skip_comments);
550    }
551
552    #[test]
553    fn test_parse_fix_hint() {
554        let cli = Cli::try_parse_from(["cc-audit", "--fix-hint", "./skill/"]).unwrap();
555        assert!(cli.fix_hint);
556    }
557
558    #[test]
559    fn test_default_fix_hint_false() {
560        let cli = Cli::try_parse_from(["cc-audit", "./skill/"]).unwrap();
561        assert!(!cli.fix_hint);
562    }
563
564    #[test]
565    fn test_parse_watch() {
566        let cli = Cli::try_parse_from(["cc-audit", "--watch", "./skill/"]).unwrap();
567        assert!(cli.watch);
568    }
569
570    #[test]
571    fn test_parse_watch_short() {
572        let cli = Cli::try_parse_from(["cc-audit", "-w", "./skill/"]).unwrap();
573        assert!(cli.watch);
574    }
575
576    #[test]
577    fn test_default_watch_false() {
578        let cli = Cli::try_parse_from(["cc-audit", "./skill/"]).unwrap();
579        assert!(!cli.watch);
580    }
581
582    #[test]
583    fn test_parse_init_hook() {
584        let cli = Cli::try_parse_from(["cc-audit", "--init-hook", "./repo/"]).unwrap();
585        assert!(cli.init_hook);
586    }
587
588    #[test]
589    fn test_parse_remove_hook() {
590        let cli = Cli::try_parse_from(["cc-audit", "--remove-hook", "./repo/"]).unwrap();
591        assert!(cli.remove_hook);
592    }
593
594    #[test]
595    fn test_default_init_hook_false() {
596        let cli = Cli::try_parse_from(["cc-audit", "./skill/"]).unwrap();
597        assert!(!cli.init_hook);
598    }
599
600    #[test]
601    fn test_default_remove_hook_false() {
602        let cli = Cli::try_parse_from(["cc-audit", "./skill/"]).unwrap();
603        assert!(!cli.remove_hook);
604    }
605
606    #[test]
607    fn test_parse_malware_db() {
608        let cli =
609            Cli::try_parse_from(["cc-audit", "--malware-db", "./custom.json", "./skill/"]).unwrap();
610        assert!(cli.malware_db.is_some());
611        assert_eq!(cli.malware_db.unwrap().to_str().unwrap(), "./custom.json");
612    }
613
614    #[test]
615    fn test_default_malware_db_none() {
616        let cli = Cli::try_parse_from(["cc-audit", "./skill/"]).unwrap();
617        assert!(cli.malware_db.is_none());
618    }
619
620    #[test]
621    fn test_parse_no_malware_scan() {
622        let cli = Cli::try_parse_from(["cc-audit", "--no-malware-scan", "./skill/"]).unwrap();
623        assert!(cli.no_malware_scan);
624    }
625
626    #[test]
627    fn test_default_no_malware_scan_false() {
628        let cli = Cli::try_parse_from(["cc-audit", "./skill/"]).unwrap();
629        assert!(!cli.no_malware_scan);
630    }
631
632    #[test]
633    fn test_parse_custom_rules() {
634        let cli = Cli::try_parse_from(["cc-audit", "--custom-rules", "./rules.yaml", "./skill/"])
635            .unwrap();
636        assert!(cli.custom_rules.is_some());
637        assert_eq!(cli.custom_rules.unwrap().to_str().unwrap(), "./rules.yaml");
638    }
639
640    #[test]
641    fn test_default_custom_rules_none() {
642        let cli = Cli::try_parse_from(["cc-audit", "./skill/"]).unwrap();
643        assert!(cli.custom_rules.is_none());
644    }
645
646    #[test]
647    fn test_parse_init() {
648        let cli = Cli::try_parse_from(["cc-audit", "--init", "./"]).unwrap();
649        assert!(cli.init);
650    }
651
652    #[test]
653    fn test_default_init_false() {
654        let cli = Cli::try_parse_from(["cc-audit", "./skill/"]).unwrap();
655        assert!(!cli.init);
656    }
657
658    #[test]
659    fn test_parse_warn_only() {
660        let cli = Cli::try_parse_from(["cc-audit", "--warn-only", "./skill/"]).unwrap();
661        assert!(cli.warn_only);
662    }
663
664    #[test]
665    fn test_default_warn_only_false() {
666        let cli = Cli::try_parse_from(["cc-audit", "./skill/"]).unwrap();
667        assert!(!cli.warn_only);
668    }
669
670    #[test]
671    fn test_parse_min_severity_critical() {
672        let cli =
673            Cli::try_parse_from(["cc-audit", "--min-severity", "critical", "./skill/"]).unwrap();
674        assert_eq!(cli.min_severity, Some(Severity::Critical));
675    }
676
677    #[test]
678    fn test_parse_min_severity_high() {
679        let cli = Cli::try_parse_from(["cc-audit", "--min-severity", "high", "./skill/"]).unwrap();
680        assert_eq!(cli.min_severity, Some(Severity::High));
681    }
682
683    #[test]
684    fn test_parse_min_severity_medium() {
685        let cli =
686            Cli::try_parse_from(["cc-audit", "--min-severity", "medium", "./skill/"]).unwrap();
687        assert_eq!(cli.min_severity, Some(Severity::Medium));
688    }
689
690    #[test]
691    fn test_parse_min_severity_low() {
692        let cli = Cli::try_parse_from(["cc-audit", "--min-severity", "low", "./skill/"]).unwrap();
693        assert_eq!(cli.min_severity, Some(Severity::Low));
694    }
695
696    #[test]
697    fn test_default_min_severity_none() {
698        let cli = Cli::try_parse_from(["cc-audit", "./skill/"]).unwrap();
699        assert!(cli.min_severity.is_none());
700    }
701
702    #[test]
703    fn test_parse_min_rule_severity_error() {
704        let cli =
705            Cli::try_parse_from(["cc-audit", "--min-rule-severity", "error", "./skill/"]).unwrap();
706        assert_eq!(cli.min_rule_severity, Some(RuleSeverity::Error));
707    }
708
709    #[test]
710    fn test_parse_min_rule_severity_warn() {
711        let cli =
712            Cli::try_parse_from(["cc-audit", "--min-rule-severity", "warn", "./skill/"]).unwrap();
713        assert_eq!(cli.min_rule_severity, Some(RuleSeverity::Warn));
714    }
715
716    #[test]
717    fn test_default_min_rule_severity_none() {
718        let cli = Cli::try_parse_from(["cc-audit", "./skill/"]).unwrap();
719        assert!(cli.min_rule_severity.is_none());
720    }
721
722    #[test]
723    fn test_warn_only_with_strict_conflict() {
724        // Both options can be parsed, but logic will determine behavior
725        let cli = Cli::try_parse_from(["cc-audit", "--warn-only", "--strict", "./skill/"]).unwrap();
726        assert!(cli.warn_only);
727        assert!(cli.strict);
728    }
729
730    #[test]
731    fn test_parse_all_clients() {
732        let cli = Cli::try_parse_from(["cc-audit", "--all-clients"]).unwrap();
733        assert!(cli.all_clients);
734        assert!(cli.paths.is_empty());
735    }
736
737    #[test]
738    fn test_parse_client_claude() {
739        let cli = Cli::try_parse_from(["cc-audit", "--client", "claude"]).unwrap();
740        assert_eq!(cli.client, Some(ClientType::Claude));
741        assert!(cli.paths.is_empty());
742    }
743
744    #[test]
745    fn test_parse_client_cursor() {
746        let cli = Cli::try_parse_from(["cc-audit", "--client", "cursor"]).unwrap();
747        assert_eq!(cli.client, Some(ClientType::Cursor));
748    }
749
750    #[test]
751    fn test_parse_client_windsurf() {
752        let cli = Cli::try_parse_from(["cc-audit", "--client", "windsurf"]).unwrap();
753        assert_eq!(cli.client, Some(ClientType::Windsurf));
754    }
755
756    #[test]
757    fn test_parse_client_vscode() {
758        let cli = Cli::try_parse_from(["cc-audit", "--client", "vscode"]).unwrap();
759        assert_eq!(cli.client, Some(ClientType::Vscode));
760    }
761
762    #[test]
763    fn test_all_clients_conflicts_with_client() {
764        let result = Cli::try_parse_from(["cc-audit", "--all-clients", "--client", "claude"]);
765        assert!(result.is_err());
766    }
767
768    #[test]
769    fn test_all_clients_conflicts_with_remote() {
770        let result = Cli::try_parse_from([
771            "cc-audit",
772            "--all-clients",
773            "--remote",
774            "https://github.com/x/y",
775        ]);
776        assert!(result.is_err());
777    }
778
779    #[test]
780    fn test_default_client_none() {
781        let cli = Cli::try_parse_from(["cc-audit", "./skill/"]).unwrap();
782        assert!(cli.client.is_none());
783        assert!(!cli.all_clients);
784    }
785}