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