1use crate::scoring::RiskScore;
2use serde::{Deserialize, Serialize};
3
4#[derive(Debug, Clone, PartialEq, Eq)]
6pub struct ParseEnumError {
7 type_name: &'static str,
8 value: String,
9}
10
11impl ParseEnumError {
12 pub fn invalid(type_name: &'static str, value: &str) -> Self {
13 Self {
14 type_name,
15 value: value.to_string(),
16 }
17 }
18}
19
20impl std::fmt::Display for ParseEnumError {
21 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
22 write!(f, "invalid {} value: '{}'", self.type_name, self.value)
23 }
24}
25
26impl std::error::Error for ParseEnumError {}
27
28#[derive(
31 Debug,
32 Clone,
33 Copy,
34 PartialEq,
35 Eq,
36 PartialOrd,
37 Ord,
38 Serialize,
39 Deserialize,
40 Default,
41 clap::ValueEnum,
42)]
43#[serde(rename_all = "lowercase")]
44pub enum RuleSeverity {
45 Warn,
47 #[default]
49 Error,
50}
51
52impl RuleSeverity {
53 pub fn as_str(&self) -> &'static str {
54 match self {
55 RuleSeverity::Warn => "warn",
56 RuleSeverity::Error => "error",
57 }
58 }
59}
60
61impl std::fmt::Display for RuleSeverity {
62 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63 write!(f, "{}", self.as_str().to_uppercase())
64 }
65}
66
67impl std::str::FromStr for RuleSeverity {
68 type Err = ParseEnumError;
69
70 fn from_str(s: &str) -> Result<Self, Self::Err> {
71 match s.to_lowercase().as_str() {
72 "warn" | "warning" => Ok(RuleSeverity::Warn),
73 "error" | "err" => Ok(RuleSeverity::Error),
74 _ => Err(ParseEnumError::invalid("RuleSeverity", s)),
75 }
76 }
77}
78
79#[derive(
80 Debug,
81 Clone,
82 Copy,
83 PartialEq,
84 Eq,
85 PartialOrd,
86 Ord,
87 Hash,
88 Serialize,
89 Deserialize,
90 clap::ValueEnum,
91)]
92#[serde(rename_all = "lowercase")]
93pub enum Severity {
94 Low,
95 Medium,
96 High,
97 Critical,
98}
99
100#[derive(
102 Debug,
103 Clone,
104 Copy,
105 PartialEq,
106 Eq,
107 PartialOrd,
108 Ord,
109 Serialize,
110 Deserialize,
111 Default,
112 clap::ValueEnum,
113)]
114#[serde(rename_all = "lowercase")]
115pub enum Confidence {
116 Tentative,
118 #[default]
120 Firm,
121 Certain,
123}
124
125impl Confidence {
126 pub fn as_str(&self) -> &'static str {
127 match self {
128 Confidence::Tentative => "tentative",
129 Confidence::Firm => "firm",
130 Confidence::Certain => "certain",
131 }
132 }
133
134 pub fn downgrade(&self) -> Self {
143 match self {
144 Confidence::Certain => Confidence::Firm,
145 Confidence::Firm => Confidence::Tentative,
146 Confidence::Tentative => Confidence::Tentative,
147 }
148 }
149}
150
151impl std::fmt::Display for Confidence {
152 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
153 write!(f, "{}", self.as_str())
154 }
155}
156
157impl std::str::FromStr for Confidence {
158 type Err = ParseEnumError;
159
160 fn from_str(s: &str) -> Result<Self, Self::Err> {
161 match s.to_lowercase().as_str() {
162 "tentative" => Ok(Confidence::Tentative),
163 "firm" => Ok(Confidence::Firm),
164 "certain" => Ok(Confidence::Certain),
165 _ => Err(ParseEnumError::invalid("Confidence", s)),
166 }
167 }
168}
169
170impl Severity {
171 pub fn as_str(&self) -> &'static str {
172 match self {
173 Severity::Low => "low",
174 Severity::Medium => "medium",
175 Severity::High => "high",
176 Severity::Critical => "critical",
177 }
178 }
179}
180
181impl std::fmt::Display for Severity {
182 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
183 write!(f, "{}", self.as_str().to_uppercase())
184 }
185}
186
187impl std::str::FromStr for Severity {
188 type Err = ParseEnumError;
189
190 fn from_str(s: &str) -> Result<Self, Self::Err> {
191 match s.to_lowercase().as_str() {
192 "low" => Ok(Severity::Low),
193 "medium" | "med" => Ok(Severity::Medium),
194 "high" => Ok(Severity::High),
195 "critical" | "crit" => Ok(Severity::Critical),
196 _ => Err(ParseEnumError::invalid("Severity", s)),
197 }
198 }
199}
200
201#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
202#[serde(rename_all = "lowercase")]
203pub enum Category {
204 Exfiltration,
205 PrivilegeEscalation,
206 Persistence,
207 PromptInjection,
208 Overpermission,
209 Obfuscation,
210 SupplyChain,
211 SecretLeak,
212}
213
214impl Category {
215 pub fn as_str(&self) -> &'static str {
216 match self {
217 Category::Exfiltration => "exfiltration",
218 Category::PrivilegeEscalation => "privilege_escalation",
219 Category::Persistence => "persistence",
220 Category::PromptInjection => "prompt_injection",
221 Category::Overpermission => "overpermission",
222 Category::Obfuscation => "obfuscation",
223 Category::SupplyChain => "supply_chain",
224 Category::SecretLeak => "secret_leak",
225 }
226 }
227}
228
229impl std::str::FromStr for Category {
230 type Err = ParseEnumError;
231
232 fn from_str(s: &str) -> Result<Self, Self::Err> {
233 match s.to_lowercase().replace(['_', '-'], "").as_str() {
234 "exfiltration" | "exfil" => Ok(Category::Exfiltration),
235 "privilegeescalation" | "privesc" => Ok(Category::PrivilegeEscalation),
236 "persistence" => Ok(Category::Persistence),
237 "promptinjection" => Ok(Category::PromptInjection),
238 "overpermission" => Ok(Category::Overpermission),
239 "obfuscation" => Ok(Category::Obfuscation),
240 "supplychain" => Ok(Category::SupplyChain),
241 "secretleak" => Ok(Category::SecretLeak),
242 _ => Err(ParseEnumError::invalid("Category", s)),
243 }
244 }
245}
246
247#[derive(Debug, Clone)]
248pub struct Rule {
249 pub id: &'static str,
250 pub name: &'static str,
251 pub description: &'static str,
252 pub severity: Severity,
253 pub category: Category,
254 pub confidence: Confidence,
255 pub patterns: Vec<regex::Regex>,
256 pub exclusions: Vec<regex::Regex>,
257 pub message: &'static str,
258 pub recommendation: &'static str,
259 pub fix_hint: Option<&'static str>,
261 pub cwe_ids: &'static [&'static str],
263}
264
265#[derive(Debug, Clone, Serialize, Deserialize)]
266pub struct Location {
267 pub file: String,
268 pub line: usize,
269 #[serde(skip_serializing_if = "Option::is_none")]
270 pub column: Option<usize>,
271}
272
273#[derive(Debug, Clone, Serialize, Deserialize)]
274pub struct Finding {
275 pub id: String,
276 pub severity: Severity,
277 pub category: Category,
278 pub confidence: Confidence,
279 pub name: String,
280 pub location: Location,
281 pub code: String,
282 pub message: String,
283 pub recommendation: String,
284 #[serde(skip_serializing_if = "Option::is_none")]
285 pub fix_hint: Option<String>,
286 #[serde(default, skip_serializing_if = "Vec::is_empty")]
288 pub cwe_ids: Vec<String>,
289 #[serde(default, skip_serializing_if = "Option::is_none")]
292 pub rule_severity: Option<RuleSeverity>,
293 #[serde(default, skip_serializing_if = "Option::is_none")]
296 pub client: Option<String>,
297 #[serde(default, skip_serializing_if = "Option::is_none")]
299 pub context: Option<crate::context::ContentContext>,
300}
301
302impl Finding {
303 pub fn new(rule: &Rule, location: Location, code: String) -> Self {
304 Self {
305 id: rule.id.to_string(),
306 severity: rule.severity,
307 category: rule.category,
308 confidence: rule.confidence,
309 name: rule.name.to_string(),
310 location,
311 code,
312 message: rule.message.to_string(),
313 recommendation: rule.recommendation.to_string(),
314 fix_hint: rule.fix_hint.map(|s| s.to_string()),
315 cwe_ids: rule.cwe_ids.iter().map(|s| s.to_string()).collect(),
316 rule_severity: None, client: None, context: None, }
320 }
321
322 pub fn with_context(mut self, context: crate::context::ContentContext) -> Self {
324 self.context = Some(context);
325 self
326 }
327
328 pub fn with_client(mut self, client: Option<String>) -> Self {
330 self.client = client;
331 self
332 }
333}
334
335#[derive(Debug, Clone, Serialize, Deserialize)]
336pub struct Summary {
337 pub critical: usize,
338 pub high: usize,
339 pub medium: usize,
340 pub low: usize,
341 pub passed: bool,
342 #[serde(default)]
344 pub errors: usize,
345 #[serde(default)]
347 pub warnings: usize,
348}
349
350impl Summary {
351 pub fn from_findings(findings: &[Finding]) -> Self {
355 let (critical, high, medium, low) =
356 findings
357 .iter()
358 .fold((0, 0, 0, 0), |(c, h, m, l), f| match f.severity {
359 Severity::Critical => (c + 1, h, m, l),
360 Severity::High => (c, h + 1, m, l),
361 Severity::Medium => (c, h, m + 1, l),
362 Severity::Low => (c, h, m, l + 1),
363 });
364
365 Self {
366 critical,
367 high,
368 medium,
369 low,
370 passed: critical == 0 && high == 0,
371 errors: 0,
372 warnings: 0,
373 }
374 }
375
376 pub fn from_findings_with_rule_severity(findings: &[Finding]) -> Self {
379 let (critical, high, medium, low, errors, warnings) =
380 findings
381 .iter()
382 .fold((0, 0, 0, 0, 0, 0), |(c, h, m, l, e, w), f| {
383 let (new_c, new_h, new_m, new_l) = match f.severity {
384 Severity::Critical => (c + 1, h, m, l),
385 Severity::High => (c, h + 1, m, l),
386 Severity::Medium => (c, h, m + 1, l),
387 Severity::Low => (c, h, m, l + 1),
388 };
389 let (new_e, new_w) = match f.rule_severity {
390 Some(RuleSeverity::Error) | None => (e + 1, w), Some(RuleSeverity::Warn) => (e, w + 1),
392 };
393 (new_c, new_h, new_m, new_l, new_e, new_w)
394 });
395
396 Self {
397 critical,
398 high,
399 medium,
400 low,
401 passed: errors == 0, errors,
403 warnings,
404 }
405 }
406}
407
408#[derive(Debug, Clone, Serialize, Deserialize)]
409pub struct ScanResult {
410 pub version: String,
411 pub scanned_at: String,
412 pub target: String,
413 pub summary: Summary,
414 pub findings: Vec<Finding>,
415 #[serde(skip_serializing_if = "Option::is_none")]
416 pub risk_score: Option<RiskScore>,
417}
418
419#[cfg(test)]
420mod tests {
421 use super::*;
422
423 #[test]
424 fn test_severity_as_str() {
425 assert_eq!(Severity::Low.as_str(), "low");
426 assert_eq!(Severity::Medium.as_str(), "medium");
427 assert_eq!(Severity::High.as_str(), "high");
428 assert_eq!(Severity::Critical.as_str(), "critical");
429 }
430
431 #[test]
432 fn test_severity_display() {
433 assert_eq!(format!("{}", Severity::Low), "LOW");
434 assert_eq!(format!("{}", Severity::Medium), "MEDIUM");
435 assert_eq!(format!("{}", Severity::High), "HIGH");
436 assert_eq!(format!("{}", Severity::Critical), "CRITICAL");
437 }
438
439 #[test]
440 fn test_severity_ordering() {
441 assert!(Severity::Low < Severity::Medium);
442 assert!(Severity::Medium < Severity::High);
443 assert!(Severity::High < Severity::Critical);
444 }
445
446 #[test]
447 fn test_category_as_str() {
448 assert_eq!(Category::Exfiltration.as_str(), "exfiltration");
449 assert_eq!(
450 Category::PrivilegeEscalation.as_str(),
451 "privilege_escalation"
452 );
453 assert_eq!(Category::Persistence.as_str(), "persistence");
454 assert_eq!(Category::PromptInjection.as_str(), "prompt_injection");
455 assert_eq!(Category::Overpermission.as_str(), "overpermission");
456 assert_eq!(Category::Obfuscation.as_str(), "obfuscation");
457 assert_eq!(Category::SupplyChain.as_str(), "supply_chain");
458 assert_eq!(Category::SecretLeak.as_str(), "secret_leak");
459 }
460
461 #[test]
462 fn test_summary_from_empty_findings() {
463 let findings: Vec<Finding> = vec![];
464 let summary = Summary::from_findings(&findings);
465 assert_eq!(summary.critical, 0);
466 assert_eq!(summary.high, 0);
467 assert_eq!(summary.medium, 0);
468 assert_eq!(summary.low, 0);
469 assert!(summary.passed);
470 }
471
472 #[test]
473 fn test_summary_from_findings_with_critical() {
474 let findings = vec![Finding {
475 id: "EX-001".to_string(),
476 severity: Severity::Critical,
477 category: Category::Exfiltration,
478 confidence: Confidence::Certain,
479 name: "Test".to_string(),
480 location: Location {
481 file: "test.sh".to_string(),
482 line: 1,
483 column: None,
484 },
485 code: "test".to_string(),
486 message: "test".to_string(),
487 recommendation: "test".to_string(),
488 fix_hint: None,
489 cwe_ids: vec![],
490 rule_severity: None,
491 client: None,
492 context: None,
493 }];
494 let summary = Summary::from_findings(&findings);
495 assert_eq!(summary.critical, 1);
496 assert!(!summary.passed);
497 }
498
499 #[test]
500 fn test_summary_from_findings_all_severities() {
501 let findings = vec![
502 Finding {
503 id: "C-001".to_string(),
504 severity: Severity::Critical,
505 category: Category::Exfiltration,
506 confidence: Confidence::Certain,
507 name: "Critical".to_string(),
508 location: Location {
509 file: "test.sh".to_string(),
510 line: 1,
511 column: None,
512 },
513 code: "test".to_string(),
514 message: "test".to_string(),
515 recommendation: "test".to_string(),
516 fix_hint: None,
517 cwe_ids: vec![],
518 rule_severity: None,
519 client: None,
520 context: None,
521 },
522 Finding {
523 id: "H-001".to_string(),
524 severity: Severity::High,
525 category: Category::PrivilegeEscalation,
526 confidence: Confidence::Firm,
527 name: "High".to_string(),
528 location: Location {
529 file: "test.sh".to_string(),
530 line: 2,
531 column: None,
532 },
533 code: "test".to_string(),
534 message: "test".to_string(),
535 recommendation: "test".to_string(),
536 fix_hint: None,
537 cwe_ids: vec![],
538 rule_severity: None,
539 client: None,
540 context: None,
541 },
542 Finding {
543 id: "M-001".to_string(),
544 severity: Severity::Medium,
545 category: Category::Persistence,
546 confidence: Confidence::Tentative,
547 name: "Medium".to_string(),
548 location: Location {
549 file: "test.sh".to_string(),
550 line: 3,
551 column: Some(5),
552 },
553 code: "test".to_string(),
554 message: "test".to_string(),
555 recommendation: "test".to_string(),
556 fix_hint: None,
557 cwe_ids: vec![],
558 rule_severity: None,
559 client: None,
560 context: None,
561 },
562 Finding {
563 id: "L-001".to_string(),
564 severity: Severity::Low,
565 category: Category::Overpermission,
566 confidence: Confidence::Firm,
567 name: "Low".to_string(),
568 location: Location {
569 file: "test.sh".to_string(),
570 line: 4,
571 column: None,
572 },
573 code: "test".to_string(),
574 message: "test".to_string(),
575 recommendation: "test".to_string(),
576 fix_hint: None,
577 cwe_ids: vec![],
578 rule_severity: None,
579 client: None,
580 context: None,
581 },
582 ];
583 let summary = Summary::from_findings(&findings);
584 assert_eq!(summary.critical, 1);
585 assert_eq!(summary.high, 1);
586 assert_eq!(summary.medium, 1);
587 assert_eq!(summary.low, 1);
588 assert!(!summary.passed);
589 }
590
591 #[test]
592 fn test_summary_passes_with_only_medium_low() {
593 let findings = vec![
594 Finding {
595 id: "M-001".to_string(),
596 severity: Severity::Medium,
597 category: Category::Persistence,
598 confidence: Confidence::Firm,
599 name: "Medium".to_string(),
600 location: Location {
601 file: "test.sh".to_string(),
602 line: 1,
603 column: None,
604 },
605 code: "test".to_string(),
606 message: "test".to_string(),
607 recommendation: "test".to_string(),
608 fix_hint: None,
609 cwe_ids: vec![],
610 rule_severity: None,
611 client: None,
612 context: None,
613 },
614 Finding {
615 id: "L-001".to_string(),
616 severity: Severity::Low,
617 category: Category::Overpermission,
618 confidence: Confidence::Firm,
619 name: "Low".to_string(),
620 location: Location {
621 file: "test.sh".to_string(),
622 line: 2,
623 column: None,
624 },
625 code: "test".to_string(),
626 message: "test".to_string(),
627 recommendation: "test".to_string(),
628 fix_hint: None,
629 cwe_ids: vec![],
630 rule_severity: None,
631 client: None,
632 context: None,
633 },
634 ];
635 let summary = Summary::from_findings(&findings);
636 assert!(summary.passed);
637 }
638
639 #[test]
640 fn test_finding_new() {
641 let rule = Rule {
642 id: "TEST-001",
643 name: "Test Rule",
644 description: "A test rule",
645 severity: Severity::High,
646 category: Category::Exfiltration,
647 confidence: Confidence::Certain,
648 patterns: vec![],
649 exclusions: vec![],
650 message: "Test message",
651 recommendation: "Test recommendation",
652 fix_hint: Some("Test fix hint"),
653 cwe_ids: &["CWE-200", "CWE-78"],
654 };
655 let location = Location {
656 file: "test.sh".to_string(),
657 line: 42,
658 column: Some(10),
659 };
660 let finding = Finding::new(&rule, location, "test code".to_string());
661
662 assert_eq!(finding.id, "TEST-001");
663 assert_eq!(finding.name, "Test Rule");
664 assert_eq!(finding.severity, Severity::High);
665 assert_eq!(finding.category, Category::Exfiltration);
666 assert_eq!(finding.confidence, Confidence::Certain);
667 assert_eq!(finding.location.file, "test.sh");
668 assert_eq!(finding.location.line, 42);
669 assert_eq!(finding.location.column, Some(10));
670 assert_eq!(finding.code, "test code");
671 assert_eq!(finding.message, "Test message");
672 assert_eq!(finding.recommendation, "Test recommendation");
673 assert_eq!(finding.cwe_ids, vec!["CWE-200", "CWE-78"]);
674 }
675
676 #[test]
677 fn test_confidence_as_str() {
678 assert_eq!(Confidence::Tentative.as_str(), "tentative");
679 assert_eq!(Confidence::Firm.as_str(), "firm");
680 assert_eq!(Confidence::Certain.as_str(), "certain");
681 }
682
683 #[test]
684 fn test_confidence_display() {
685 assert_eq!(format!("{}", Confidence::Tentative), "tentative");
686 assert_eq!(format!("{}", Confidence::Firm), "firm");
687 assert_eq!(format!("{}", Confidence::Certain), "certain");
688 }
689
690 #[test]
691 fn test_confidence_downgrade() {
692 assert_eq!(Confidence::Certain.downgrade(), Confidence::Firm);
694 assert_eq!(Confidence::Firm.downgrade(), Confidence::Tentative);
696 assert_eq!(Confidence::Tentative.downgrade(), Confidence::Tentative);
698 }
699
700 #[test]
701 fn test_confidence_downgrade_twice() {
702 let confidence = Confidence::Certain;
704 let downgraded_once = confidence.downgrade();
705 let downgraded_twice = downgraded_once.downgrade();
706 assert_eq!(downgraded_twice, Confidence::Tentative);
707 }
708
709 #[test]
710 fn test_confidence_ordering() {
711 assert!(Confidence::Tentative < Confidence::Firm);
712 assert!(Confidence::Firm < Confidence::Certain);
713 }
714
715 #[test]
716 fn test_confidence_default() {
717 assert_eq!(Confidence::default(), Confidence::Firm);
718 }
719
720 #[test]
721 fn test_confidence_serialization() {
722 let confidence = Confidence::Certain;
723 let json = serde_json::to_string(&confidence).unwrap();
724 assert_eq!(json, "\"certain\"");
725
726 let deserialized: Confidence = serde_json::from_str(&json).unwrap();
727 assert_eq!(deserialized, Confidence::Certain);
728 }
729
730 #[test]
731 fn test_severity_serialization() {
732 let severity = Severity::Critical;
733 let json = serde_json::to_string(&severity).unwrap();
734 assert_eq!(json, "\"critical\"");
735
736 let deserialized: Severity = serde_json::from_str(&json).unwrap();
737 assert_eq!(deserialized, Severity::Critical);
738 }
739
740 #[test]
741 fn test_category_serialization() {
742 let category = Category::PromptInjection;
743 let json = serde_json::to_string(&category).unwrap();
744 assert_eq!(json, "\"promptinjection\"");
745
746 let deserialized: Category = serde_json::from_str(&json).unwrap();
747 assert_eq!(deserialized, Category::PromptInjection);
748 }
749
750 #[test]
751 fn test_location_without_column_serialization() {
752 let location = Location {
753 file: "test.sh".to_string(),
754 line: 10,
755 column: None,
756 };
757 let json = serde_json::to_string(&location).unwrap();
758 assert!(!json.contains("column"));
759 }
760
761 #[test]
762 fn test_location_with_column_serialization() {
763 let location = Location {
764 file: "test.sh".to_string(),
765 line: 10,
766 column: Some(5),
767 };
768 let json = serde_json::to_string(&location).unwrap();
769 assert!(json.contains("\"column\":5"));
770 }
771
772 #[test]
775 fn test_rule_severity_default_is_error() {
776 assert_eq!(RuleSeverity::default(), RuleSeverity::Error);
777 }
778
779 #[test]
780 fn test_rule_severity_as_str() {
781 assert_eq!(RuleSeverity::Error.as_str(), "error");
782 assert_eq!(RuleSeverity::Warn.as_str(), "warn");
783 }
784
785 #[test]
786 fn test_rule_severity_display() {
787 assert_eq!(format!("{}", RuleSeverity::Error), "ERROR");
788 assert_eq!(format!("{}", RuleSeverity::Warn), "WARN");
789 }
790
791 #[test]
792 fn test_rule_severity_ordering() {
793 assert!(RuleSeverity::Warn < RuleSeverity::Error);
795 }
796
797 #[test]
798 fn test_rule_severity_serialization() {
799 let error = RuleSeverity::Error;
800 let json = serde_json::to_string(&error).unwrap();
801 assert_eq!(json, "\"error\"");
802
803 let warn = RuleSeverity::Warn;
804 let json = serde_json::to_string(&warn).unwrap();
805 assert_eq!(json, "\"warn\"");
806
807 let deserialized: RuleSeverity = serde_json::from_str("\"error\"").unwrap();
808 assert_eq!(deserialized, RuleSeverity::Error);
809
810 let deserialized: RuleSeverity = serde_json::from_str("\"warn\"").unwrap();
811 assert_eq!(deserialized, RuleSeverity::Warn);
812 }
813
814 fn create_test_finding(
817 id: &str,
818 severity: Severity,
819 rule_severity: Option<RuleSeverity>,
820 ) -> Finding {
821 Finding {
822 id: id.to_string(),
823 severity,
824 category: Category::Exfiltration,
825 confidence: Confidence::Firm,
826 name: "Test".to_string(),
827 location: Location {
828 file: "test.sh".to_string(),
829 line: 1,
830 column: None,
831 },
832 code: "test".to_string(),
833 message: "test".to_string(),
834 recommendation: "test".to_string(),
835 fix_hint: None,
836 cwe_ids: vec![],
837 rule_severity,
838 client: None,
839 context: None,
840 }
841 }
842
843 #[test]
844 fn test_summary_with_rule_severity_empty() {
845 let findings: Vec<Finding> = vec![];
846 let summary = Summary::from_findings_with_rule_severity(&findings);
847 assert_eq!(summary.errors, 0);
848 assert_eq!(summary.warnings, 0);
849 assert!(summary.passed);
850 }
851
852 #[test]
853 fn test_summary_with_rule_severity_all_errors() {
854 let findings = vec![
855 create_test_finding("E-001", Severity::Critical, Some(RuleSeverity::Error)),
856 create_test_finding("E-002", Severity::High, Some(RuleSeverity::Error)),
857 ];
858 let summary = Summary::from_findings_with_rule_severity(&findings);
859 assert_eq!(summary.errors, 2);
860 assert_eq!(summary.warnings, 0);
861 assert!(!summary.passed);
862 }
863
864 #[test]
865 fn test_summary_with_rule_severity_all_warnings() {
866 let findings = vec![
867 create_test_finding("W-001", Severity::Critical, Some(RuleSeverity::Warn)),
868 create_test_finding("W-002", Severity::High, Some(RuleSeverity::Warn)),
869 ];
870 let summary = Summary::from_findings_with_rule_severity(&findings);
871 assert_eq!(summary.errors, 0);
872 assert_eq!(summary.warnings, 2);
873 assert!(summary.passed); }
875
876 #[test]
877 fn test_summary_with_rule_severity_mixed() {
878 let findings = vec![
879 create_test_finding("E-001", Severity::Critical, Some(RuleSeverity::Error)),
880 create_test_finding("W-001", Severity::High, Some(RuleSeverity::Warn)),
881 create_test_finding("W-002", Severity::Medium, Some(RuleSeverity::Warn)),
882 ];
883 let summary = Summary::from_findings_with_rule_severity(&findings);
884 assert_eq!(summary.errors, 1);
885 assert_eq!(summary.warnings, 2);
886 assert!(!summary.passed); assert_eq!(summary.critical, 1);
889 assert_eq!(summary.high, 1);
890 assert_eq!(summary.medium, 1);
891 }
892
893 #[test]
894 fn test_summary_with_rule_severity_none_defaults_to_error() {
895 let findings = vec![
896 create_test_finding("N-001", Severity::Low, None), ];
898 let summary = Summary::from_findings_with_rule_severity(&findings);
899 assert_eq!(summary.errors, 1);
900 assert_eq!(summary.warnings, 0);
901 assert!(!summary.passed);
902 }
903
904 #[test]
905 fn test_finding_rule_severity_not_serialized_when_none() {
906 let finding = create_test_finding("TEST-001", Severity::High, None);
907 let json = serde_json::to_string(&finding).unwrap();
908 assert!(!json.contains("rule_severity"));
909 }
910
911 #[test]
912 fn test_finding_rule_severity_serialized_when_some() {
913 let finding = create_test_finding("TEST-001", Severity::High, Some(RuleSeverity::Warn));
914 let json = serde_json::to_string(&finding).unwrap();
915 assert!(json.contains("\"rule_severity\":\"warn\""));
916 }
917}