1use crate::error::{AuditError, Result};
2use serde::{Deserialize, Serialize};
3use std::fs;
4use std::path::PathBuf;
5
6#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct Profile {
9 pub name: String,
10 pub description: String,
11 #[serde(default)]
12 pub strict: bool,
13 #[serde(default)]
14 pub recursive: bool,
15 #[serde(default)]
16 pub ci: bool,
17 #[serde(default)]
18 pub verbose: bool,
19 #[serde(default)]
20 pub skip_comments: bool,
21 #[serde(default)]
22 pub fix_hint: bool,
23 #[serde(default)]
24 pub no_malware_scan: bool,
25 #[serde(default)]
26 pub deep_scan: bool,
27 #[serde(default)]
28 pub min_confidence: String,
29 #[serde(default)]
30 pub format: Option<String>,
31 #[serde(default)]
32 pub scan_type: Option<String>,
33 #[serde(default)]
34 pub disabled_rules: Vec<String>,
35}
36
37impl Profile {
38 pub fn builtin(name: &str) -> Option<Self> {
40 match name {
41 "default" => Some(Self::default_profile()),
42 "strict" => Some(Self::strict_profile()),
43 "ci" => Some(Self::ci_profile()),
44 "quick" => Some(Self::quick_profile()),
45 _ => None,
46 }
47 }
48
49 fn default_profile() -> Self {
50 Self {
51 name: "default".to_string(),
52 description: "Default balanced scan configuration".to_string(),
53 strict: false,
54 recursive: true,
55 ci: false,
56 verbose: false,
57 skip_comments: false,
58 fix_hint: false,
59 no_malware_scan: false,
60 deep_scan: false,
61 min_confidence: "tentative".to_string(),
62 format: None,
63 scan_type: None,
64 disabled_rules: vec![],
65 }
66 }
67
68 fn strict_profile() -> Self {
69 Self {
70 name: "strict".to_string(),
71 description: "Strict mode - all findings reported, no rules disabled".to_string(),
72 strict: true,
73 recursive: true,
74 ci: false,
75 verbose: true,
76 skip_comments: false,
77 fix_hint: true,
78 no_malware_scan: false,
79 deep_scan: true,
80 min_confidence: "tentative".to_string(),
81 format: None,
82 scan_type: None,
83 disabled_rules: vec![],
84 }
85 }
86
87 fn ci_profile() -> Self {
88 Self {
89 name: "ci".to_string(),
90 description: "CI/CD optimized - non-interactive, JSON output".to_string(),
91 strict: true,
92 recursive: true,
93 ci: true,
94 verbose: false,
95 skip_comments: true,
96 fix_hint: false,
97 no_malware_scan: false,
98 deep_scan: false,
99 min_confidence: "firm".to_string(),
100 format: Some("json".to_string()),
101 scan_type: None,
102 disabled_rules: vec![],
103 }
104 }
105
106 fn quick_profile() -> Self {
107 Self {
108 name: "quick".to_string(),
109 description: "Quick scan - high confidence only, no deep scan".to_string(),
110 strict: false,
111 recursive: true,
112 ci: false,
113 verbose: false,
114 skip_comments: true,
115 fix_hint: false,
116 no_malware_scan: true, deep_scan: false,
118 min_confidence: "certain".to_string(),
119 format: None,
120 scan_type: None,
121 disabled_rules: vec![],
122 }
123 }
124
125 pub fn load(name: &str) -> Result<Self> {
127 if let Some(profile) = Self::builtin(name) {
129 return Ok(profile);
130 }
131
132 let profile_path = Self::get_profile_path(name)?;
134
135 if !profile_path.exists() {
136 return Err(AuditError::FileNotFound(format!(
137 "Profile '{}' not found. Available built-in profiles: default, strict, ci, quick",
138 name
139 )));
140 }
141
142 let content = fs::read_to_string(&profile_path).map_err(|e| AuditError::ReadError {
143 path: profile_path.display().to_string(),
144 source: e,
145 })?;
146
147 serde_yaml::from_str(&content).map_err(|e| AuditError::ParseError {
148 path: profile_path.display().to_string(),
149 message: e.to_string(),
150 })
151 }
152
153 pub fn save(&self) -> Result<PathBuf> {
155 let profiles_dir = Self::get_profiles_dir()?;
156 fs::create_dir_all(&profiles_dir).map_err(|e| AuditError::ReadError {
157 path: profiles_dir.display().to_string(),
158 source: e,
159 })?;
160
161 let profile_path = profiles_dir.join(format!("{}.yaml", self.name));
162
163 let content = serde_yaml::to_string(self).map_err(|e| AuditError::ParseError {
164 path: profile_path.display().to_string(),
165 message: e.to_string(),
166 })?;
167
168 fs::write(&profile_path, content).map_err(|e| AuditError::ReadError {
169 path: profile_path.display().to_string(),
170 source: e,
171 })?;
172
173 Ok(profile_path)
174 }
175
176 pub fn list_all() -> Vec<String> {
178 let mut profiles = vec![
179 "default".to_string(),
180 "strict".to_string(),
181 "ci".to_string(),
182 "quick".to_string(),
183 ];
184
185 if let Ok(dir) = Self::get_profiles_dir()
187 && let Ok(entries) = fs::read_dir(dir)
188 {
189 for entry in entries.flatten() {
190 if let Some(name) = entry.path().file_stem()
191 && let Some(name_str) = name.to_str()
192 && !profiles.contains(&name_str.to_string())
193 {
194 profiles.push(name_str.to_string());
195 }
196 }
197 }
198
199 profiles
200 }
201
202 fn get_profiles_dir() -> Result<PathBuf> {
203 let home = dirs::home_dir().ok_or_else(|| {
204 AuditError::FileNotFound("Could not determine home directory".to_string())
205 })?;
206
207 Ok(home.join(".config").join("cc-audit").join("profiles"))
208 }
209
210 fn get_profile_path(name: &str) -> Result<PathBuf> {
211 let profiles_dir = Self::get_profiles_dir()?;
212 Ok(profiles_dir.join(format!("{}.yaml", name)))
213 }
214
215 pub fn apply_to_config(&self, config: &mut crate::config::ScanConfig) {
217 config.strict = config.strict || self.strict;
218 config.recursive = config.recursive || self.recursive;
219 config.ci = config.ci || self.ci;
220 config.verbose = config.verbose || self.verbose;
221 config.skip_comments = config.skip_comments || self.skip_comments;
222 config.fix_hint = config.fix_hint || self.fix_hint;
223 config.no_malware_scan = config.no_malware_scan || self.no_malware_scan;
224
225 if !self.min_confidence.is_empty() && config.min_confidence.is_none() {
226 config.min_confidence = Some(self.min_confidence.clone());
227 }
228
229 if let Some(ref format) = self.format
230 && config.format.is_none()
231 {
232 config.format = Some(format.clone());
233 }
234
235 if let Some(ref scan_type) = self.scan_type
236 && config.scan_type.is_none()
237 {
238 config.scan_type = Some(scan_type.clone());
239 }
240 }
241}
242
243impl Default for Profile {
244 fn default() -> Self {
245 Self::default_profile()
246 }
247}
248
249pub fn profile_from_cli(name: &str, cli: &crate::Cli) -> Profile {
251 Profile {
252 name: name.to_string(),
253 description: "Custom profile saved from CLI settings".to_string(),
254 strict: cli.strict,
255 recursive: cli.recursive,
256 ci: cli.ci,
257 verbose: cli.verbose,
258 skip_comments: cli.skip_comments,
259 fix_hint: cli.fix_hint,
260 no_malware_scan: cli.no_malware_scan,
261 deep_scan: cli.deep_scan,
262 min_confidence: format!("{:?}", cli.min_confidence).to_lowercase(),
263 format: Some(format!("{:?}", cli.format).to_lowercase()),
264 scan_type: Some(format!("{:?}", cli.scan_type).to_lowercase()),
265 disabled_rules: vec![],
266 }
267}
268
269#[cfg(test)]
270mod tests {
271 use super::*;
272 use crate::config::ScanConfig;
273
274 #[test]
275 fn test_builtin_profiles() {
276 assert!(Profile::builtin("default").is_some());
277 assert!(Profile::builtin("strict").is_some());
278 assert!(Profile::builtin("ci").is_some());
279 assert!(Profile::builtin("quick").is_some());
280 assert!(Profile::builtin("nonexistent").is_none());
281 }
282
283 #[test]
284 fn test_default_profile() {
285 let profile = Profile::default_profile();
286 assert_eq!(profile.name, "default");
287 assert!(!profile.strict);
288 assert!(profile.recursive);
289 }
290
291 #[test]
292 fn test_strict_profile() {
293 let profile = Profile::strict_profile();
294 assert_eq!(profile.name, "strict");
295 assert!(profile.strict);
296 assert!(profile.verbose);
297 assert!(profile.deep_scan);
298 }
299
300 #[test]
301 fn test_ci_profile() {
302 let profile = Profile::ci_profile();
303 assert_eq!(profile.name, "ci");
304 assert!(profile.ci);
305 assert!(profile.strict);
306 assert_eq!(profile.format, Some("json".to_string()));
307 }
308
309 #[test]
310 fn test_quick_profile() {
311 let profile = Profile::quick_profile();
312 assert_eq!(profile.name, "quick");
313 assert!(profile.no_malware_scan);
314 assert!(!profile.deep_scan);
315 assert_eq!(profile.min_confidence, "certain");
316 }
317
318 #[test]
319 fn test_list_all_includes_builtins() {
320 let profiles = Profile::list_all();
321 assert!(profiles.contains(&"default".to_string()));
322 assert!(profiles.contains(&"strict".to_string()));
323 assert!(profiles.contains(&"ci".to_string()));
324 assert!(profiles.contains(&"quick".to_string()));
325 }
326
327 #[test]
328 fn test_profile_serialize_deserialize() {
329 let profile = Profile::strict_profile();
330 let yaml = serde_yaml::to_string(&profile).unwrap();
331 let parsed: Profile = serde_yaml::from_str(&yaml).unwrap();
332 assert_eq!(profile.name, parsed.name);
333 assert_eq!(profile.strict, parsed.strict);
334 }
335
336 #[test]
337 fn test_default_trait() {
338 let profile = Profile::default();
339 assert_eq!(profile.name, "default");
340 }
341
342 #[test]
343 fn test_load_builtin_profile() {
344 let profile = Profile::load("default").unwrap();
345 assert_eq!(profile.name, "default");
346
347 let profile = Profile::load("strict").unwrap();
348 assert_eq!(profile.name, "strict");
349 }
350
351 #[test]
352 fn test_load_nonexistent_profile() {
353 let result = Profile::load("nonexistent_profile_xyz");
354 assert!(result.is_err());
355 }
356
357 #[test]
358 fn test_apply_to_config_basic() {
359 let profile = Profile::strict_profile();
360 let mut config = ScanConfig::default();
361
362 profile.apply_to_config(&mut config);
363
364 assert!(config.strict);
365 assert!(config.verbose);
366 assert!(config.fix_hint);
367 }
368
369 #[test]
370 fn test_apply_to_config_min_confidence() {
371 let profile = Profile::ci_profile();
372 let mut config = ScanConfig::default();
373
374 profile.apply_to_config(&mut config);
375
376 assert_eq!(config.min_confidence, Some("firm".to_string()));
377 }
378
379 #[test]
380 fn test_apply_to_config_format() {
381 let profile = Profile::ci_profile();
382 let mut config = ScanConfig::default();
383
384 profile.apply_to_config(&mut config);
385
386 assert_eq!(config.format, Some("json".to_string()));
387 }
388
389 #[test]
390 fn test_apply_to_config_does_not_override_existing() {
391 let profile = Profile::ci_profile();
392 let mut config = ScanConfig {
393 format: Some("sarif".to_string()),
394 min_confidence: Some("certain".to_string()),
395 ..Default::default()
396 };
397
398 profile.apply_to_config(&mut config);
399
400 assert_eq!(config.format, Some("sarif".to_string()));
402 assert_eq!(config.min_confidence, Some("certain".to_string()));
403 }
404
405 #[test]
406 fn test_apply_to_config_scan_type() {
407 let mut profile = Profile::default_profile();
408 profile.scan_type = Some("hook".to_string());
409 let mut config = ScanConfig::default();
410
411 profile.apply_to_config(&mut config);
412
413 assert_eq!(config.scan_type, Some("hook".to_string()));
414 }
415
416 #[test]
417 fn test_apply_to_config_no_malware_scan() {
418 let profile = Profile::quick_profile();
419 let mut config = ScanConfig::default();
420
421 profile.apply_to_config(&mut config);
422
423 assert!(config.no_malware_scan);
424 }
425
426 #[test]
427 fn test_apply_to_config_empty_min_confidence() {
428 let mut profile = Profile::default_profile();
429 profile.min_confidence = String::new();
430 let mut config = ScanConfig::default();
431
432 profile.apply_to_config(&mut config);
433
434 assert!(config.min_confidence.is_none());
436 }
437
438 #[test]
439 fn test_get_profiles_dir() {
440 let result = Profile::get_profiles_dir();
441 assert!(result.is_ok());
442 let path = result.unwrap();
443 assert!(path.ends_with("profiles"));
444 }
445
446 #[test]
447 fn test_get_profile_path() {
448 let result = Profile::get_profile_path("test_profile");
449 assert!(result.is_ok());
450 let path = result.unwrap();
451 assert!(path.ends_with("test_profile.yaml"));
452 }
453
454 #[test]
455 fn test_profile_debug_trait() {
456 let profile = Profile::default();
457 let debug_str = format!("{:?}", profile);
458 assert!(debug_str.contains("Profile"));
459 assert!(debug_str.contains("default"));
460 }
461
462 #[test]
463 fn test_profile_clone_trait() {
464 let profile = Profile::strict_profile();
465 let cloned = profile.clone();
466 assert_eq!(profile.name, cloned.name);
467 assert_eq!(profile.strict, cloned.strict);
468 }
469
470 #[test]
471 fn test_profile_from_cli() {
472 use crate::Cli;
473 use clap::Parser;
474
475 let cli = Cli::parse_from(["cc-audit", "--strict", "--verbose", "."]);
476 let profile = profile_from_cli("test_profile", &cli);
477
478 assert_eq!(profile.name, "test_profile");
479 assert!(profile.strict);
480 assert!(profile.verbose);
481 assert!(profile.description.contains("Custom profile"));
482 }
483
484 #[test]
485 fn test_profile_from_cli_with_options() {
486 use crate::Cli;
487 use clap::Parser;
488
489 let cli = Cli::parse_from([
490 "cc-audit",
491 "--skip-comments",
492 "--fix-hint",
493 "--no-malware-scan",
494 "--deep-scan",
495 ".",
496 ]);
497 let profile = profile_from_cli("custom", &cli);
498
499 assert!(profile.skip_comments);
500 assert!(profile.fix_hint);
501 assert!(profile.no_malware_scan);
502 assert!(profile.deep_scan);
503 }
504
505 #[test]
506 fn test_profile_from_cli_format_and_type() {
507 use crate::Cli;
508 use clap::Parser;
509
510 let cli = Cli::parse_from(["cc-audit", "--format", "json", "--type", "hook", "."]);
511 let profile = profile_from_cli("json_profile", &cli);
512
513 assert!(profile.format.is_some());
514 assert!(profile.scan_type.is_some());
515 }
516
517 #[test]
518 fn test_profile_save_and_load() {
519 let profile = Profile {
522 name: "test_save_load_unique_12345".to_string(),
523 description: "Test profile for save/load".to_string(),
524 strict: true,
525 recursive: true,
526 ci: false,
527 verbose: true,
528 skip_comments: false,
529 fix_hint: true,
530 no_malware_scan: false,
531 deep_scan: true,
532 min_confidence: "firm".to_string(),
533 format: Some("json".to_string()),
534 scan_type: Some("hook".to_string()),
535 disabled_rules: vec!["PE-001".to_string()],
536 };
537
538 let save_result = profile.save();
540 assert!(save_result.is_ok());
541 let saved_path = save_result.unwrap();
542 assert!(saved_path.exists());
543
544 let loaded = Profile::load("test_save_load_unique_12345");
546 assert!(loaded.is_ok());
547 let loaded_profile = loaded.unwrap();
548 assert_eq!(loaded_profile.name, "test_save_load_unique_12345");
549 assert!(loaded_profile.strict);
550 assert!(loaded_profile.deep_scan);
551 assert_eq!(loaded_profile.format, Some("json".to_string()));
552
553 let _ = fs::remove_file(saved_path);
555 }
556
557 #[test]
558 fn test_apply_to_config_recursive() {
559 let profile = Profile::default_profile();
560 let mut config = ScanConfig {
561 recursive: false,
562 ..Default::default()
563 };
564
565 profile.apply_to_config(&mut config);
566
567 assert!(config.recursive);
569 }
570
571 #[test]
572 fn test_apply_to_config_ci() {
573 let profile = Profile::ci_profile();
574 let mut config = ScanConfig::default();
575
576 profile.apply_to_config(&mut config);
577
578 assert!(config.ci);
579 }
580
581 #[test]
582 fn test_apply_to_config_skip_comments() {
583 let profile = Profile::quick_profile();
584 let mut config = ScanConfig::default();
585
586 profile.apply_to_config(&mut config);
587
588 assert!(config.skip_comments);
589 }
590
591 #[test]
592 fn test_profile_disabled_rules() {
593 let profile = Profile {
594 name: "test".to_string(),
595 description: "Test".to_string(),
596 strict: false,
597 recursive: true,
598 ci: false,
599 verbose: false,
600 skip_comments: false,
601 fix_hint: false,
602 no_malware_scan: false,
603 deep_scan: false,
604 min_confidence: "tentative".to_string(),
605 format: None,
606 scan_type: None,
607 disabled_rules: vec!["PE-001".to_string(), "SC-001".to_string()],
608 };
609
610 assert_eq!(profile.disabled_rules.len(), 2);
611 assert!(profile.disabled_rules.contains(&"PE-001".to_string()));
612 }
613
614 #[test]
615 fn test_profile_from_cli_ci_mode() {
616 use crate::Cli;
617 use clap::Parser;
618
619 let cli = Cli::parse_from(["cc-audit", "--ci", "."]);
620 let profile = profile_from_cli("ci_profile", &cli);
621
622 assert!(profile.ci);
623 assert!(!profile.recursive); }
625
626 #[test]
627 fn test_profile_from_cli_recursive() {
628 use crate::Cli;
629 use clap::Parser;
630
631 let cli = Cli::parse_from(["cc-audit", "--recursive", "."]);
632 let profile = profile_from_cli("recursive_profile", &cli);
633
634 assert!(profile.recursive);
635 }
636
637 #[test]
638 fn test_list_all_includes_user_profiles() {
639 let profile = Profile {
641 name: "test_user_profile_list_all".to_string(),
642 description: "Test user profile".to_string(),
643 strict: false,
644 recursive: true,
645 ci: false,
646 verbose: false,
647 skip_comments: false,
648 fix_hint: false,
649 no_malware_scan: false,
650 deep_scan: false,
651 min_confidence: "tentative".to_string(),
652 format: None,
653 scan_type: None,
654 disabled_rules: vec![],
655 };
656
657 let save_result = profile.save();
658 assert!(save_result.is_ok());
659 let saved_path = save_result.unwrap();
660
661 let profiles = Profile::list_all();
663 assert!(profiles.contains(&"test_user_profile_list_all".to_string()));
664
665 let _ = fs::remove_file(saved_path);
667 }
668
669 #[test]
670 fn test_load_user_profile_from_file() {
671 let profile = Profile {
673 name: "test_load_user_profile".to_string(),
674 description: "Test for loading".to_string(),
675 strict: true,
676 recursive: false,
677 ci: true,
678 verbose: true,
679 skip_comments: true,
680 fix_hint: true,
681 no_malware_scan: true,
682 deep_scan: true,
683 min_confidence: "certain".to_string(),
684 format: Some("sarif".to_string()),
685 scan_type: Some("docker".to_string()),
686 disabled_rules: vec!["PE-001".to_string()],
687 };
688
689 let save_result = profile.save();
690 assert!(save_result.is_ok());
691 let saved_path = save_result.unwrap();
692
693 let loaded = Profile::load("test_load_user_profile");
695 assert!(loaded.is_ok());
696 let loaded_profile = loaded.unwrap();
697
698 assert_eq!(loaded_profile.name, "test_load_user_profile");
699 assert!(loaded_profile.strict);
700 assert!(!loaded_profile.recursive);
701 assert!(loaded_profile.ci);
702 assert!(loaded_profile.verbose);
703 assert!(loaded_profile.skip_comments);
704 assert!(loaded_profile.fix_hint);
705 assert!(loaded_profile.no_malware_scan);
706 assert!(loaded_profile.deep_scan);
707 assert_eq!(loaded_profile.min_confidence, "certain");
708 assert_eq!(loaded_profile.format, Some("sarif".to_string()));
709 assert_eq!(loaded_profile.scan_type, Some("docker".to_string()));
710 assert_eq!(loaded_profile.disabled_rules, vec!["PE-001".to_string()]);
711
712 let _ = fs::remove_file(saved_path);
714 }
715
716 #[test]
717 fn test_apply_to_config_with_none_format() {
718 let mut profile = Profile::default_profile();
719 profile.format = None;
720 let config = ScanConfig {
721 format: None,
722 ..Default::default()
723 };
724 let mut config = config;
725
726 profile.apply_to_config(&mut config);
727
728 assert!(config.format.is_none());
730 }
731
732 #[test]
733 fn test_apply_to_config_with_none_scan_type() {
734 let mut profile = Profile::default_profile();
735 profile.scan_type = None;
736 let config = ScanConfig {
737 scan_type: None,
738 ..Default::default()
739 };
740 let mut config = config;
741
742 profile.apply_to_config(&mut config);
743
744 assert!(config.scan_type.is_none());
746 }
747
748 #[test]
749 fn test_profile_with_all_fields() {
750 let profile = Profile {
751 name: "complete".to_string(),
752 description: "Complete profile with all fields".to_string(),
753 strict: true,
754 recursive: true,
755 ci: true,
756 verbose: true,
757 skip_comments: true,
758 fix_hint: true,
759 no_malware_scan: true,
760 deep_scan: true,
761 min_confidence: "firm".to_string(),
762 format: Some("html".to_string()),
763 scan_type: Some("mcp".to_string()),
764 disabled_rules: vec!["PE-001".to_string(), "SC-001".to_string()],
765 };
766
767 assert_eq!(profile.name, "complete");
769 assert!(profile.strict);
770 assert!(profile.recursive);
771 assert!(profile.ci);
772 assert!(profile.verbose);
773 assert!(profile.skip_comments);
774 assert!(profile.fix_hint);
775 assert!(profile.no_malware_scan);
776 assert!(profile.deep_scan);
777 assert_eq!(profile.min_confidence, "firm");
778 assert_eq!(profile.format, Some("html".to_string()));
779 assert_eq!(profile.scan_type, Some("mcp".to_string()));
780 assert_eq!(profile.disabled_rules.len(), 2);
781 }
782}