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    /// Downgrade the confidence level by one step.
135    ///
136    /// Used to reduce confidence when heuristics suggest a potential false positive
137    /// (e.g., detecting secrets in test files or dummy variable names).
138    ///
139    /// - Certain -> Firm
140    /// - Firm -> Tentative
141    /// - Tentative -> Tentative (minimum level)
142    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    /// Optional concrete fix hint (e.g., command to run, code pattern to use)
260    pub fix_hint: Option<&'static str>,
261    /// CWE IDs associated with this rule (e.g., ["CWE-200", "CWE-78"])
262    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    /// CWE IDs associated with this finding
287    #[serde(default, skip_serializing_if = "Vec::is_empty")]
288    pub cwe_ids: Vec<String>,
289    /// Rule severity level (error/warn) - determines CI exit code behavior.
290    /// This is assigned based on configuration, not the rule definition.
291    #[serde(default, skip_serializing_if = "Option::is_none")]
292    pub rule_severity: Option<RuleSeverity>,
293    /// AI client that owns this configuration (Claude, Cursor, Windsurf, VS Code).
294    /// Set when scanning with --all-clients or --client options.
295    #[serde(default, skip_serializing_if = "Option::is_none")]
296    pub client: Option<String>,
297    /// Content context (documentation, code block, etc.) for false positive reduction.
298    #[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, // Assigned later based on config
317            client: None,        // Assigned later for client scans
318            context: None,       // Assigned by context-aware scanner
319        }
320    }
321
322    /// Set the content context for this finding
323    pub fn with_context(mut self, context: crate::context::ContentContext) -> Self {
324        self.context = Some(context);
325        self
326    }
327
328    /// Set the client for this finding
329    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    /// Number of findings with RuleSeverity::Error
343    #[serde(default)]
344    pub errors: usize,
345    /// Number of findings with RuleSeverity::Warn
346    #[serde(default)]
347    pub warnings: usize,
348}
349
350impl Summary {
351    /// Creates a Summary from findings.
352    /// Note: This method does not set errors/warnings counts.
353    /// Use `from_findings_with_rule_severity` when rule_severity is assigned.
354    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    /// Creates a Summary from findings with rule_severity counts.
377    /// The `passed` field is determined by whether there are any errors.
378    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), // Default to error
391                        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, // Pass only if no errors
402            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        // Certain -> Firm
693        assert_eq!(Confidence::Certain.downgrade(), Confidence::Firm);
694        // Firm -> Tentative
695        assert_eq!(Confidence::Firm.downgrade(), Confidence::Tentative);
696        // Tentative -> Tentative (minimum level)
697        assert_eq!(Confidence::Tentative.downgrade(), Confidence::Tentative);
698    }
699
700    #[test]
701    fn test_confidence_downgrade_twice() {
702        // Double downgrade: Certain -> Firm -> Tentative
703        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    // ========== RuleSeverity Tests ==========
773
774    #[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        // Warn < Error (warn is less severe)
794        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    // ========== Summary with RuleSeverity Tests ==========
815
816    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); // No errors, so passed
874    }
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); // Has errors, so failed
887        // Also check severity counts
888        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), // None defaults to Error
897        ];
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}