Skip to main content

covguard_types/
lib.rs

1//! Core types and DTOs for covguard.
2//!
3//! This crate defines the data transfer objects used throughout covguard,
4//! including the report schema, findings, verdicts, and error codes.
5
6pub use covguard_policy::Scope;
7use serde::{Deserialize, Serialize};
8use sha2::{Digest, Sha256};
9
10// ============================================================================
11// Schema and Code Constants
12// ============================================================================
13
14/// Schema identifier for the covguard report format.
15pub const SCHEMA_ID: &str = "covguard.report.v1";
16
17/// Schema identifier for the sensor.report.v1 format (Cockpit ecosystem).
18pub const SENSOR_SCHEMA_ID: &str = "sensor.report.v1";
19
20/// Error code for uncovered changed lines.
21pub const CODE_UNCOVERED_LINE: &str = "covguard.diff.uncovered_line";
22
23/// Error code for diff coverage below threshold.
24pub const CODE_COVERAGE_BELOW_THRESHOLD: &str = "covguard.diff.coverage_below_threshold";
25
26/// Error code for files with changes but no coverage data.
27pub const CODE_MISSING_COVERAGE_FOR_FILE: &str = "covguard.diff.missing_coverage_for_file";
28
29/// Error code for invalid LCOV input.
30pub const CODE_INVALID_LCOV: &str = "covguard.input.invalid_lcov";
31
32/// Error code for invalid diff input.
33pub const CODE_INVALID_DIFF: &str = "covguard.input.invalid_diff";
34
35/// Error code for runtime errors.
36pub const CODE_RUNTIME_ERROR: &str = "tool.runtime_error";
37
38/// Check ID for runtime/tool errors.
39pub const CHECK_ID_RUNTIME: &str = "tool.runtime_error";
40
41// ============================================================================
42// Verdict Reason Tokens (fleet vocabulary)
43// ============================================================================
44
45/// Reason: LCOV coverage data was not provided.
46pub const REASON_MISSING_LCOV: &str = "missing_lcov";
47
48/// Reason: Diff contained no changed lines in scope.
49pub const REASON_NO_CHANGED_LINES: &str = "no_changed_lines";
50
51/// Reason: All diff lines are covered.
52pub const REASON_DIFF_COVERED: &str = "diff_covered";
53
54/// Reason: Some changed lines are uncovered.
55pub const REASON_UNCOVERED_LINES: &str = "uncovered_lines";
56
57/// Reason: Diff coverage is below the configured threshold.
58pub const REASON_BELOW_THRESHOLD: &str = "below_threshold";
59
60/// Reason: A tool/runtime error occurred.
61pub const REASON_TOOL_ERROR: &str = "tool_error";
62
63/// Reason: Evaluation was skipped (e.g., missing inputs in cockpit mode).
64pub const REASON_SKIPPED: &str = "skipped";
65
66/// Reason: Findings were truncated due to max_findings limit.
67pub const REASON_TRUNCATED: &str = "truncated";
68
69/// Reason: Diff input was not provided.
70pub const REASON_MISSING_DIFF: &str = "missing_diff";
71
72// ============================================================================
73// Fingerprint
74// ============================================================================
75
76/// Compute a SHA-256 fingerprint from pipe-delimited parts.
77///
78/// Joins all parts with `|`, hashes with SHA-256, and returns lowercase hex.
79pub fn compute_fingerprint(parts: &[&str]) -> String {
80    let input = parts.join("|");
81    let mut hasher = Sha256::new();
82    hasher.update(input.as_bytes());
83    let result = hasher.finalize();
84    format!("{:x}", result)
85}
86
87// ============================================================================
88// Code Registry
89// ============================================================================
90
91/// Metadata for a covguard error code.
92#[derive(Debug, Clone, Copy)]
93pub struct CodeInfo {
94    pub code: &'static str,
95    pub name: &'static str,
96    pub short_description: &'static str,
97    pub full_description: &'static str,
98    pub remediation: &'static str,
99    pub help_anchor: &'static str,
100    pub help_uri: &'static str,
101}
102
103/// Registry of all covguard codes.
104pub const CODE_REGISTRY: &[CodeInfo] = &[
105    CodeInfo {
106        code: CODE_UNCOVERED_LINE,
107        name: "UncoveredLine",
108        short_description: "Uncovered changed line",
109        full_description: "A changed line has zero hits in LCOV coverage data.",
110        remediation: "Add tests to execute the line, or use covguard: ignore / exclude the path.",
111        help_anchor: "uncovered_line",
112        help_uri: "https://github.com/covguard/covguard/blob/main/docs/codes.md#uncovered_line",
113    },
114    CodeInfo {
115        code: CODE_COVERAGE_BELOW_THRESHOLD,
116        name: "CoverageBelowThreshold",
117        short_description: "Diff coverage below threshold",
118        full_description: "Diff-scoped coverage percentage is below the configured threshold.",
119        remediation: "Add tests for changed lines or adjust coverage configuration/normalization.",
120        help_anchor: "coverage_below_threshold",
121        help_uri: "https://github.com/covguard/covguard/blob/main/docs/codes.md#coverage_below_threshold",
122    },
123    CodeInfo {
124        code: CODE_MISSING_COVERAGE_FOR_FILE,
125        name: "MissingCoverageForFile",
126        short_description: "Missing coverage for file",
127        full_description: "A changed file has no LCOV record.",
128        remediation: "Ensure coverage includes the file, or exclude the path if appropriate.",
129        help_anchor: "missing_coverage_for_file",
130        help_uri: "https://github.com/covguard/covguard/blob/main/docs/codes.md#missing_coverage_for_file",
131    },
132    CodeInfo {
133        code: CODE_INVALID_LCOV,
134        name: "InvalidLcov",
135        short_description: "Invalid LCOV input",
136        full_description: "LCOV input could not be parsed as valid LCOV format.",
137        remediation: "Regenerate LCOV and ensure the file is not truncated or corrupted.",
138        help_anchor: "invalid_lcov",
139        help_uri: "https://github.com/covguard/covguard/blob/main/docs/codes.md#invalid_lcov",
140    },
141    CodeInfo {
142        code: CODE_INVALID_DIFF,
143        name: "InvalidDiff",
144        short_description: "Invalid diff input",
145        full_description: "Diff input could not be parsed as a unified diff.",
146        remediation: "Ensure a valid unified diff is provided or use --base/--head.",
147        help_anchor: "invalid_diff",
148        help_uri: "https://github.com/covguard/covguard/blob/main/docs/codes.md#invalid_diff",
149    },
150    CodeInfo {
151        code: CODE_RUNTIME_ERROR,
152        name: "RuntimeError",
153        short_description: "Tool runtime error",
154        full_description: "covguard failed due to a runtime or internal error.",
155        remediation: "Re-run with raw inputs captured and file a bug if reproducible.",
156        help_anchor: "runtime_error",
157        help_uri: "https://github.com/covguard/covguard/blob/main/docs/codes.md#runtime_error",
158    },
159];
160
161/// Lookup code metadata by code string.
162pub fn explain(code: &str) -> Option<&'static CodeInfo> {
163    CODE_REGISTRY.iter().find(|info| info.code == code)
164}
165
166// ============================================================================
167// Enums
168// ============================================================================
169
170/// Severity level for findings.
171#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
172#[serde(rename_all = "lowercase")]
173pub enum Severity {
174    Info,
175    Warn,
176    Error,
177}
178
179/// Status of the overall verdict.
180#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
181#[serde(rename_all = "lowercase")]
182pub enum VerdictStatus {
183    Pass,
184    Warn,
185    Fail,
186    Skip,
187}
188
189/// Input availability status for capabilities block.
190///
191/// Used to implement "No Green By Omission" - explicitly report input availability.
192#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
193#[serde(rename_all = "lowercase")]
194pub enum InputStatus {
195    /// Input was available and processed.
196    Available,
197    /// Input was not available (file missing, not provided).
198    Unavailable,
199    /// Input was available but skipped (e.g., disabled by configuration).
200    Skipped,
201}
202
203/// Capabilities block for sensor.report.v1 compliance.
204///
205/// Reports what inputs were available and processed.
206#[derive(Debug, Clone, Serialize, Deserialize)]
207pub struct Capabilities {
208    /// Status of each input type.
209    pub inputs: InputsCapability,
210}
211
212/// A single input capability with status and optional reason.
213#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
214pub struct InputCapability {
215    /// Availability status of the input.
216    pub status: InputStatus,
217    /// Reason for the status (e.g., "missing_lcov" when unavailable).
218    #[serde(skip_serializing_if = "Option::is_none")]
219    pub reason: Option<String>,
220}
221
222/// Input availability for the capabilities block.
223#[derive(Debug, Clone, Serialize, Deserialize)]
224pub struct InputsCapability {
225    /// Diff input availability.
226    pub diff: InputCapability,
227    /// Coverage input availability.
228    pub coverage: InputCapability,
229}
230
231// ============================================================================
232// Structs
233// ============================================================================
234
235/// Information about the tool that generated the report.
236#[derive(Debug, Clone, Serialize, Deserialize)]
237pub struct Tool {
238    /// Name of the tool.
239    pub name: String,
240    /// Version of the tool.
241    pub version: String,
242    /// Git commit hash of the tool, if available.
243    #[serde(skip_serializing_if = "Option::is_none")]
244    pub commit: Option<String>,
245}
246
247impl Default for Tool {
248    fn default() -> Self {
249        Self {
250            name: "covguard".to_string(),
251            version: env!("CARGO_PKG_VERSION").to_string(),
252            commit: None,
253        }
254    }
255}
256
257/// Information about the run timing.
258#[derive(Debug, Clone, Serialize, Deserialize)]
259pub struct Run {
260    /// ISO 8601 timestamp when the run started.
261    pub started_at: String,
262    /// ISO 8601 timestamp when the run ended.
263    #[serde(skip_serializing_if = "Option::is_none")]
264    pub ended_at: Option<String>,
265    /// Duration of the run in milliseconds.
266    #[serde(skip_serializing_if = "Option::is_none")]
267    pub duration_ms: Option<u64>,
268    /// Capabilities block for sensor.report.v1 compliance.
269    #[serde(skip_serializing_if = "Option::is_none")]
270    pub capabilities: Option<Capabilities>,
271}
272
273impl Default for Run {
274    fn default() -> Self {
275        Self {
276            started_at: chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(),
277            ended_at: None,
278            duration_ms: None,
279            capabilities: None,
280        }
281    }
282}
283
284/// Counts of findings by severity.
285#[derive(Debug, Clone, Default, Serialize, Deserialize)]
286pub struct VerdictCounts {
287    /// Number of info-level findings.
288    pub info: u32,
289    /// Number of warn-level findings.
290    pub warn: u32,
291    /// Number of error-level findings.
292    pub error: u32,
293}
294
295/// The overall verdict of the coverage check.
296#[derive(Debug, Clone, Serialize, Deserialize)]
297pub struct Verdict {
298    /// Overall status of the check.
299    pub status: VerdictStatus,
300    /// Counts of findings by severity.
301    pub counts: VerdictCounts,
302    /// Reasons for the verdict.
303    pub reasons: Vec<String>,
304}
305
306impl Default for Verdict {
307    fn default() -> Self {
308        Self {
309            status: VerdictStatus::Pass,
310            counts: VerdictCounts::default(),
311            reasons: Vec::new(),
312        }
313    }
314}
315
316/// Location of a finding in the source code.
317#[derive(Debug, Clone, Serialize, Deserialize)]
318pub struct Location {
319    /// Repo-relative path to the file (forward slashes, no ./ prefix).
320    pub path: String,
321    /// Line number (1-indexed).
322    #[serde(skip_serializing_if = "Option::is_none")]
323    pub line: Option<u32>,
324    /// Column number (1-indexed).
325    #[serde(skip_serializing_if = "Option::is_none")]
326    pub col: Option<u32>,
327}
328
329/// A single finding from the coverage analysis.
330#[derive(Debug, Clone, Serialize, Deserialize)]
331pub struct Finding {
332    /// Severity of the finding.
333    pub severity: Severity,
334    /// Check identifier (e.g., "diff.uncovered_line").
335    pub check_id: String,
336    /// Full error code (e.g., "covguard.diff.uncovered_line").
337    pub code: String,
338    /// Human-readable message describing the finding.
339    pub message: String,
340    /// Location of the finding in source code.
341    #[serde(skip_serializing_if = "Option::is_none")]
342    pub location: Option<Location>,
343    /// Additional structured data about the finding.
344    #[serde(skip_serializing_if = "Option::is_none")]
345    pub data: Option<serde_json::Value>,
346    /// SHA-256 fingerprint for deduplication (`^[a-f0-9]{64}$`).
347    #[serde(skip_serializing_if = "Option::is_none")]
348    pub fingerprint: Option<String>,
349}
350
351impl Finding {
352    /// Create a finding for an uncovered line.
353    pub fn uncovered_line(path: impl Into<String>, line: u32, hits: u64) -> Self {
354        Self {
355            severity: Severity::Error,
356            check_id: "diff.uncovered_line".to_string(),
357            code: CODE_UNCOVERED_LINE.to_string(),
358            message: format!("Uncovered changed line (hits={}).", hits),
359            location: Some(Location {
360                path: path.into(),
361                line: Some(line),
362                col: None,
363            }),
364            data: Some(serde_json::json!({ "hits": hits })),
365            fingerprint: None,
366        }
367    }
368}
369
370/// Information about the inputs used for the analysis.
371#[derive(Debug, Clone, Serialize, Deserialize)]
372pub struct Inputs {
373    /// Source of the diff ("diff-file", "git-refs", etc.).
374    pub diff_source: String,
375    /// Path to the diff file, if applicable.
376    #[serde(skip_serializing_if = "Option::is_none")]
377    pub diff_file: Option<String>,
378    /// Base git ref, if applicable.
379    #[serde(skip_serializing_if = "Option::is_none")]
380    pub base: Option<String>,
381    /// Head git ref, if applicable.
382    #[serde(skip_serializing_if = "Option::is_none")]
383    pub head: Option<String>,
384    /// Paths to LCOV coverage files.
385    pub lcov_paths: Vec<String>,
386}
387
388impl Default for Inputs {
389    fn default() -> Self {
390        Self {
391            diff_source: "diff-file".to_string(),
392            diff_file: None,
393            base: None,
394            head: None,
395            lcov_paths: Vec::new(),
396        }
397    }
398}
399
400/// Truncation metadata when findings exceed `max_findings`.
401#[derive(Debug, Clone, Serialize, Deserialize)]
402pub struct Truncation {
403    /// Whether findings were truncated.
404    pub findings_truncated: bool,
405    /// Number of findings shown in the report.
406    pub shown: u32,
407    /// Total number of findings before truncation.
408    pub total: u32,
409}
410
411/// Aggregated data about the coverage analysis.
412#[derive(Debug, Clone, Serialize, Deserialize)]
413pub struct ReportData {
414    /// Scope of lines evaluated ("added" or "touched").
415    pub scope: String,
416    /// Coverage threshold percentage.
417    pub threshold_pct: f64,
418    /// Total number of changed lines in scope.
419    pub changed_lines_total: u32,
420    /// Number of covered lines.
421    pub covered_lines: u32,
422    /// Number of uncovered lines.
423    pub uncovered_lines: u32,
424    /// Number of lines with missing coverage data.
425    pub missing_lines: u32,
426    /// Number of lines ignored via `covguard: ignore` directive.
427    #[serde(default, skip_serializing_if = "is_zero")]
428    pub ignored_lines_count: u32,
429    /// Number of files excluded via include/exclude filtering.
430    #[serde(default, skip_serializing_if = "is_zero")]
431    pub excluded_files_count: u32,
432    /// Diff coverage percentage.
433    pub diff_coverage_pct: f64,
434    /// Information about the inputs.
435    pub inputs: Inputs,
436    /// Optional debug payload (opaque).
437    #[serde(skip_serializing_if = "Option::is_none")]
438    pub debug: Option<serde_json::Value>,
439    /// Truncation metadata (populated when findings exceed max_findings).
440    #[serde(skip_serializing_if = "Option::is_none")]
441    pub truncation: Option<Truncation>,
442}
443
444fn is_zero(n: &u32) -> bool {
445    *n == 0
446}
447
448impl Default for ReportData {
449    fn default() -> Self {
450        Self {
451            scope: "added".to_string(),
452            threshold_pct: 80.0,
453            changed_lines_total: 0,
454            covered_lines: 0,
455            uncovered_lines: 0,
456            missing_lines: 0,
457            ignored_lines_count: 0,
458            excluded_files_count: 0,
459            diff_coverage_pct: 0.0,
460            inputs: Inputs::default(),
461            debug: None,
462            truncation: None,
463        }
464    }
465}
466
467/// The full coverage report.
468#[derive(Debug, Clone, Serialize, Deserialize)]
469pub struct Report {
470    /// Schema identifier.
471    pub schema: String,
472    /// Tool information.
473    pub tool: Tool,
474    /// Run timing information.
475    pub run: Run,
476    /// Overall verdict.
477    pub verdict: Verdict,
478    /// List of findings.
479    pub findings: Vec<Finding>,
480    /// Aggregated data.
481    pub data: ReportData,
482}
483
484impl Report {
485    /// Create a new report with default values.
486    pub fn new() -> Self {
487        Self::default()
488    }
489}
490
491impl Default for Report {
492    fn default() -> Self {
493        Self {
494            schema: SCHEMA_ID.to_string(),
495            tool: Tool::default(),
496            run: Run::default(),
497            verdict: Verdict::default(),
498            findings: Vec::new(),
499            data: ReportData::default(),
500        }
501    }
502}
503
504// ============================================================================
505// Tests
506// ============================================================================
507
508#[cfg(test)]
509mod tests {
510    use super::*;
511
512    #[test]
513    fn test_compute_fingerprint_known_values() {
514        assert_eq!(
515            compute_fingerprint(&["a", "b"]),
516            "0eab8a0a3380abf4c7d1fb0b43b66aafbb64a4b953e4eb2dccca579461912d0c"
517        );
518        assert_eq!(
519            compute_fingerprint(&[]),
520            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
521        );
522    }
523
524    #[test]
525    fn test_explain_returns_code_info() {
526        let info = explain(CODE_UNCOVERED_LINE).expect("code should exist");
527        assert_eq!(info.code, CODE_UNCOVERED_LINE);
528        assert_eq!(info.name, "UncoveredLine");
529        assert!(explain("covguard.missing.code").is_none());
530    }
531
532    #[test]
533    fn test_severity_serialization() {
534        assert_eq!(serde_json::to_string(&Severity::Info).unwrap(), "\"info\"");
535        assert_eq!(serde_json::to_string(&Severity::Warn).unwrap(), "\"warn\"");
536        assert_eq!(
537            serde_json::to_string(&Severity::Error).unwrap(),
538            "\"error\""
539        );
540    }
541
542    #[test]
543    fn test_severity_deserialization() {
544        assert_eq!(
545            serde_json::from_str::<Severity>("\"info\"").unwrap(),
546            Severity::Info
547        );
548        assert_eq!(
549            serde_json::from_str::<Severity>("\"warn\"").unwrap(),
550            Severity::Warn
551        );
552        assert_eq!(
553            serde_json::from_str::<Severity>("\"error\"").unwrap(),
554            Severity::Error
555        );
556    }
557
558    #[test]
559    fn test_verdict_status_serialization() {
560        assert_eq!(
561            serde_json::to_string(&VerdictStatus::Pass).unwrap(),
562            "\"pass\""
563        );
564        assert_eq!(
565            serde_json::to_string(&VerdictStatus::Warn).unwrap(),
566            "\"warn\""
567        );
568        assert_eq!(
569            serde_json::to_string(&VerdictStatus::Fail).unwrap(),
570            "\"fail\""
571        );
572        assert_eq!(
573            serde_json::to_string(&VerdictStatus::Skip).unwrap(),
574            "\"skip\""
575        );
576    }
577
578    #[test]
579    fn test_scope_serialization() {
580        assert_eq!(serde_json::to_string(&Scope::Added).unwrap(), "\"added\"");
581        assert_eq!(
582            serde_json::to_string(&Scope::Touched).unwrap(),
583            "\"touched\""
584        );
585    }
586
587    #[test]
588    fn test_finding_uncovered_line() {
589        let finding = Finding::uncovered_line("src/lib.rs", 42, 0);
590
591        assert_eq!(finding.severity, Severity::Error);
592        assert_eq!(finding.check_id, "diff.uncovered_line");
593        assert_eq!(finding.code, CODE_UNCOVERED_LINE);
594        assert_eq!(finding.message, "Uncovered changed line (hits=0).");
595
596        let location = finding.location.unwrap();
597        assert_eq!(location.path, "src/lib.rs");
598        assert_eq!(location.line, Some(42));
599        assert_eq!(location.col, None);
600
601        let data = finding.data.unwrap();
602        assert_eq!(data["hits"], 0);
603    }
604
605    #[test]
606    fn test_report_default() {
607        let report = Report::new();
608
609        assert_eq!(report.schema, SCHEMA_ID);
610        assert_eq!(report.tool.name, "covguard");
611        assert_eq!(report.verdict.status, VerdictStatus::Pass);
612        assert!(report.findings.is_empty());
613    }
614
615    #[test]
616    fn test_optional_fields_not_serialized() {
617        let tool = Tool {
618            name: "covguard".to_string(),
619            version: "0.1.0".to_string(),
620            commit: None,
621        };
622
623        let json = serde_json::to_string(&tool).unwrap();
624        assert!(!json.contains("commit"));
625    }
626
627    #[test]
628    fn test_location_serialization() {
629        let location = Location {
630            path: "src/main.rs".to_string(),
631            line: Some(10),
632            col: None,
633        };
634
635        let json = serde_json::to_string(&location).unwrap();
636        assert!(json.contains("\"path\":\"src/main.rs\""));
637        assert!(json.contains("\"line\":10"));
638        assert!(!json.contains("col"));
639    }
640
641    #[test]
642    fn test_report_matches_expected_json_structure() {
643        // Build a report matching fixtures/expected/report_uncovered.json
644        let report = Report {
645            schema: SCHEMA_ID.to_string(),
646            tool: Tool {
647                name: "covguard".to_string(),
648                version: "0.1.0".to_string(),
649                commit: None,
650            },
651            run: Run {
652                started_at: "2026-02-02T00:00:00Z".to_string(),
653                ended_at: None,
654                duration_ms: None,
655                capabilities: None,
656            },
657            verdict: Verdict {
658                status: VerdictStatus::Fail,
659                counts: VerdictCounts {
660                    info: 0,
661                    warn: 0,
662                    error: 3,
663                },
664                reasons: vec!["uncovered_lines".to_string()],
665            },
666            findings: vec![
667                Finding::uncovered_line("src/lib.rs", 1, 0),
668                Finding::uncovered_line("src/lib.rs", 2, 0),
669                Finding::uncovered_line("src/lib.rs", 3, 0),
670            ],
671            data: ReportData {
672                scope: "added".to_string(),
673                threshold_pct: 80.0,
674                changed_lines_total: 3,
675                covered_lines: 0,
676                uncovered_lines: 3,
677                missing_lines: 0,
678                ignored_lines_count: 0,
679                excluded_files_count: 0,
680                diff_coverage_pct: 0.0,
681                inputs: Inputs {
682                    diff_source: "diff-file".to_string(),
683                    diff_file: Some("fixtures/diff/simple_added.patch".to_string()),
684                    base: None,
685                    head: None,
686                    lcov_paths: vec!["fixtures/lcov/uncovered.info".to_string()],
687                },
688                debug: None,
689                truncation: None,
690            },
691        };
692
693        let json = serde_json::to_value(&report).unwrap();
694
695        // Verify structure matches expected
696        assert_eq!(json["schema"], "covguard.report.v1");
697        assert_eq!(json["tool"]["name"], "covguard");
698        assert_eq!(json["tool"]["version"], "0.1.0");
699        assert_eq!(json["run"]["started_at"], "2026-02-02T00:00:00Z");
700        assert_eq!(json["verdict"]["status"], "fail");
701        assert_eq!(json["verdict"]["counts"]["error"], 3);
702        assert_eq!(json["findings"].as_array().unwrap().len(), 3);
703        assert_eq!(json["findings"][0]["severity"], "error");
704        assert_eq!(json["findings"][0]["location"]["path"], "src/lib.rs");
705        assert_eq!(json["findings"][0]["location"]["line"], 1);
706        assert_eq!(json["data"]["scope"], "added");
707        assert_eq!(json["data"]["threshold_pct"], 80.0);
708        assert_eq!(json["data"]["inputs"]["diff_source"], "diff-file");
709    }
710
711    #[test]
712    fn test_full_report_roundtrip() {
713        let report = Report::new();
714        let json = serde_json::to_string(&report).unwrap();
715        let parsed: Report = serde_json::from_str(&json).unwrap();
716
717        assert_eq!(report.schema, parsed.schema);
718        assert_eq!(report.tool.name, parsed.tool.name);
719        assert_eq!(report.verdict.status, parsed.verdict.status);
720    }
721
722    // ========================================================================
723    // Severity Ordering Tests
724    // ========================================================================
725
726    #[test]
727    fn test_severity_ordering() {
728        // Info < Warn < Error
729        assert!(Severity::Info < Severity::Warn);
730        assert!(Severity::Warn < Severity::Error);
731        assert!(Severity::Info < Severity::Error);
732    }
733
734    #[test]
735    fn test_severity_sorting() {
736        let mut severities = vec![
737            Severity::Error,
738            Severity::Info,
739            Severity::Warn,
740            Severity::Error,
741        ];
742        severities.sort();
743        assert_eq!(
744            severities,
745            vec![
746                Severity::Info,
747                Severity::Warn,
748                Severity::Error,
749                Severity::Error
750            ]
751        );
752    }
753
754    #[test]
755    fn test_severity_equality() {
756        assert_eq!(Severity::Info, Severity::Info);
757        assert_eq!(Severity::Warn, Severity::Warn);
758        assert_eq!(Severity::Error, Severity::Error);
759        assert_ne!(Severity::Info, Severity::Warn);
760    }
761
762    // ========================================================================
763    // Deserialization Error Tests
764    // ========================================================================
765
766    #[test]
767    fn test_invalid_severity_deserialization() {
768        let result = serde_json::from_str::<Severity>("\"invalid\"");
769        assert!(result.is_err());
770    }
771
772    #[test]
773    fn test_invalid_verdict_status_deserialization() {
774        let result = serde_json::from_str::<VerdictStatus>("\"invalid\"");
775        assert!(result.is_err());
776    }
777
778    #[test]
779    fn test_invalid_scope_deserialization() {
780        let result = serde_json::from_str::<Scope>("\"invalid\"");
781        assert!(result.is_err());
782    }
783
784    #[test]
785    fn test_missing_required_field() {
786        // Report missing schema field
787        let json = r#"{"tool": {"name": "covguard", "version": "0.1.0"}}"#;
788        let result = serde_json::from_str::<Report>(json);
789        assert!(result.is_err());
790    }
791
792    #[test]
793    fn test_wrong_type_field() {
794        // Severity should be string, not number
795        let result = serde_json::from_str::<Severity>("123");
796        assert!(result.is_err());
797    }
798
799    // ========================================================================
800    // Scope Tests
801    // ========================================================================
802
803    #[test]
804    fn test_scope_default() {
805        assert_eq!(Scope::default(), Scope::Added);
806    }
807
808    #[test]
809    fn test_scope_deserialization() {
810        assert_eq!(
811            serde_json::from_str::<Scope>("\"added\"").unwrap(),
812            Scope::Added
813        );
814        assert_eq!(
815            serde_json::from_str::<Scope>("\"touched\"").unwrap(),
816            Scope::Touched
817        );
818    }
819
820    // ========================================================================
821    // VerdictCounts Tests
822    // ========================================================================
823
824    #[test]
825    fn test_verdict_counts_default() {
826        let counts = VerdictCounts::default();
827        assert_eq!(counts.info, 0);
828        assert_eq!(counts.warn, 0);
829        assert_eq!(counts.error, 0);
830    }
831
832    #[test]
833    fn test_verdict_counts_serialization() {
834        let counts = VerdictCounts {
835            info: 1,
836            warn: 2,
837            error: 3,
838        };
839        let json = serde_json::to_value(&counts).unwrap();
840        assert_eq!(json["info"], 1);
841        assert_eq!(json["warn"], 2);
842        assert_eq!(json["error"], 3);
843    }
844
845    #[test]
846    fn test_verdict_counts_large_values() {
847        let counts = VerdictCounts {
848            info: u32::MAX,
849            warn: u32::MAX,
850            error: u32::MAX,
851        };
852        let json = serde_json::to_string(&counts).unwrap();
853        let parsed: VerdictCounts = serde_json::from_str(&json).unwrap();
854        assert_eq!(parsed.info, u32::MAX);
855        assert_eq!(parsed.warn, u32::MAX);
856        assert_eq!(parsed.error, u32::MAX);
857    }
858
859    // ========================================================================
860    // Tool Tests
861    // ========================================================================
862
863    #[test]
864    fn test_tool_default() {
865        let tool = Tool::default();
866        assert_eq!(tool.name, "covguard");
867        assert!(!tool.version.is_empty());
868        assert!(tool.commit.is_none());
869    }
870
871    #[test]
872    fn test_tool_with_commit() {
873        let tool = Tool {
874            name: "covguard".to_string(),
875            version: "1.0.0".to_string(),
876            commit: Some("abc123".to_string()),
877        };
878        let json = serde_json::to_string(&tool).unwrap();
879        assert!(json.contains("commit"));
880        assert!(json.contains("abc123"));
881    }
882
883    // ========================================================================
884    // Run Tests
885    // ========================================================================
886
887    #[test]
888    fn test_run_default_has_timestamp() {
889        let run = Run::default();
890        assert!(!run.started_at.is_empty());
891        assert!(run.started_at.contains("T")); // ISO 8601 format
892        assert!(run.ended_at.is_none());
893        assert!(run.duration_ms.is_none());
894    }
895
896    #[test]
897    fn test_run_with_duration() {
898        let run = Run {
899            started_at: "2026-02-02T00:00:00Z".to_string(),
900            ended_at: Some("2026-02-02T00:00:01Z".to_string()),
901            duration_ms: Some(1000),
902            capabilities: None,
903        };
904        let json = serde_json::to_value(&run).unwrap();
905        assert_eq!(json["duration_ms"], 1000);
906    }
907
908    // ========================================================================
909    // Verdict Tests
910    // ========================================================================
911
912    #[test]
913    fn test_verdict_default() {
914        let verdict = Verdict::default();
915        assert_eq!(verdict.status, VerdictStatus::Pass);
916        assert!(verdict.reasons.is_empty());
917    }
918
919    #[test]
920    fn test_verdict_all_statuses() {
921        for status in [
922            VerdictStatus::Pass,
923            VerdictStatus::Warn,
924            VerdictStatus::Fail,
925            VerdictStatus::Skip,
926        ] {
927            let verdict = Verdict {
928                status,
929                counts: VerdictCounts::default(),
930                reasons: Vec::new(),
931            };
932            let json = serde_json::to_string(&verdict).unwrap();
933            let parsed: Verdict = serde_json::from_str(&json).unwrap();
934            assert_eq!(verdict.status, parsed.status);
935        }
936    }
937
938    // ========================================================================
939    // Location Tests
940    // ========================================================================
941
942    #[test]
943    fn test_location_minimal() {
944        let location = Location {
945            path: "src/lib.rs".to_string(),
946            line: None,
947            col: None,
948        };
949        let json = serde_json::to_string(&location).unwrap();
950        assert!(!json.contains("line"));
951        assert!(!json.contains("col"));
952    }
953
954    #[test]
955    fn test_location_full() {
956        let location = Location {
957            path: "src/lib.rs".to_string(),
958            line: Some(42),
959            col: Some(10),
960        };
961        let json = serde_json::to_value(&location).unwrap();
962        assert_eq!(json["path"], "src/lib.rs");
963        assert_eq!(json["line"], 42);
964        assert_eq!(json["col"], 10);
965    }
966
967    #[test]
968    fn test_location_with_unicode_path() {
969        let location = Location {
970            path: "src/日本語/lib.rs".to_string(),
971            line: Some(1),
972            col: None,
973        };
974        let json = serde_json::to_string(&location).unwrap();
975        let parsed: Location = serde_json::from_str(&json).unwrap();
976        assert_eq!(parsed.path, "src/日本語/lib.rs");
977    }
978
979    // ========================================================================
980    // Finding Tests
981    // ========================================================================
982
983    #[test]
984    fn test_finding_uncovered_line_structure() {
985        let finding = Finding::uncovered_line("src/lib.rs", 42, 0);
986
987        assert_eq!(finding.severity, Severity::Error);
988        assert_eq!(finding.check_id, "diff.uncovered_line");
989        assert_eq!(finding.code, CODE_UNCOVERED_LINE);
990        assert!(finding.message.contains("hits=0"));
991    }
992
993    #[test]
994    fn test_finding_uncovered_line_with_nonzero_hits() {
995        // Edge case: technically impossible but test the formatting
996        let finding = Finding::uncovered_line("src/lib.rs", 42, 5);
997        assert!(finding.message.contains("hits=5"));
998
999        let data = finding.data.unwrap();
1000        assert_eq!(data["hits"], 5);
1001    }
1002
1003    #[test]
1004    fn test_finding_without_location() {
1005        let finding = Finding {
1006            severity: Severity::Error,
1007            check_id: "diff.coverage_below_threshold".to_string(),
1008            code: CODE_COVERAGE_BELOW_THRESHOLD.to_string(),
1009            message: "Coverage 50% is below threshold 80%".to_string(),
1010            location: None,
1011            data: None,
1012            fingerprint: None,
1013        };
1014        let json = serde_json::to_string(&finding).unwrap();
1015        assert!(!json.contains("location"));
1016    }
1017
1018    #[test]
1019    fn test_finding_large_line_number() {
1020        let finding = Finding::uncovered_line("src/lib.rs", u32::MAX, 0);
1021        let location = finding.location.unwrap();
1022        assert_eq!(location.line, Some(u32::MAX));
1023    }
1024
1025    // ========================================================================
1026    // Inputs Tests
1027    // ========================================================================
1028
1029    #[test]
1030    fn test_inputs_default() {
1031        let inputs = Inputs::default();
1032        assert_eq!(inputs.diff_source, "diff-file");
1033        assert!(inputs.diff_file.is_none());
1034        assert!(inputs.base.is_none());
1035        assert!(inputs.head.is_none());
1036        assert!(inputs.lcov_paths.is_empty());
1037    }
1038
1039    #[test]
1040    fn test_inputs_with_git_refs() {
1041        let inputs = Inputs {
1042            diff_source: "git-refs".to_string(),
1043            diff_file: None,
1044            base: Some("main".to_string()),
1045            head: Some("feature".to_string()),
1046            lcov_paths: vec!["coverage.info".to_string()],
1047        };
1048        let json = serde_json::to_value(&inputs).unwrap();
1049        assert_eq!(json["diff_source"], "git-refs");
1050        assert_eq!(json["base"], "main");
1051        assert_eq!(json["head"], "feature");
1052    }
1053
1054    #[test]
1055    fn test_inputs_with_multiple_lcov_paths() {
1056        let inputs = Inputs {
1057            diff_source: "diff-file".to_string(),
1058            diff_file: Some("changes.patch".to_string()),
1059            base: None,
1060            head: None,
1061            lcov_paths: vec![
1062                "unit.info".to_string(),
1063                "integration.info".to_string(),
1064                "e2e.info".to_string(),
1065            ],
1066        };
1067        let json = serde_json::to_value(&inputs).unwrap();
1068        let lcov_paths = json["lcov_paths"].as_array().unwrap();
1069        assert_eq!(lcov_paths.len(), 3);
1070    }
1071
1072    // ========================================================================
1073    // ReportData Tests
1074    // ========================================================================
1075
1076    #[test]
1077    fn test_report_data_default() {
1078        let data = ReportData::default();
1079        assert_eq!(data.scope, "added");
1080        assert_eq!(data.threshold_pct, 80.0);
1081        assert_eq!(data.changed_lines_total, 0);
1082        assert_eq!(data.covered_lines, 0);
1083        assert_eq!(data.uncovered_lines, 0);
1084        assert_eq!(data.missing_lines, 0);
1085        assert_eq!(data.ignored_lines_count, 0);
1086        assert_eq!(data.diff_coverage_pct, 0.0);
1087    }
1088
1089    #[test]
1090    fn test_report_data_ignored_lines_not_serialized_when_zero() {
1091        let data = ReportData {
1092            ignored_lines_count: 0,
1093            ..Default::default()
1094        };
1095        let json = serde_json::to_string(&data).unwrap();
1096        assert!(!json.contains("ignored_lines_count"));
1097    }
1098
1099    #[test]
1100    fn test_report_data_ignored_lines_serialized_when_nonzero() {
1101        let data = ReportData {
1102            ignored_lines_count: 5,
1103            ..Default::default()
1104        };
1105        let json = serde_json::to_string(&data).unwrap();
1106        assert!(json.contains("ignored_lines_count"));
1107    }
1108
1109    #[test]
1110    fn test_report_data_excluded_files_count_serialization() {
1111        let data = ReportData {
1112            excluded_files_count: 2,
1113            ..Default::default()
1114        };
1115        let json = serde_json::to_string(&data).unwrap();
1116        assert!(json.contains("excluded_files_count"));
1117    }
1118
1119    #[test]
1120    fn test_code_registry_contains_known_codes() {
1121        let codes: Vec<&str> = CODE_REGISTRY.iter().map(|c| c.code).collect();
1122        assert!(codes.contains(&CODE_UNCOVERED_LINE));
1123        assert!(codes.contains(&CODE_COVERAGE_BELOW_THRESHOLD));
1124        assert!(codes.contains(&CODE_MISSING_COVERAGE_FOR_FILE));
1125        assert!(codes.contains(&CODE_INVALID_LCOV));
1126        assert!(codes.contains(&CODE_INVALID_DIFF));
1127        assert!(codes.contains(&CODE_RUNTIME_ERROR));
1128    }
1129
1130    #[test]
1131    fn test_registry_covers_fixture_and_snapshot_codes() {
1132        use std::collections::BTreeSet;
1133        use std::fs;
1134        use std::path::PathBuf;
1135
1136        fn workspace_root() -> PathBuf {
1137            PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1138                .parent()
1139                .expect("crates directory")
1140                .parent()
1141                .expect("workspace root")
1142                .to_path_buf()
1143        }
1144
1145        fn extract_codes(content: &str) -> BTreeSet<String> {
1146            let mut codes = BTreeSet::new();
1147            let bytes = content.as_bytes();
1148            let mut i = 0;
1149            while i < bytes.len() {
1150                if bytes[i..].starts_with(b"covguard.") {
1151                    let mut j = i + "covguard.".len();
1152                    while j < bytes.len() {
1153                        let c = bytes[j] as char;
1154                        if c.is_ascii_alphanumeric() || c == '_' || c == '.' || c == '-' {
1155                            j += 1;
1156                        } else {
1157                            break;
1158                        }
1159                    }
1160                    if let Ok(code) = std::str::from_utf8(&bytes[i..j]) {
1161                        codes.insert(code.to_string());
1162                    }
1163                    i = j;
1164                    continue;
1165                }
1166                if bytes[i..].starts_with(CODE_RUNTIME_ERROR.as_bytes()) {
1167                    codes.insert(CODE_RUNTIME_ERROR.to_string());
1168                    i += CODE_RUNTIME_ERROR.len();
1169                    continue;
1170                }
1171                i += 1;
1172            }
1173            codes
1174        }
1175
1176        fn scan_dir(root: &PathBuf, rel: &str) -> BTreeSet<String> {
1177            let mut found = BTreeSet::new();
1178            let dir = root.join(rel);
1179            if !dir.exists() {
1180                return found;
1181            }
1182            let entries = fs::read_dir(&dir).expect("read_dir failed");
1183            for entry in entries.flatten() {
1184                let path = entry.path();
1185                if path.is_dir() {
1186                    let sub_rel = path
1187                        .strip_prefix(root)
1188                        .unwrap()
1189                        .to_string_lossy()
1190                        .to_string();
1191                    found.extend(scan_dir(root, &sub_rel));
1192                } else if let Ok(content) = fs::read_to_string(&path) {
1193                    found.extend(extract_codes(&content));
1194                }
1195            }
1196            found
1197        }
1198
1199        let root = workspace_root();
1200        let mut codes = BTreeSet::new();
1201        codes.extend(scan_dir(&root, "fixtures/expected"));
1202        codes.extend(scan_dir(&root, "crates/covguard-render/src/snapshots"));
1203        codes.extend(scan_dir(&root, "crates/covguard-app/src/snapshots"));
1204        let temp_root = std::env::temp_dir().join(format!("covguard-types-{}", std::process::id()));
1205        let nested_dir = temp_root.join("nested").join("inner");
1206        fs::create_dir_all(&nested_dir).expect("create temp nested dir");
1207        fs::write(nested_dir.join("codes.txt"), "covguard.diff.uncovered_line")
1208            .expect("write temp codes file");
1209        codes.extend(scan_dir(&temp_root, "nested"));
1210        let _ = fs::remove_dir_all(&temp_root);
1211
1212        // Filter out schema ID which isn't an error code
1213        codes.remove(SCHEMA_ID);
1214
1215        let registry: BTreeSet<&'static str> = CODE_REGISTRY.iter().map(|c| c.code).collect();
1216        for code in codes {
1217            assert!(registry.contains(code.as_str()));
1218        }
1219    }
1220
1221    #[test]
1222    fn test_report_data_100_percent_coverage() {
1223        let data = ReportData {
1224            changed_lines_total: 10,
1225            covered_lines: 10,
1226            uncovered_lines: 0,
1227            diff_coverage_pct: 100.0,
1228            ..Default::default()
1229        };
1230        let json = serde_json::to_value(&data).unwrap();
1231        assert_eq!(json["diff_coverage_pct"], 100.0);
1232    }
1233
1234    #[test]
1235    fn test_report_data_zero_coverage() {
1236        let data = ReportData {
1237            changed_lines_total: 10,
1238            covered_lines: 0,
1239            uncovered_lines: 10,
1240            diff_coverage_pct: 0.0,
1241            ..Default::default()
1242        };
1243        let json = serde_json::to_value(&data).unwrap();
1244        assert_eq!(json["diff_coverage_pct"], 0.0);
1245    }
1246
1247    // ========================================================================
1248    // Constants Tests
1249    // ========================================================================
1250
1251    #[test]
1252    fn test_schema_id_constant() {
1253        assert_eq!(SCHEMA_ID, "covguard.report.v1");
1254    }
1255
1256    #[test]
1257    fn test_error_code_constants() {
1258        assert!(CODE_UNCOVERED_LINE.starts_with("covguard."));
1259        assert!(CODE_COVERAGE_BELOW_THRESHOLD.starts_with("covguard."));
1260        assert!(CODE_MISSING_COVERAGE_FOR_FILE.starts_with("covguard."));
1261        assert!(CODE_INVALID_LCOV.starts_with("covguard."));
1262        assert!(CODE_INVALID_DIFF.starts_with("covguard."));
1263        assert!(CODE_RUNTIME_ERROR.starts_with("tool."));
1264    }
1265
1266    // ========================================================================
1267    // Report Tests
1268    // ========================================================================
1269
1270    #[test]
1271    fn test_report_new_equals_default() {
1272        let new = Report::new();
1273        let default = Report::default();
1274        assert_eq!(new.schema, default.schema);
1275        assert_eq!(new.tool.name, default.tool.name);
1276        assert_eq!(new.verdict.status, default.verdict.status);
1277    }
1278
1279    #[test]
1280    fn test_report_with_many_findings() {
1281        let findings: Vec<_> = (1..=1000)
1282            .map(|i| Finding::uncovered_line("src/lib.rs", i, 0))
1283            .collect();
1284
1285        let report = Report {
1286            findings,
1287            ..Default::default()
1288        };
1289
1290        let json = serde_json::to_string(&report).unwrap();
1291        let parsed: Report = serde_json::from_str(&json).unwrap();
1292        assert_eq!(parsed.findings.len(), 1000);
1293    }
1294
1295    #[test]
1296    fn test_report_empty_findings() {
1297        let report = Report {
1298            findings: Vec::new(),
1299            ..Default::default()
1300        };
1301        let json = serde_json::to_value(&report).unwrap();
1302        assert_eq!(json["findings"].as_array().unwrap().len(), 0);
1303    }
1304
1305    // ========================================================================
1306    // Capabilities Tests (sensor.report.v1)
1307    // ========================================================================
1308
1309    #[test]
1310    fn test_sensor_schema_id_constant() {
1311        assert_eq!(SENSOR_SCHEMA_ID, "sensor.report.v1");
1312    }
1313
1314    #[test]
1315    fn test_input_status_serialization() {
1316        assert_eq!(
1317            serde_json::to_string(&InputStatus::Available).unwrap(),
1318            "\"available\""
1319        );
1320        assert_eq!(
1321            serde_json::to_string(&InputStatus::Unavailable).unwrap(),
1322            "\"unavailable\""
1323        );
1324        assert_eq!(
1325            serde_json::to_string(&InputStatus::Skipped).unwrap(),
1326            "\"skipped\""
1327        );
1328    }
1329
1330    #[test]
1331    fn test_input_status_deserialization() {
1332        assert_eq!(
1333            serde_json::from_str::<InputStatus>("\"available\"").unwrap(),
1334            InputStatus::Available
1335        );
1336        assert_eq!(
1337            serde_json::from_str::<InputStatus>("\"unavailable\"").unwrap(),
1338            InputStatus::Unavailable
1339        );
1340        assert_eq!(
1341            serde_json::from_str::<InputStatus>("\"skipped\"").unwrap(),
1342            InputStatus::Skipped
1343        );
1344    }
1345
1346    #[test]
1347    fn test_input_status_invalid_deserialization() {
1348        let result = serde_json::from_str::<InputStatus>("\"invalid\"");
1349        assert!(result.is_err());
1350    }
1351
1352    #[test]
1353    fn test_capabilities_serialization() {
1354        let capabilities = Capabilities {
1355            inputs: InputsCapability {
1356                diff: InputCapability {
1357                    status: InputStatus::Available,
1358                    reason: None,
1359                },
1360                coverage: InputCapability {
1361                    status: InputStatus::Unavailable,
1362                    reason: Some("missing_lcov".to_string()),
1363                },
1364            },
1365        };
1366        let json = serde_json::to_value(&capabilities).unwrap();
1367        assert_eq!(json["inputs"]["diff"]["status"], "available");
1368        assert_eq!(json["inputs"]["coverage"]["status"], "unavailable");
1369        assert_eq!(json["inputs"]["coverage"]["reason"], "missing_lcov");
1370        // reason should not be present when None
1371        assert!(json["inputs"]["diff"].get("reason").is_none());
1372    }
1373
1374    #[test]
1375    fn test_capabilities_roundtrip() {
1376        let capabilities = Capabilities {
1377            inputs: InputsCapability {
1378                diff: InputCapability {
1379                    status: InputStatus::Available,
1380                    reason: None,
1381                },
1382                coverage: InputCapability {
1383                    status: InputStatus::Skipped,
1384                    reason: Some("disabled".to_string()),
1385                },
1386            },
1387        };
1388        let json = serde_json::to_string(&capabilities).unwrap();
1389        let parsed: Capabilities = serde_json::from_str(&json).unwrap();
1390        assert_eq!(parsed.inputs.diff.status, InputStatus::Available);
1391        assert_eq!(parsed.inputs.coverage.status, InputStatus::Skipped);
1392        assert_eq!(parsed.inputs.coverage.reason, Some("disabled".to_string()));
1393    }
1394
1395    #[test]
1396    fn test_run_with_capabilities() {
1397        let run = Run {
1398            started_at: "2026-02-02T00:00:00Z".to_string(),
1399            ended_at: None,
1400            duration_ms: None,
1401            capabilities: Some(Capabilities {
1402                inputs: InputsCapability {
1403                    diff: InputCapability {
1404                        status: InputStatus::Available,
1405                        reason: None,
1406                    },
1407                    coverage: InputCapability {
1408                        status: InputStatus::Available,
1409                        reason: None,
1410                    },
1411                },
1412            }),
1413        };
1414        let json = serde_json::to_value(&run).unwrap();
1415        assert!(json.get("capabilities").is_some());
1416        assert_eq!(
1417            json["capabilities"]["inputs"]["diff"]["status"],
1418            "available"
1419        );
1420        assert_eq!(
1421            json["capabilities"]["inputs"]["coverage"]["status"],
1422            "available"
1423        );
1424    }
1425
1426    #[test]
1427    fn test_run_without_capabilities_omits_field() {
1428        let run = Run {
1429            started_at: "2026-02-02T00:00:00Z".to_string(),
1430            ended_at: None,
1431            duration_ms: None,
1432            capabilities: None,
1433        };
1434        let json = serde_json::to_string(&run).unwrap();
1435        assert!(!json.contains("capabilities"));
1436    }
1437
1438    // ========================================================================
1439    // Token & Code Hygiene Tests
1440    // ========================================================================
1441
1442    #[test]
1443    fn test_reason_tokens_match_pattern() {
1444        let reason_re = regex_lite::Regex::new(r"^[a-z0-9_]+$").unwrap();
1445        let reasons = [
1446            REASON_MISSING_LCOV,
1447            REASON_MISSING_DIFF,
1448            REASON_NO_CHANGED_LINES,
1449            REASON_DIFF_COVERED,
1450            REASON_UNCOVERED_LINES,
1451            REASON_BELOW_THRESHOLD,
1452            REASON_TOOL_ERROR,
1453            REASON_SKIPPED,
1454            REASON_TRUNCATED,
1455        ];
1456        for reason in &reasons {
1457            assert!(reason_re.is_match(reason));
1458        }
1459    }
1460
1461    #[test]
1462    fn test_code_constants_match_pattern() {
1463        let code_re = regex_lite::Regex::new(r"^[a-z0-9_.]+$").unwrap();
1464        let codes = [
1465            CODE_UNCOVERED_LINE,
1466            CODE_COVERAGE_BELOW_THRESHOLD,
1467            CODE_MISSING_COVERAGE_FOR_FILE,
1468            CODE_INVALID_LCOV,
1469            CODE_INVALID_DIFF,
1470            CODE_RUNTIME_ERROR,
1471        ];
1472        for code in &codes {
1473            assert!(code_re.is_match(code));
1474        }
1475    }
1476
1477    #[test]
1478    fn test_code_registry_entries_have_valid_codes() {
1479        let code_re = regex_lite::Regex::new(r"^[a-z0-9_.]+$").unwrap();
1480        for entry in CODE_REGISTRY {
1481            assert!(code_re.is_match(entry.code));
1482        }
1483    }
1484}