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_check_args(name: &str, args: &crate::CheckArgs, verbose: bool) -> Profile {
251 Profile {
252 name: name.to_string(),
253 description: "Custom profile saved from CLI settings".to_string(),
254 strict: args.strict,
255 recursive: !args.no_recursive,
256 ci: args.ci,
257 verbose,
258 skip_comments: args.skip_comments,
259 fix_hint: args.fix_hint,
260 no_malware_scan: args.no_malware_scan,
261 deep_scan: args.deep_scan,
262 min_confidence: args
263 .min_confidence
264 .map(|c| format!("{:?}", c).to_lowercase())
265 .unwrap_or_else(|| "tentative".to_string()),
266 format: Some(format!("{:?}", args.format).to_lowercase()),
267 scan_type: Some(format!("{:?}", args.scan_type).to_lowercase()),
268 disabled_rules: vec![],
269 }
270}
271
272#[cfg(test)]
273mod tests {
274 use super::*;
275 use crate::config::ScanConfig;
276
277 #[test]
278 fn test_builtin_profiles() {
279 assert!(Profile::builtin("default").is_some());
280 assert!(Profile::builtin("strict").is_some());
281 assert!(Profile::builtin("ci").is_some());
282 assert!(Profile::builtin("quick").is_some());
283 assert!(Profile::builtin("nonexistent").is_none());
284 }
285
286 #[test]
287 fn test_default_profile() {
288 let profile = Profile::default_profile();
289 assert_eq!(profile.name, "default");
290 assert!(!profile.strict);
291 assert!(profile.recursive);
292 }
293
294 #[test]
295 fn test_strict_profile() {
296 let profile = Profile::strict_profile();
297 assert_eq!(profile.name, "strict");
298 assert!(profile.strict);
299 assert!(profile.verbose);
300 assert!(profile.deep_scan);
301 }
302
303 #[test]
304 fn test_ci_profile() {
305 let profile = Profile::ci_profile();
306 assert_eq!(profile.name, "ci");
307 assert!(profile.ci);
308 assert!(profile.strict);
309 assert_eq!(profile.format, Some("json".to_string()));
310 }
311
312 #[test]
313 fn test_quick_profile() {
314 let profile = Profile::quick_profile();
315 assert_eq!(profile.name, "quick");
316 assert!(profile.no_malware_scan);
317 assert!(!profile.deep_scan);
318 assert_eq!(profile.min_confidence, "certain");
319 }
320
321 #[test]
322 fn test_list_all_includes_builtins() {
323 let profiles = Profile::list_all();
324 assert!(profiles.contains(&"default".to_string()));
325 assert!(profiles.contains(&"strict".to_string()));
326 assert!(profiles.contains(&"ci".to_string()));
327 assert!(profiles.contains(&"quick".to_string()));
328 }
329
330 #[test]
331 fn test_profile_serialize_deserialize() {
332 let profile = Profile::strict_profile();
333 let yaml = serde_yaml::to_string(&profile).unwrap();
334 let parsed: Profile = serde_yaml::from_str(&yaml).unwrap();
335 assert_eq!(profile.name, parsed.name);
336 assert_eq!(profile.strict, parsed.strict);
337 }
338
339 #[test]
340 fn test_default_trait() {
341 let profile = Profile::default();
342 assert_eq!(profile.name, "default");
343 }
344
345 #[test]
346 fn test_load_builtin_profile() {
347 let profile = Profile::load("default").unwrap();
348 assert_eq!(profile.name, "default");
349
350 let profile = Profile::load("strict").unwrap();
351 assert_eq!(profile.name, "strict");
352 }
353
354 #[test]
355 fn test_load_nonexistent_profile() {
356 let result = Profile::load("nonexistent_profile_xyz");
357 assert!(result.is_err());
358 }
359
360 #[test]
361 fn test_apply_to_config_basic() {
362 let profile = Profile::strict_profile();
363 let mut config = ScanConfig::default();
364
365 profile.apply_to_config(&mut config);
366
367 assert!(config.strict);
368 assert!(config.verbose);
369 assert!(config.fix_hint);
370 }
371
372 #[test]
373 fn test_apply_to_config_min_confidence() {
374 let profile = Profile::ci_profile();
375 let mut config = ScanConfig::default();
376
377 profile.apply_to_config(&mut config);
378
379 assert_eq!(config.min_confidence, Some("firm".to_string()));
380 }
381
382 #[test]
383 fn test_apply_to_config_format() {
384 let profile = Profile::ci_profile();
385 let mut config = ScanConfig::default();
386
387 profile.apply_to_config(&mut config);
388
389 assert_eq!(config.format, Some("json".to_string()));
390 }
391
392 #[test]
393 fn test_apply_to_config_does_not_override_existing() {
394 let profile = Profile::ci_profile();
395 let mut config = ScanConfig {
396 format: Some("sarif".to_string()),
397 min_confidence: Some("certain".to_string()),
398 ..Default::default()
399 };
400
401 profile.apply_to_config(&mut config);
402
403 assert_eq!(config.format, Some("sarif".to_string()));
405 assert_eq!(config.min_confidence, Some("certain".to_string()));
406 }
407
408 #[test]
409 fn test_apply_to_config_scan_type() {
410 let mut profile = Profile::default_profile();
411 profile.scan_type = Some("hook".to_string());
412 let mut config = ScanConfig::default();
413
414 profile.apply_to_config(&mut config);
415
416 assert_eq!(config.scan_type, Some("hook".to_string()));
417 }
418
419 #[test]
420 fn test_apply_to_config_no_malware_scan() {
421 let profile = Profile::quick_profile();
422 let mut config = ScanConfig::default();
423
424 profile.apply_to_config(&mut config);
425
426 assert!(config.no_malware_scan);
427 }
428
429 #[test]
430 fn test_apply_to_config_empty_min_confidence() {
431 let mut profile = Profile::default_profile();
432 profile.min_confidence = String::new();
433 let mut config = ScanConfig::default();
434
435 profile.apply_to_config(&mut config);
436
437 assert!(config.min_confidence.is_none());
439 }
440
441 #[test]
442 fn test_get_profiles_dir() {
443 let result = Profile::get_profiles_dir();
444 assert!(result.is_ok());
445 let path = result.unwrap();
446 assert!(path.ends_with("profiles"));
447 }
448
449 #[test]
450 fn test_get_profile_path() {
451 let result = Profile::get_profile_path("test_profile");
452 assert!(result.is_ok());
453 let path = result.unwrap();
454 assert!(path.ends_with("test_profile.yaml"));
455 }
456
457 #[test]
458 fn test_profile_debug_trait() {
459 let profile = Profile::default();
460 let debug_str = format!("{:?}", profile);
461 assert!(debug_str.contains("Profile"));
462 assert!(debug_str.contains("default"));
463 }
464
465 #[test]
466 fn test_profile_clone_trait() {
467 let profile = Profile::strict_profile();
468 let cloned = profile.clone();
469 assert_eq!(profile.name, cloned.name);
470 assert_eq!(profile.strict, cloned.strict);
471 }
472
473 #[test]
474 fn test_profile_from_check_args() {
475 use crate::CheckArgs;
476
477 let args = CheckArgs {
478 strict: true,
479 ..Default::default()
480 };
481 let profile = profile_from_check_args("test_profile", &args, true);
482
483 assert_eq!(profile.name, "test_profile");
484 assert!(profile.strict);
485 assert!(profile.verbose);
486 assert!(profile.description.contains("Custom profile"));
487 }
488
489 #[test]
490 fn test_profile_from_check_args_with_options() {
491 use crate::CheckArgs;
492
493 let args = CheckArgs {
494 skip_comments: true,
495 fix_hint: true,
496 no_malware_scan: true,
497 deep_scan: true,
498 ..Default::default()
499 };
500 let profile = profile_from_check_args("custom", &args, false);
501
502 assert!(profile.skip_comments);
503 assert!(profile.fix_hint);
504 assert!(profile.no_malware_scan);
505 assert!(profile.deep_scan);
506 }
507
508 #[test]
509 fn test_profile_from_check_args_format_and_type() {
510 use crate::{CheckArgs, OutputFormat, ScanType};
511
512 let args = CheckArgs {
513 format: OutputFormat::Json,
514 scan_type: ScanType::Hook,
515 ..Default::default()
516 };
517 let profile = profile_from_check_args("json_profile", &args, false);
518
519 assert!(profile.format.is_some());
520 assert!(profile.scan_type.is_some());
521 }
522
523 #[test]
524 fn test_profile_save_and_load() {
525 let profile = Profile {
528 name: "test_save_load_unique_12345".to_string(),
529 description: "Test profile for save/load".to_string(),
530 strict: true,
531 recursive: true,
532 ci: false,
533 verbose: true,
534 skip_comments: false,
535 fix_hint: true,
536 no_malware_scan: false,
537 deep_scan: true,
538 min_confidence: "firm".to_string(),
539 format: Some("json".to_string()),
540 scan_type: Some("hook".to_string()),
541 disabled_rules: vec!["PE-001".to_string()],
542 };
543
544 let save_result = profile.save();
546 assert!(save_result.is_ok());
547 let saved_path = save_result.unwrap();
548 assert!(saved_path.exists());
549
550 let loaded = Profile::load("test_save_load_unique_12345");
552 assert!(loaded.is_ok());
553 let loaded_profile = loaded.unwrap();
554 assert_eq!(loaded_profile.name, "test_save_load_unique_12345");
555 assert!(loaded_profile.strict);
556 assert!(loaded_profile.deep_scan);
557 assert_eq!(loaded_profile.format, Some("json".to_string()));
558
559 let _ = fs::remove_file(saved_path);
561 }
562
563 #[test]
564 fn test_apply_to_config_recursive() {
565 let profile = Profile::default_profile();
566 let mut config = ScanConfig {
567 recursive: false,
568 ..Default::default()
569 };
570
571 profile.apply_to_config(&mut config);
572
573 assert!(config.recursive);
575 }
576
577 #[test]
578 fn test_apply_to_config_ci() {
579 let profile = Profile::ci_profile();
580 let mut config = ScanConfig::default();
581
582 profile.apply_to_config(&mut config);
583
584 assert!(config.ci);
585 }
586
587 #[test]
588 fn test_apply_to_config_skip_comments() {
589 let profile = Profile::quick_profile();
590 let mut config = ScanConfig::default();
591
592 profile.apply_to_config(&mut config);
593
594 assert!(config.skip_comments);
595 }
596
597 #[test]
598 fn test_profile_disabled_rules() {
599 let profile = Profile {
600 name: "test".to_string(),
601 description: "Test".to_string(),
602 strict: false,
603 recursive: true,
604 ci: false,
605 verbose: false,
606 skip_comments: false,
607 fix_hint: false,
608 no_malware_scan: false,
609 deep_scan: false,
610 min_confidence: "tentative".to_string(),
611 format: None,
612 scan_type: None,
613 disabled_rules: vec!["PE-001".to_string(), "SC-001".to_string()],
614 };
615
616 assert_eq!(profile.disabled_rules.len(), 2);
617 assert!(profile.disabled_rules.contains(&"PE-001".to_string()));
618 }
619
620 #[test]
621 fn test_profile_from_check_args_ci_mode() {
622 use crate::CheckArgs;
623
624 let args = CheckArgs {
625 ci: true,
626 ..Default::default()
627 };
628 let profile = profile_from_check_args("ci_profile", &args, false);
629
630 assert!(profile.ci);
631 assert!(profile.recursive); }
633
634 #[test]
635 fn test_profile_from_check_args_recursive() {
636 use crate::CheckArgs;
637
638 let args = CheckArgs {
639 no_recursive: false, ..Default::default()
641 };
642 let profile = profile_from_check_args("recursive_profile", &args, false);
643
644 assert!(profile.recursive);
645 }
646
647 #[test]
648 fn test_profile_from_check_args_no_recursive() {
649 use crate::CheckArgs;
650
651 let args = CheckArgs {
652 no_recursive: true, ..Default::default()
654 };
655 let profile = profile_from_check_args("non_recursive_profile", &args, false);
656
657 assert!(!profile.recursive);
658 }
659
660 #[test]
661 fn test_list_all_includes_user_profiles() {
662 let profile = Profile {
664 name: "test_user_profile_list_all".to_string(),
665 description: "Test user profile".to_string(),
666 strict: false,
667 recursive: true,
668 ci: false,
669 verbose: false,
670 skip_comments: false,
671 fix_hint: false,
672 no_malware_scan: false,
673 deep_scan: false,
674 min_confidence: "tentative".to_string(),
675 format: None,
676 scan_type: None,
677 disabled_rules: vec![],
678 };
679
680 let save_result = profile.save();
681 assert!(save_result.is_ok());
682 let saved_path = save_result.unwrap();
683
684 let profiles = Profile::list_all();
686 assert!(profiles.contains(&"test_user_profile_list_all".to_string()));
687
688 let _ = fs::remove_file(saved_path);
690 }
691
692 #[test]
693 fn test_load_user_profile_from_file() {
694 let profile = Profile {
696 name: "test_load_user_profile".to_string(),
697 description: "Test for loading".to_string(),
698 strict: true,
699 recursive: false,
700 ci: true,
701 verbose: true,
702 skip_comments: true,
703 fix_hint: true,
704 no_malware_scan: true,
705 deep_scan: true,
706 min_confidence: "certain".to_string(),
707 format: Some("sarif".to_string()),
708 scan_type: Some("docker".to_string()),
709 disabled_rules: vec!["PE-001".to_string()],
710 };
711
712 let save_result = profile.save();
713 assert!(save_result.is_ok());
714 let saved_path = save_result.unwrap();
715
716 let loaded = Profile::load("test_load_user_profile");
718 assert!(loaded.is_ok());
719 let loaded_profile = loaded.unwrap();
720
721 assert_eq!(loaded_profile.name, "test_load_user_profile");
722 assert!(loaded_profile.strict);
723 assert!(!loaded_profile.recursive);
724 assert!(loaded_profile.ci);
725 assert!(loaded_profile.verbose);
726 assert!(loaded_profile.skip_comments);
727 assert!(loaded_profile.fix_hint);
728 assert!(loaded_profile.no_malware_scan);
729 assert!(loaded_profile.deep_scan);
730 assert_eq!(loaded_profile.min_confidence, "certain");
731 assert_eq!(loaded_profile.format, Some("sarif".to_string()));
732 assert_eq!(loaded_profile.scan_type, Some("docker".to_string()));
733 assert_eq!(loaded_profile.disabled_rules, vec!["PE-001".to_string()]);
734
735 let _ = fs::remove_file(saved_path);
737 }
738
739 #[test]
740 fn test_apply_to_config_with_none_format() {
741 let mut profile = Profile::default_profile();
742 profile.format = None;
743 let config = ScanConfig {
744 format: None,
745 ..Default::default()
746 };
747 let mut config = config;
748
749 profile.apply_to_config(&mut config);
750
751 assert!(config.format.is_none());
753 }
754
755 #[test]
756 fn test_apply_to_config_with_none_scan_type() {
757 let mut profile = Profile::default_profile();
758 profile.scan_type = None;
759 let config = ScanConfig {
760 scan_type: None,
761 ..Default::default()
762 };
763 let mut config = config;
764
765 profile.apply_to_config(&mut config);
766
767 assert!(config.scan_type.is_none());
769 }
770
771 #[test]
772 fn test_profile_with_all_fields() {
773 let profile = Profile {
774 name: "complete".to_string(),
775 description: "Complete profile with all fields".to_string(),
776 strict: true,
777 recursive: true,
778 ci: true,
779 verbose: true,
780 skip_comments: true,
781 fix_hint: true,
782 no_malware_scan: true,
783 deep_scan: true,
784 min_confidence: "firm".to_string(),
785 format: Some("html".to_string()),
786 scan_type: Some("mcp".to_string()),
787 disabled_rules: vec!["PE-001".to_string(), "SC-001".to_string()],
788 };
789
790 assert_eq!(profile.name, "complete");
792 assert!(profile.strict);
793 assert!(profile.recursive);
794 assert!(profile.ci);
795 assert!(profile.verbose);
796 assert!(profile.skip_comments);
797 assert!(profile.fix_hint);
798 assert!(profile.no_malware_scan);
799 assert!(profile.deep_scan);
800 assert_eq!(profile.min_confidence, "firm");
801 assert_eq!(profile.format, Some("html".to_string()));
802 assert_eq!(profile.scan_type, Some("mcp".to_string()));
803 assert_eq!(profile.disabled_rules.len(), 2);
804 }
805}