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, 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/// Confidence level for findings. Higher confidence means less likely to be a false positive.
91#[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: May be a false positive, requires review
107    Tentative,
108    /// Firm: Likely a real issue, but context-dependent
109    #[default]
110    Firm,
111    /// Certain: Very high confidence, unlikely to be a false positive
112    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    /// Optional concrete fix hint (e.g., command to run, code pattern to use)
234    pub fix_hint: Option<&'static str>,
235    /// CWE IDs associated with this rule (e.g., ["CWE-200", "CWE-78"])
236    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    /// CWE IDs associated with this finding
261    #[serde(default, skip_serializing_if = "Vec::is_empty")]
262    pub cwe_ids: Vec<String>,
263    /// Rule severity level (error/warn) - determines CI exit code behavior.
264    /// This is assigned based on configuration, not the rule definition.
265    #[serde(default, skip_serializing_if = "Option::is_none")]
266    pub rule_severity: Option<RuleSeverity>,
267    /// AI client that owns this configuration (Claude, Cursor, Windsurf, VS Code).
268    /// Set when scanning with --all-clients or --client options.
269    #[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, // Assigned later based on config
288            client: None,        // Assigned later for client scans
289        }
290    }
291
292    /// Set the client for this finding
293    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    /// Number of findings with RuleSeverity::Error
307    #[serde(default)]
308    pub errors: usize,
309    /// Number of findings with RuleSeverity::Warn
310    #[serde(default)]
311    pub warnings: usize,
312}
313
314impl Summary {
315    /// Creates a Summary from findings.
316    /// Note: This method does not set errors/warnings counts.
317    /// Use `from_findings_with_rule_severity` when rule_severity is assigned.
318    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    /// Creates a Summary from findings with rule_severity counts.
341    /// The `passed` field is determined by whether there are any errors.
342    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), // Default to error
355                        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, // Pass only if no errors
366            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    // ========== RuleSeverity Tests ==========
711
712    #[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        // Warn < Error (warn is less severe)
732        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    // ========== Summary with RuleSeverity Tests ==========
753
754    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); // No errors, so passed
811    }
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); // Has errors, so failed
824        // Also check severity counts
825        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), // None defaults to Error
834        ];
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}