Skip to main content

cc_audit/rules/
types.rs

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