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    pub elapsed_ms: u64,
418}
419
420#[cfg(test)]
421mod tests {
422    use super::*;
423
424    #[test]
425    fn test_severity_as_str() {
426        assert_eq!(Severity::Low.as_str(), "low");
427        assert_eq!(Severity::Medium.as_str(), "medium");
428        assert_eq!(Severity::High.as_str(), "high");
429        assert_eq!(Severity::Critical.as_str(), "critical");
430    }
431
432    #[test]
433    fn test_severity_display() {
434        assert_eq!(format!("{}", Severity::Low), "LOW");
435        assert_eq!(format!("{}", Severity::Medium), "MEDIUM");
436        assert_eq!(format!("{}", Severity::High), "HIGH");
437        assert_eq!(format!("{}", Severity::Critical), "CRITICAL");
438    }
439
440    #[test]
441    fn test_severity_ordering() {
442        assert!(Severity::Low < Severity::Medium);
443        assert!(Severity::Medium < Severity::High);
444        assert!(Severity::High < Severity::Critical);
445    }
446
447    #[test]
448    fn test_category_as_str() {
449        assert_eq!(Category::Exfiltration.as_str(), "exfiltration");
450        assert_eq!(
451            Category::PrivilegeEscalation.as_str(),
452            "privilege_escalation"
453        );
454        assert_eq!(Category::Persistence.as_str(), "persistence");
455        assert_eq!(Category::PromptInjection.as_str(), "prompt_injection");
456        assert_eq!(Category::Overpermission.as_str(), "overpermission");
457        assert_eq!(Category::Obfuscation.as_str(), "obfuscation");
458        assert_eq!(Category::SupplyChain.as_str(), "supply_chain");
459        assert_eq!(Category::SecretLeak.as_str(), "secret_leak");
460    }
461
462    #[test]
463    fn test_summary_from_empty_findings() {
464        let findings: Vec<Finding> = vec![];
465        let summary = Summary::from_findings(&findings);
466        assert_eq!(summary.critical, 0);
467        assert_eq!(summary.high, 0);
468        assert_eq!(summary.medium, 0);
469        assert_eq!(summary.low, 0);
470        assert!(summary.passed);
471    }
472
473    #[test]
474    fn test_summary_from_findings_with_critical() {
475        let findings = vec![Finding {
476            id: "EX-001".to_string(),
477            severity: Severity::Critical,
478            category: Category::Exfiltration,
479            confidence: Confidence::Certain,
480            name: "Test".to_string(),
481            location: Location {
482                file: "test.sh".to_string(),
483                line: 1,
484                column: None,
485            },
486            code: "test".to_string(),
487            message: "test".to_string(),
488            recommendation: "test".to_string(),
489            fix_hint: None,
490            cwe_ids: vec![],
491            rule_severity: None,
492            client: None,
493            context: None,
494        }];
495        let summary = Summary::from_findings(&findings);
496        assert_eq!(summary.critical, 1);
497        assert!(!summary.passed);
498    }
499
500    #[test]
501    fn test_summary_from_findings_all_severities() {
502        let findings = vec![
503            Finding {
504                id: "C-001".to_string(),
505                severity: Severity::Critical,
506                category: Category::Exfiltration,
507                confidence: Confidence::Certain,
508                name: "Critical".to_string(),
509                location: Location {
510                    file: "test.sh".to_string(),
511                    line: 1,
512                    column: None,
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                context: None,
522            },
523            Finding {
524                id: "H-001".to_string(),
525                severity: Severity::High,
526                category: Category::PrivilegeEscalation,
527                confidence: Confidence::Firm,
528                name: "High".to_string(),
529                location: Location {
530                    file: "test.sh".to_string(),
531                    line: 2,
532                    column: None,
533                },
534                code: "test".to_string(),
535                message: "test".to_string(),
536                recommendation: "test".to_string(),
537                fix_hint: None,
538                cwe_ids: vec![],
539                rule_severity: None,
540                client: None,
541                context: None,
542            },
543            Finding {
544                id: "M-001".to_string(),
545                severity: Severity::Medium,
546                category: Category::Persistence,
547                confidence: Confidence::Tentative,
548                name: "Medium".to_string(),
549                location: Location {
550                    file: "test.sh".to_string(),
551                    line: 3,
552                    column: Some(5),
553                },
554                code: "test".to_string(),
555                message: "test".to_string(),
556                recommendation: "test".to_string(),
557                fix_hint: None,
558                cwe_ids: vec![],
559                rule_severity: None,
560                client: None,
561                context: None,
562            },
563            Finding {
564                id: "L-001".to_string(),
565                severity: Severity::Low,
566                category: Category::Overpermission,
567                confidence: Confidence::Firm,
568                name: "Low".to_string(),
569                location: Location {
570                    file: "test.sh".to_string(),
571                    line: 4,
572                    column: None,
573                },
574                code: "test".to_string(),
575                message: "test".to_string(),
576                recommendation: "test".to_string(),
577                fix_hint: None,
578                cwe_ids: vec![],
579                rule_severity: None,
580                client: None,
581                context: None,
582            },
583        ];
584        let summary = Summary::from_findings(&findings);
585        assert_eq!(summary.critical, 1);
586        assert_eq!(summary.high, 1);
587        assert_eq!(summary.medium, 1);
588        assert_eq!(summary.low, 1);
589        assert!(!summary.passed);
590    }
591
592    #[test]
593    fn test_summary_passes_with_only_medium_low() {
594        let findings = vec![
595            Finding {
596                id: "M-001".to_string(),
597                severity: Severity::Medium,
598                category: Category::Persistence,
599                confidence: Confidence::Firm,
600                name: "Medium".to_string(),
601                location: Location {
602                    file: "test.sh".to_string(),
603                    line: 1,
604                    column: None,
605                },
606                code: "test".to_string(),
607                message: "test".to_string(),
608                recommendation: "test".to_string(),
609                fix_hint: None,
610                cwe_ids: vec![],
611                rule_severity: None,
612                client: None,
613                context: None,
614            },
615            Finding {
616                id: "L-001".to_string(),
617                severity: Severity::Low,
618                category: Category::Overpermission,
619                confidence: Confidence::Firm,
620                name: "Low".to_string(),
621                location: Location {
622                    file: "test.sh".to_string(),
623                    line: 2,
624                    column: None,
625                },
626                code: "test".to_string(),
627                message: "test".to_string(),
628                recommendation: "test".to_string(),
629                fix_hint: None,
630                cwe_ids: vec![],
631                rule_severity: None,
632                client: None,
633                context: None,
634            },
635        ];
636        let summary = Summary::from_findings(&findings);
637        assert!(summary.passed);
638    }
639
640    #[test]
641    fn test_finding_new() {
642        let rule = Rule {
643            id: "TEST-001",
644            name: "Test Rule",
645            description: "A test rule",
646            severity: Severity::High,
647            category: Category::Exfiltration,
648            confidence: Confidence::Certain,
649            patterns: vec![],
650            exclusions: vec![],
651            message: "Test message",
652            recommendation: "Test recommendation",
653            fix_hint: Some("Test fix hint"),
654            cwe_ids: &["CWE-200", "CWE-78"],
655        };
656        let location = Location {
657            file: "test.sh".to_string(),
658            line: 42,
659            column: Some(10),
660        };
661        let finding = Finding::new(&rule, location, "test code".to_string());
662
663        assert_eq!(finding.id, "TEST-001");
664        assert_eq!(finding.name, "Test Rule");
665        assert_eq!(finding.severity, Severity::High);
666        assert_eq!(finding.category, Category::Exfiltration);
667        assert_eq!(finding.confidence, Confidence::Certain);
668        assert_eq!(finding.location.file, "test.sh");
669        assert_eq!(finding.location.line, 42);
670        assert_eq!(finding.location.column, Some(10));
671        assert_eq!(finding.code, "test code");
672        assert_eq!(finding.message, "Test message");
673        assert_eq!(finding.recommendation, "Test recommendation");
674        assert_eq!(finding.cwe_ids, vec!["CWE-200", "CWE-78"]);
675    }
676
677    #[test]
678    fn test_confidence_as_str() {
679        assert_eq!(Confidence::Tentative.as_str(), "tentative");
680        assert_eq!(Confidence::Firm.as_str(), "firm");
681        assert_eq!(Confidence::Certain.as_str(), "certain");
682    }
683
684    #[test]
685    fn test_confidence_display() {
686        assert_eq!(format!("{}", Confidence::Tentative), "tentative");
687        assert_eq!(format!("{}", Confidence::Firm), "firm");
688        assert_eq!(format!("{}", Confidence::Certain), "certain");
689    }
690
691    #[test]
692    fn test_confidence_downgrade() {
693        // Certain -> Firm
694        assert_eq!(Confidence::Certain.downgrade(), Confidence::Firm);
695        // Firm -> Tentative
696        assert_eq!(Confidence::Firm.downgrade(), Confidence::Tentative);
697        // Tentative -> Tentative (minimum level)
698        assert_eq!(Confidence::Tentative.downgrade(), Confidence::Tentative);
699    }
700
701    #[test]
702    fn test_confidence_downgrade_twice() {
703        // Double downgrade: Certain -> Firm -> Tentative
704        let confidence = Confidence::Certain;
705        let downgraded_once = confidence.downgrade();
706        let downgraded_twice = downgraded_once.downgrade();
707        assert_eq!(downgraded_twice, Confidence::Tentative);
708    }
709
710    #[test]
711    fn test_confidence_ordering() {
712        assert!(Confidence::Tentative < Confidence::Firm);
713        assert!(Confidence::Firm < Confidence::Certain);
714    }
715
716    #[test]
717    fn test_confidence_default() {
718        assert_eq!(Confidence::default(), Confidence::Firm);
719    }
720
721    #[test]
722    fn test_confidence_serialization() {
723        let confidence = Confidence::Certain;
724        let json = serde_json::to_string(&confidence).unwrap();
725        assert_eq!(json, "\"certain\"");
726
727        let deserialized: Confidence = serde_json::from_str(&json).unwrap();
728        assert_eq!(deserialized, Confidence::Certain);
729    }
730
731    #[test]
732    fn test_severity_serialization() {
733        let severity = Severity::Critical;
734        let json = serde_json::to_string(&severity).unwrap();
735        assert_eq!(json, "\"critical\"");
736
737        let deserialized: Severity = serde_json::from_str(&json).unwrap();
738        assert_eq!(deserialized, Severity::Critical);
739    }
740
741    #[test]
742    fn test_category_serialization() {
743        let category = Category::PromptInjection;
744        let json = serde_json::to_string(&category).unwrap();
745        assert_eq!(json, "\"promptinjection\"");
746
747        let deserialized: Category = serde_json::from_str(&json).unwrap();
748        assert_eq!(deserialized, Category::PromptInjection);
749    }
750
751    #[test]
752    fn test_location_without_column_serialization() {
753        let location = Location {
754            file: "test.sh".to_string(),
755            line: 10,
756            column: None,
757        };
758        let json = serde_json::to_string(&location).unwrap();
759        assert!(!json.contains("column"));
760    }
761
762    #[test]
763    fn test_location_with_column_serialization() {
764        let location = Location {
765            file: "test.sh".to_string(),
766            line: 10,
767            column: Some(5),
768        };
769        let json = serde_json::to_string(&location).unwrap();
770        assert!(json.contains("\"column\":5"));
771    }
772
773    // ========== RuleSeverity Tests ==========
774
775    #[test]
776    fn test_rule_severity_default_is_error() {
777        assert_eq!(RuleSeverity::default(), RuleSeverity::Error);
778    }
779
780    #[test]
781    fn test_rule_severity_as_str() {
782        assert_eq!(RuleSeverity::Error.as_str(), "error");
783        assert_eq!(RuleSeverity::Warn.as_str(), "warn");
784    }
785
786    #[test]
787    fn test_rule_severity_display() {
788        assert_eq!(format!("{}", RuleSeverity::Error), "ERROR");
789        assert_eq!(format!("{}", RuleSeverity::Warn), "WARN");
790    }
791
792    #[test]
793    fn test_rule_severity_ordering() {
794        // Warn < Error (warn is less severe)
795        assert!(RuleSeverity::Warn < RuleSeverity::Error);
796    }
797
798    #[test]
799    fn test_rule_severity_serialization() {
800        let error = RuleSeverity::Error;
801        let json = serde_json::to_string(&error).unwrap();
802        assert_eq!(json, "\"error\"");
803
804        let warn = RuleSeverity::Warn;
805        let json = serde_json::to_string(&warn).unwrap();
806        assert_eq!(json, "\"warn\"");
807
808        let deserialized: RuleSeverity = serde_json::from_str("\"error\"").unwrap();
809        assert_eq!(deserialized, RuleSeverity::Error);
810
811        let deserialized: RuleSeverity = serde_json::from_str("\"warn\"").unwrap();
812        assert_eq!(deserialized, RuleSeverity::Warn);
813    }
814
815    // ========== Summary with RuleSeverity Tests ==========
816
817    fn create_test_finding(
818        id: &str,
819        severity: Severity,
820        rule_severity: Option<RuleSeverity>,
821    ) -> Finding {
822        Finding {
823            id: id.to_string(),
824            severity,
825            category: Category::Exfiltration,
826            confidence: Confidence::Firm,
827            name: "Test".to_string(),
828            location: Location {
829                file: "test.sh".to_string(),
830                line: 1,
831                column: None,
832            },
833            code: "test".to_string(),
834            message: "test".to_string(),
835            recommendation: "test".to_string(),
836            fix_hint: None,
837            cwe_ids: vec![],
838            rule_severity,
839            client: None,
840            context: None,
841        }
842    }
843
844    #[test]
845    fn test_summary_with_rule_severity_empty() {
846        let findings: Vec<Finding> = vec![];
847        let summary = Summary::from_findings_with_rule_severity(&findings);
848        assert_eq!(summary.errors, 0);
849        assert_eq!(summary.warnings, 0);
850        assert!(summary.passed);
851    }
852
853    #[test]
854    fn test_summary_with_rule_severity_all_errors() {
855        let findings = vec![
856            create_test_finding("E-001", Severity::Critical, Some(RuleSeverity::Error)),
857            create_test_finding("E-002", Severity::High, Some(RuleSeverity::Error)),
858        ];
859        let summary = Summary::from_findings_with_rule_severity(&findings);
860        assert_eq!(summary.errors, 2);
861        assert_eq!(summary.warnings, 0);
862        assert!(!summary.passed);
863    }
864
865    #[test]
866    fn test_summary_with_rule_severity_all_warnings() {
867        let findings = vec![
868            create_test_finding("W-001", Severity::Critical, Some(RuleSeverity::Warn)),
869            create_test_finding("W-002", Severity::High, Some(RuleSeverity::Warn)),
870        ];
871        let summary = Summary::from_findings_with_rule_severity(&findings);
872        assert_eq!(summary.errors, 0);
873        assert_eq!(summary.warnings, 2);
874        assert!(summary.passed); // No errors, so passed
875    }
876
877    #[test]
878    fn test_summary_with_rule_severity_mixed() {
879        let findings = vec![
880            create_test_finding("E-001", Severity::Critical, Some(RuleSeverity::Error)),
881            create_test_finding("W-001", Severity::High, Some(RuleSeverity::Warn)),
882            create_test_finding("W-002", Severity::Medium, Some(RuleSeverity::Warn)),
883        ];
884        let summary = Summary::from_findings_with_rule_severity(&findings);
885        assert_eq!(summary.errors, 1);
886        assert_eq!(summary.warnings, 2);
887        assert!(!summary.passed); // Has errors, so failed
888        // Also check severity counts
889        assert_eq!(summary.critical, 1);
890        assert_eq!(summary.high, 1);
891        assert_eq!(summary.medium, 1);
892    }
893
894    #[test]
895    fn test_summary_with_rule_severity_none_defaults_to_error() {
896        let findings = vec![
897            create_test_finding("N-001", Severity::Low, None), // None defaults to Error
898        ];
899        let summary = Summary::from_findings_with_rule_severity(&findings);
900        assert_eq!(summary.errors, 1);
901        assert_eq!(summary.warnings, 0);
902        assert!(!summary.passed);
903    }
904
905    #[test]
906    fn test_finding_rule_severity_not_serialized_when_none() {
907        let finding = create_test_finding("TEST-001", Severity::High, None);
908        let json = serde_json::to_string(&finding).unwrap();
909        assert!(!json.contains("rule_severity"));
910    }
911
912    #[test]
913    fn test_finding_rule_severity_serialized_when_some() {
914        let finding = create_test_finding("TEST-001", Severity::High, Some(RuleSeverity::Warn));
915        let json = serde_json::to_string(&finding).unwrap();
916        assert!(json.contains("\"rule_severity\":\"warn\""));
917    }
918
919    // ========== ParseEnumError Tests ==========
920
921    #[test]
922    fn test_parse_enum_error_invalid() {
923        let error = ParseEnumError::invalid("TestType", "bad_value");
924        assert_eq!(error.type_name, "TestType");
925        assert_eq!(error.value, "bad_value");
926    }
927
928    #[test]
929    fn test_parse_enum_error_display() {
930        let error = ParseEnumError::invalid("RuleSeverity", "unknown");
931        let display = format!("{}", error);
932        assert_eq!(display, "invalid RuleSeverity value: 'unknown'");
933    }
934
935    #[test]
936    fn test_parse_enum_error_debug() {
937        let error = ParseEnumError::invalid("TestType", "value");
938        let debug = format!("{:?}", error);
939        assert!(debug.contains("ParseEnumError"));
940        assert!(debug.contains("TestType"));
941        assert!(debug.contains("value"));
942    }
943
944    #[test]
945    fn test_parse_enum_error_is_error() {
946        let error = ParseEnumError::invalid("Test", "val");
947        // Verify it implements std::error::Error
948        let _: &dyn std::error::Error = &error;
949    }
950
951    // ========== RuleSeverity FromStr Tests ==========
952
953    #[test]
954    fn test_rule_severity_from_str_valid() {
955        use std::str::FromStr;
956
957        // Standard names
958        assert_eq!(RuleSeverity::from_str("warn").unwrap(), RuleSeverity::Warn);
959        assert_eq!(
960            RuleSeverity::from_str("error").unwrap(),
961            RuleSeverity::Error
962        );
963
964        // Alternate names
965        assert_eq!(
966            RuleSeverity::from_str("warning").unwrap(),
967            RuleSeverity::Warn
968        );
969        assert_eq!(RuleSeverity::from_str("err").unwrap(), RuleSeverity::Error);
970
971        // Case insensitive
972        assert_eq!(RuleSeverity::from_str("WARN").unwrap(), RuleSeverity::Warn);
973        assert_eq!(
974            RuleSeverity::from_str("ERROR").unwrap(),
975            RuleSeverity::Error
976        );
977        assert_eq!(
978            RuleSeverity::from_str("Warning").unwrap(),
979            RuleSeverity::Warn
980        );
981    }
982
983    #[test]
984    fn test_rule_severity_from_str_invalid() {
985        use std::str::FromStr;
986
987        let result = RuleSeverity::from_str("invalid");
988        assert!(result.is_err());
989        let error = result.unwrap_err();
990        assert_eq!(error.type_name, "RuleSeverity");
991        assert_eq!(error.value, "invalid");
992
993        // Empty string
994        let result = RuleSeverity::from_str("");
995        assert!(result.is_err());
996
997        // Random value
998        let result = RuleSeverity::from_str("critical");
999        assert!(result.is_err());
1000    }
1001
1002    // ========== Severity FromStr Tests ==========
1003
1004    #[test]
1005    fn test_severity_from_str_valid() {
1006        use std::str::FromStr;
1007
1008        assert_eq!(Severity::from_str("low").unwrap(), Severity::Low);
1009        assert_eq!(Severity::from_str("LOW").unwrap(), Severity::Low);
1010        assert_eq!(Severity::from_str("Low").unwrap(), Severity::Low);
1011
1012        assert_eq!(Severity::from_str("medium").unwrap(), Severity::Medium);
1013        assert_eq!(Severity::from_str("MEDIUM").unwrap(), Severity::Medium);
1014        assert_eq!(Severity::from_str("med").unwrap(), Severity::Medium);
1015        assert_eq!(Severity::from_str("MED").unwrap(), Severity::Medium);
1016
1017        assert_eq!(Severity::from_str("high").unwrap(), Severity::High);
1018        assert_eq!(Severity::from_str("HIGH").unwrap(), Severity::High);
1019
1020        assert_eq!(Severity::from_str("critical").unwrap(), Severity::Critical);
1021        assert_eq!(Severity::from_str("CRITICAL").unwrap(), Severity::Critical);
1022        assert_eq!(Severity::from_str("crit").unwrap(), Severity::Critical);
1023        assert_eq!(Severity::from_str("CRIT").unwrap(), Severity::Critical);
1024    }
1025
1026    #[test]
1027    fn test_severity_from_str_invalid() {
1028        use std::str::FromStr;
1029
1030        let result = Severity::from_str("invalid");
1031        assert!(result.is_err());
1032        let error = result.unwrap_err();
1033        assert_eq!(error.type_name, "Severity");
1034        assert_eq!(error.value, "invalid");
1035
1036        // Empty string
1037        let result = Severity::from_str("");
1038        assert!(result.is_err());
1039    }
1040
1041    // ========== Category FromStr Tests ==========
1042
1043    #[test]
1044    fn test_category_from_str_valid() {
1045        use std::str::FromStr;
1046
1047        // Exfiltration
1048        assert_eq!(
1049            Category::from_str("exfiltration").unwrap(),
1050            Category::Exfiltration
1051        );
1052        assert_eq!(
1053            Category::from_str("EXFILTRATION").unwrap(),
1054            Category::Exfiltration
1055        );
1056        assert_eq!(Category::from_str("exfil").unwrap(), Category::Exfiltration);
1057        assert_eq!(Category::from_str("EXFIL").unwrap(), Category::Exfiltration);
1058
1059        // Privilege Escalation
1060        assert_eq!(
1061            Category::from_str("privilege_escalation").unwrap(),
1062            Category::PrivilegeEscalation
1063        );
1064        assert_eq!(
1065            Category::from_str("privilege-escalation").unwrap(),
1066            Category::PrivilegeEscalation
1067        );
1068        assert_eq!(
1069            Category::from_str("privilegeescalation").unwrap(),
1070            Category::PrivilegeEscalation
1071        );
1072        assert_eq!(
1073            Category::from_str("privesc").unwrap(),
1074            Category::PrivilegeEscalation
1075        );
1076
1077        // Persistence
1078        assert_eq!(
1079            Category::from_str("persistence").unwrap(),
1080            Category::Persistence
1081        );
1082
1083        // Prompt Injection
1084        assert_eq!(
1085            Category::from_str("prompt_injection").unwrap(),
1086            Category::PromptInjection
1087        );
1088        assert_eq!(
1089            Category::from_str("promptinjection").unwrap(),
1090            Category::PromptInjection
1091        );
1092
1093        // Overpermission
1094        assert_eq!(
1095            Category::from_str("overpermission").unwrap(),
1096            Category::Overpermission
1097        );
1098
1099        // Obfuscation
1100        assert_eq!(
1101            Category::from_str("obfuscation").unwrap(),
1102            Category::Obfuscation
1103        );
1104
1105        // Supply Chain
1106        assert_eq!(
1107            Category::from_str("supply_chain").unwrap(),
1108            Category::SupplyChain
1109        );
1110        assert_eq!(
1111            Category::from_str("supplychain").unwrap(),
1112            Category::SupplyChain
1113        );
1114
1115        // Secret Leak
1116        assert_eq!(
1117            Category::from_str("secret_leak").unwrap(),
1118            Category::SecretLeak
1119        );
1120        assert_eq!(
1121            Category::from_str("secretleak").unwrap(),
1122            Category::SecretLeak
1123        );
1124    }
1125
1126    #[test]
1127    fn test_category_from_str_invalid() {
1128        use std::str::FromStr;
1129
1130        let result = Category::from_str("invalid");
1131        assert!(result.is_err());
1132        let error = result.unwrap_err();
1133        assert_eq!(error.type_name, "Category");
1134        assert_eq!(error.value, "invalid");
1135
1136        // Empty string
1137        let result = Category::from_str("");
1138        assert!(result.is_err());
1139    }
1140
1141    // ========== Finding with_context and with_client Tests ==========
1142
1143    #[test]
1144    fn test_finding_with_context() {
1145        use crate::context::ContentContext;
1146
1147        let finding = create_test_finding("TEST-001", Severity::High, None);
1148        assert!(finding.context.is_none());
1149
1150        let finding_with_context = finding.with_context(ContentContext::Documentation);
1151        assert_eq!(
1152            finding_with_context.context,
1153            Some(ContentContext::Documentation)
1154        );
1155    }
1156
1157    #[test]
1158    fn test_finding_with_client() {
1159        let finding = create_test_finding("TEST-001", Severity::High, None);
1160        assert!(finding.client.is_none());
1161
1162        let finding_with_client = finding.with_client(Some("claude".to_string()));
1163        assert_eq!(finding_with_client.client, Some("claude".to_string()));
1164
1165        // Test with None
1166        let finding2 = create_test_finding("TEST-002", Severity::Medium, None);
1167        let finding_without_client = finding2.with_client(None);
1168        assert!(finding_without_client.client.is_none());
1169    }
1170}