1pub use covguard_policy::Scope;
7use serde::{Deserialize, Serialize};
8use sha2::{Digest, Sha256};
9
10pub const SCHEMA_ID: &str = "covguard.report.v1";
16
17pub const SENSOR_SCHEMA_ID: &str = "sensor.report.v1";
19
20pub const CODE_UNCOVERED_LINE: &str = "covguard.diff.uncovered_line";
22
23pub const CODE_COVERAGE_BELOW_THRESHOLD: &str = "covguard.diff.coverage_below_threshold";
25
26pub const CODE_MISSING_COVERAGE_FOR_FILE: &str = "covguard.diff.missing_coverage_for_file";
28
29pub const CODE_INVALID_LCOV: &str = "covguard.input.invalid_lcov";
31
32pub const CODE_INVALID_DIFF: &str = "covguard.input.invalid_diff";
34
35pub const CODE_RUNTIME_ERROR: &str = "tool.runtime_error";
37
38pub const CHECK_ID_RUNTIME: &str = "tool.runtime_error";
40
41pub const REASON_MISSING_LCOV: &str = "missing_lcov";
47
48pub const REASON_NO_CHANGED_LINES: &str = "no_changed_lines";
50
51pub const REASON_DIFF_COVERED: &str = "diff_covered";
53
54pub const REASON_UNCOVERED_LINES: &str = "uncovered_lines";
56
57pub const REASON_BELOW_THRESHOLD: &str = "below_threshold";
59
60pub const REASON_TOOL_ERROR: &str = "tool_error";
62
63pub const REASON_SKIPPED: &str = "skipped";
65
66pub const REASON_TRUNCATED: &str = "truncated";
68
69pub const REASON_MISSING_DIFF: &str = "missing_diff";
71
72pub 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#[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
103pub 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
161pub fn explain(code: &str) -> Option<&'static CodeInfo> {
163 CODE_REGISTRY.iter().find(|info| info.code == code)
164}
165
166#[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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
193#[serde(rename_all = "lowercase")]
194pub enum InputStatus {
195 Available,
197 Unavailable,
199 Skipped,
201}
202
203#[derive(Debug, Clone, Serialize, Deserialize)]
207pub struct Capabilities {
208 pub inputs: InputsCapability,
210}
211
212#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
214pub struct InputCapability {
215 pub status: InputStatus,
217 #[serde(skip_serializing_if = "Option::is_none")]
219 pub reason: Option<String>,
220}
221
222#[derive(Debug, Clone, Serialize, Deserialize)]
224pub struct InputsCapability {
225 pub diff: InputCapability,
227 pub coverage: InputCapability,
229}
230
231#[derive(Debug, Clone, Serialize, Deserialize)]
237pub struct Tool {
238 pub name: String,
240 pub version: String,
242 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
259pub struct Run {
260 pub started_at: String,
262 #[serde(skip_serializing_if = "Option::is_none")]
264 pub ended_at: Option<String>,
265 #[serde(skip_serializing_if = "Option::is_none")]
267 pub duration_ms: Option<u64>,
268 #[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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
286pub struct VerdictCounts {
287 pub info: u32,
289 pub warn: u32,
291 pub error: u32,
293}
294
295#[derive(Debug, Clone, Serialize, Deserialize)]
297pub struct Verdict {
298 pub status: VerdictStatus,
300 pub counts: VerdictCounts,
302 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#[derive(Debug, Clone, Serialize, Deserialize)]
318pub struct Location {
319 pub path: String,
321 #[serde(skip_serializing_if = "Option::is_none")]
323 pub line: Option<u32>,
324 #[serde(skip_serializing_if = "Option::is_none")]
326 pub col: Option<u32>,
327}
328
329#[derive(Debug, Clone, Serialize, Deserialize)]
331pub struct Finding {
332 pub severity: Severity,
334 pub check_id: String,
336 pub code: String,
338 pub message: String,
340 #[serde(skip_serializing_if = "Option::is_none")]
342 pub location: Option<Location>,
343 #[serde(skip_serializing_if = "Option::is_none")]
345 pub data: Option<serde_json::Value>,
346 #[serde(skip_serializing_if = "Option::is_none")]
348 pub fingerprint: Option<String>,
349}
350
351impl Finding {
352 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#[derive(Debug, Clone, Serialize, Deserialize)]
372pub struct Inputs {
373 pub diff_source: String,
375 #[serde(skip_serializing_if = "Option::is_none")]
377 pub diff_file: Option<String>,
378 #[serde(skip_serializing_if = "Option::is_none")]
380 pub base: Option<String>,
381 #[serde(skip_serializing_if = "Option::is_none")]
383 pub head: Option<String>,
384 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#[derive(Debug, Clone, Serialize, Deserialize)]
402pub struct Truncation {
403 pub findings_truncated: bool,
405 pub shown: u32,
407 pub total: u32,
409}
410
411#[derive(Debug, Clone, Serialize, Deserialize)]
413pub struct ReportData {
414 pub scope: String,
416 pub threshold_pct: f64,
418 pub changed_lines_total: u32,
420 pub covered_lines: u32,
422 pub uncovered_lines: u32,
424 pub missing_lines: u32,
426 #[serde(default, skip_serializing_if = "is_zero")]
428 pub ignored_lines_count: u32,
429 #[serde(default, skip_serializing_if = "is_zero")]
431 pub excluded_files_count: u32,
432 pub diff_coverage_pct: f64,
434 pub inputs: Inputs,
436 #[serde(skip_serializing_if = "Option::is_none")]
438 pub debug: Option<serde_json::Value>,
439 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
469pub struct Report {
470 pub schema: String,
472 pub tool: Tool,
474 pub run: Run,
476 pub verdict: Verdict,
478 pub findings: Vec<Finding>,
480 pub data: ReportData,
482}
483
484impl Report {
485 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#[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 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 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 #[test]
727 fn test_severity_ordering() {
728 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 #[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 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 let result = serde_json::from_str::<Severity>("123");
796 assert!(result.is_err());
797 }
798
799 #[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 #[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 #[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 #[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")); 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 #[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 #[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 #[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 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 #[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 #[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 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 #[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 #[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 #[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 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 #[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}