1use covguard_adapters_coverage::LcovError;
33use covguard_adapters_diff::DiffError;
34use covguard_config::should_include_path;
35pub use covguard_directives::detect_ignored_lines;
36pub use covguard_domain::MissingBehavior;
37use covguard_domain::{EvalInput, EvalOutput, Policy, evaluate};
38pub use covguard_output::{
39 DEFAULT_ANNOTATION_LIMIT as DEFAULT_MAX_ANNOTATIONS,
40 DEFAULT_MARKDOWN_LINES as DEFAULT_MAX_LINES,
41 DEFAULT_SARIF_RESULTS as DEFAULT_MAX_SARIF_RESULTS, render_annotations,
42 render_annotations_with_limit, render_markdown, render_markdown_with_limit, render_sarif,
43 render_sarif_with_limit,
44};
45use covguard_output_features::OutputFeatureFlags;
46pub use covguard_policy::FailOn;
47pub use covguard_ports::{Clock, CoverageMap, CoverageProvider, DiffProvider, RepoReader};
48#[cfg(test)]
49use covguard_reporting::build_reasons as reporting_build_reasons;
50use covguard_reporting::{
51 ReportContext, build_debug as reporting_build_debug,
52 build_error_report_pair as reporting_build_error_report_pair,
53 build_report as reporting_build_report, build_report_pair as reporting_build_report_pair,
54 build_skip_report_pair as reporting_build_skip_report_pair,
55 is_invalid_diff as reporting_is_invalid_diff,
56};
57#[cfg(test)]
58use covguard_types::{
59 CODE_COVERAGE_BELOW_THRESHOLD, CODE_RUNTIME_ERROR, Finding, InputStatus,
60 REASON_BELOW_THRESHOLD, REASON_DIFF_COVERED, REASON_NO_CHANGED_LINES, REASON_SKIPPED,
61 REASON_TRUNCATED, REASON_UNCOVERED_LINES, SCHEMA_ID, SENSOR_SCHEMA_ID,
62};
63use covguard_types::{
64 CODE_INVALID_DIFF, CODE_INVALID_LCOV, REASON_MISSING_LCOV, Report, Scope, VerdictStatus,
65};
66use std::collections::{BTreeMap, BTreeSet};
67use thiserror::Error;
68
69pub struct SystemClock;
75
76impl Clock for SystemClock {
77 fn now(&self) -> chrono::DateTime<chrono::Utc> {
78 chrono::Utc::now()
79 }
80}
81
82struct NullReader;
89
90impl RepoReader for NullReader {
91 fn read_line(&self, _path: &str, _line_no: u32) -> Option<String> {
92 None
93 }
94}
95
96#[derive(Debug, Clone)]
98pub struct CheckRequest {
99 pub diff_text: String,
101 pub diff_file_path: Option<String>,
103 pub base_ref: Option<String>,
105 pub head_ref: Option<String>,
107 pub lcov_texts: Vec<String>,
109 pub lcov_paths: Vec<String>,
111 pub max_uncovered_lines: Option<u32>,
113 pub missing_coverage: MissingBehavior,
115 pub missing_file: MissingBehavior,
117 pub include_patterns: Vec<String>,
119 pub exclude_patterns: Vec<String>,
121 pub path_strip: Vec<String>,
123 pub threshold_pct: f64,
125 pub scope: Scope,
127 pub fail_on: FailOn,
129 pub ignore_directives: bool,
131 pub ignored_lines: Option<BTreeMap<String, BTreeSet<u32>>>,
134 pub sensor_schema: bool,
136 pub output: OutputFeatureFlags,
138 pub max_findings: Option<usize>,
140}
141
142impl Default for CheckRequest {
143 fn default() -> Self {
144 Self {
145 diff_text: String::new(),
146 diff_file_path: None,
147 base_ref: None,
148 head_ref: None,
149 lcov_texts: Vec::new(),
150 lcov_paths: Vec::new(),
151 max_uncovered_lines: None,
152 missing_coverage: MissingBehavior::Warn,
153 missing_file: MissingBehavior::Warn,
154 include_patterns: Vec::new(),
155 exclude_patterns: Vec::new(),
156 path_strip: Vec::new(),
157 threshold_pct: 80.0,
158 scope: Scope::Added,
159 fail_on: FailOn::Error,
160 ignore_directives: true,
161 ignored_lines: None,
162 sensor_schema: false,
163 output: OutputFeatureFlags::default(),
164 max_findings: None,
165 }
166 }
167}
168
169#[derive(Debug, Clone)]
171pub struct CheckResult {
172 pub report: Report,
174 pub output: OutputFeatureFlags,
176 pub cockpit_receipt: Option<Report>,
179 pub markdown: String,
181 pub annotations: String,
183 pub sarif: String,
185 pub exit_code: i32,
190}
191
192#[derive(Debug, Error)]
198pub enum AppError {
199 #[error("Failed to parse diff: {0}")]
201 DiffParse(String),
202
203 #[error("Failed to parse LCOV: {0}")]
205 LcovParse(String),
206
207 #[error("I/O error: {0}")]
209 Io(String),
210}
211
212impl From<DiffError> for AppError {
213 fn from(e: DiffError) -> Self {
214 AppError::DiffParse(e.to_string())
215 }
216}
217
218impl From<LcovError> for AppError {
219 fn from(e: LcovError) -> Self {
220 AppError::LcovParse(e.to_string())
221 }
222}
223
224pub fn check(request: CheckRequest) -> Result<CheckResult, AppError> {
248 check_with_clock(request, &SystemClock)
249}
250
251pub fn check_with_clock<C: Clock>(
255 request: CheckRequest,
256 clock: &C,
257) -> Result<CheckResult, AppError> {
258 check_with_clock_and_reader(request, clock, &NullReader)
259}
260
261pub fn check_with_clock_and_reader<C: Clock, R: RepoReader>(
265 request: CheckRequest,
266 clock: &C,
267 reader: &R,
268) -> Result<CheckResult, AppError> {
269 let diff_provider = covguard_adapters_diff::GitDiffProvider;
270 let coverage_provider = covguard_adapters_coverage::LcovCoverageProvider;
271 check_with_providers_and_reader(request, clock, reader, &diff_provider, &coverage_provider)
272}
273
274pub fn check_with_providers_and_reader<
279 C: Clock,
280 R: RepoReader,
281 D: DiffProvider,
282 P: CoverageProvider,
283>(
284 request: CheckRequest,
285 clock: &C,
286 reader: &R,
287 diff_provider: &D,
288 coverage_provider: &P,
289) -> Result<CheckResult, AppError> {
290 let started_at = clock.now();
291
292 let diff_available = !request.diff_text.is_empty()
294 || request.diff_file_path.is_some()
295 || (request.base_ref.is_some() && request.head_ref.is_some());
296
297 let coverage_available =
299 !request.lcov_texts.is_empty() && request.lcov_texts.iter().any(|t| !t.trim().is_empty());
300
301 if request.sensor_schema && !coverage_available {
303 return Ok(build_skip_result(
304 &request,
305 started_at,
306 diff_available,
307 false, REASON_MISSING_LCOV,
309 clock,
310 ));
311 }
312
313 if is_invalid_diff(&request.diff_text) {
315 return Ok(build_error_result(
316 &request,
317 started_at,
318 CODE_INVALID_DIFF,
319 "Diff input did not contain any recognized diff markers.",
320 true, coverage_available,
322 clock,
323 ));
324 }
325
326 let parse_result = match diff_provider.parse_patch(&request.diff_text) {
328 Ok(ranges) => ranges,
329 Err(e) => {
330 return Ok(build_error_result(
331 &request,
332 started_at,
333 CODE_INVALID_DIFF,
334 &format!("Failed to parse diff: {e}"),
335 true, coverage_available,
337 clock,
338 ));
339 }
340 };
341 let changed_ranges = parse_result.changed_ranges;
342 let binary_files = parse_result.binary_files;
343
344 let mut coverage_maps: Vec<CoverageMap> = Vec::new();
346 for lcov_text in &request.lcov_texts {
347 if lcov_text.trim().is_empty() {
348 continue;
349 }
350 if !lcov_text.contains("SF:") {
351 return Ok(build_error_result(
352 &request,
353 started_at,
354 CODE_INVALID_LCOV,
355 "LCOV input contained no SF records.",
356 true, true, clock,
359 ));
360 }
361 match coverage_provider.parse_lcov(lcov_text, &request.path_strip) {
362 Ok(map) => coverage_maps.push(map),
363 Err(e) => {
364 return Ok(build_error_result(
365 &request,
366 started_at,
367 CODE_INVALID_LCOV,
368 &format!("Failed to parse LCOV: {e}"),
369 true, true, clock,
372 ));
373 }
374 }
375 }
376 let coverage: CoverageMap = coverage_provider.merge_coverage(coverage_maps);
377
378 let mut filtered_ranges = changed_ranges;
380 let mut excluded_files_count = 0u32;
381 filtered_ranges.retain(|path, _| {
382 if should_include_path(path, &request.include_patterns, &request.exclude_patterns) {
383 true
384 } else {
385 excluded_files_count += 1;
386 false
387 }
388 });
389
390 let ignored_lines = if request.ignore_directives {
391 request
392 .ignored_lines
393 .clone()
394 .unwrap_or_else(|| detect_ignored_lines(&filtered_ranges, reader))
395 } else {
396 BTreeMap::new()
397 };
398
399 let domain_scope = request.scope;
400
401 let policy = Policy {
402 scope: domain_scope,
403 threshold_pct: request.threshold_pct,
404 max_uncovered_lines: request.max_uncovered_lines,
405 missing_coverage: request.missing_coverage,
406 missing_file: request.missing_file,
407 fail_on: request.fail_on,
408 ignore_directives_enabled: request.ignore_directives,
409 };
410
411 let eval_input = EvalInput {
413 changed_ranges: filtered_ranges,
414 coverage,
415 policy,
416 ignored_lines,
417 };
418
419 let eval_output = evaluate(eval_input);
421
422 let debug = build_debug(&binary_files);
424 let ended_at = clock.now();
425 let output = request.output;
426 let (domain_report, cockpit_receipt) = build_report_pair(
427 eval_output.clone(),
428 &request,
429 started_at,
430 ended_at,
431 excluded_files_count,
432 debug,
433 );
434
435 let markdown = render_markdown_with_limit(&domain_report, output.max_markdown_lines);
437 let annotations = render_annotations_with_limit(&domain_report, output.max_annotations);
438 let sarif = render_sarif_with_limit(&domain_report, output.max_sarif_results);
439
440 let exit_code = match domain_report.verdict.status {
442 VerdictStatus::Pass | VerdictStatus::Warn | VerdictStatus::Skip => 0,
443 VerdictStatus::Fail => 2,
444 };
445
446 Ok(CheckResult {
447 report: domain_report,
448 output,
449 cockpit_receipt,
450 markdown,
451 annotations,
452 sarif,
453 exit_code,
454 })
455}
456
457fn build_report_context(request: &CheckRequest) -> ReportContext {
464 ReportContext {
465 threshold_pct: request.threshold_pct,
466 scope: request.scope,
467 sensor_schema: request.sensor_schema,
468 max_findings: request.max_findings,
469 diff_file_path: request.diff_file_path.clone(),
470 base_ref: request.base_ref.clone(),
471 head_ref: request.head_ref.clone(),
472 lcov_paths: request.lcov_paths.clone(),
473 }
474}
475
476fn build_report_pair(
483 eval: EvalOutput,
484 request: &CheckRequest,
485 started_at: chrono::DateTime<chrono::Utc>,
486 ended_at: chrono::DateTime<chrono::Utc>,
487 excluded_files_count: u32,
488 debug: Option<serde_json::Value>,
489) -> (Report, Option<Report>) {
490 reporting_build_report_pair(
491 eval,
492 &build_report_context(request),
493 started_at,
494 ended_at,
495 excluded_files_count,
496 debug,
497 )
498}
499
500pub fn build_report(
512 eval: EvalOutput,
513 request: &CheckRequest,
514 started_at: chrono::DateTime<chrono::Utc>,
515 ended_at: chrono::DateTime<chrono::Utc>,
516 excluded_files_count: u32,
517 debug: Option<serde_json::Value>,
518) -> Report {
519 reporting_build_report(
520 eval,
521 &build_report_context(request),
522 started_at,
523 ended_at,
524 excluded_files_count,
525 debug,
526 )
527}
528
529fn build_debug(binary_files: &[String]) -> Option<serde_json::Value> {
530 reporting_build_debug(binary_files)
531}
532
533fn build_error_result<C: Clock>(
534 request: &CheckRequest,
535 started_at: chrono::DateTime<chrono::Utc>,
536 code: &str,
537 message: &str,
538 diff_available: bool,
539 coverage_available: bool,
540 clock: &C,
541) -> CheckResult {
542 let ended_at = clock.now();
543 let (domain_report, cockpit_receipt) = build_error_report_pair(
544 request,
545 started_at,
546 ended_at,
547 code,
548 message,
549 diff_available,
550 coverage_available,
551 );
552 let markdown = render_markdown_with_limit(&domain_report, request.output.max_markdown_lines);
553 let annotations = render_annotations_with_limit(&domain_report, request.output.max_annotations);
554 let sarif = render_sarif_with_limit(&domain_report, request.output.max_sarif_results);
555
556 CheckResult {
557 report: domain_report,
558 output: request.output,
559 cockpit_receipt,
560 markdown,
561 annotations,
562 sarif,
563 exit_code: 1,
564 }
565}
566
567fn build_error_report_pair(
569 request: &CheckRequest,
570 started_at: chrono::DateTime<chrono::Utc>,
571 ended_at: chrono::DateTime<chrono::Utc>,
572 code: &str,
573 message: &str,
574 diff_available: bool,
575 coverage_available: bool,
576) -> (Report, Option<Report>) {
577 reporting_build_error_report_pair(
578 &build_report_context(request),
579 started_at,
580 ended_at,
581 code,
582 message,
583 diff_available,
584 coverage_available,
585 )
586}
587
588fn build_skip_result<C: Clock>(
590 request: &CheckRequest,
591 started_at: chrono::DateTime<chrono::Utc>,
592 diff_available: bool,
593 coverage_available: bool,
594 reason: &str,
595 clock: &C,
596) -> CheckResult {
597 let ended_at = clock.now();
598 let (domain_report, cockpit_receipt) = build_skip_report_pair(
599 request,
600 started_at,
601 ended_at,
602 diff_available,
603 coverage_available,
604 reason,
605 );
606 let markdown = render_markdown_with_limit(&domain_report, request.output.max_markdown_lines);
607 let annotations = render_annotations_with_limit(&domain_report, request.output.max_annotations);
608 let sarif = render_sarif_with_limit(&domain_report, request.output.max_sarif_results);
609
610 CheckResult {
611 report: domain_report,
612 output: request.output,
613 cockpit_receipt,
614 markdown,
615 annotations,
616 sarif,
617 exit_code: 0, }
619}
620
621fn build_skip_report_pair(
623 request: &CheckRequest,
624 started_at: chrono::DateTime<chrono::Utc>,
625 ended_at: chrono::DateTime<chrono::Utc>,
626 diff_available: bool,
627 coverage_available: bool,
628 reason: &str,
629) -> (Report, Option<Report>) {
630 reporting_build_skip_report_pair(
631 &build_report_context(request),
632 started_at,
633 ended_at,
634 diff_available,
635 coverage_available,
636 reason,
637 )
638}
639
640fn is_invalid_diff(diff_text: &str) -> bool {
641 reporting_is_invalid_diff(diff_text)
642}
643
644#[cfg(test)]
646fn build_reasons(output: &EvalOutput) -> Vec<String> {
647 reporting_build_reasons(output)
648}
649
650#[cfg(test)]
669mod tests {
670 use super::*;
671 use covguard_domain::Metrics;
672 use std::collections::{BTreeMap, BTreeSet};
673 use std::path::Path;
674
675 struct FixedClock {
677 time: chrono::DateTime<chrono::Utc>,
678 }
679
680 impl FixedClock {
681 fn new(timestamp: &str) -> Self {
682 Self {
683 time: chrono::DateTime::parse_from_rfc3339(timestamp)
684 .unwrap()
685 .with_timezone(&chrono::Utc),
686 }
687 }
688 }
689
690 impl Clock for FixedClock {
691 fn now(&self) -> chrono::DateTime<chrono::Utc> {
692 self.time
693 }
694 }
695
696 struct MapReader {
697 lines: BTreeMap<(String, u32), String>,
698 }
699
700 impl MapReader {
701 fn new(entries: Vec<(&str, u32, &str)>) -> Self {
702 let mut lines = BTreeMap::new();
703 for (path, line_no, content) in entries {
704 lines.insert((path.to_string(), line_no), content.to_string());
705 }
706 Self { lines }
707 }
708 }
709
710 impl RepoReader for MapReader {
711 fn read_line(&self, path: &str, line_no: u32) -> Option<String> {
712 self.lines.get(&(path.to_string(), line_no)).cloned()
713 }
714 }
715
716 struct FakeDiffProvider {
717 parsed: covguard_ports::DiffParseResult,
718 }
719
720 impl DiffProvider for FakeDiffProvider {
721 fn parse_patch(&self, _text: &str) -> Result<covguard_ports::DiffParseResult, String> {
722 Ok(self.parsed.clone())
723 }
724
725 fn load_diff_from_git(
726 &self,
727 _base: &str,
728 _head: &str,
729 _repo_root: &Path,
730 ) -> Result<String, String> {
731 Ok(String::new())
732 }
733 }
734
735 struct FakeCoverageProvider {
736 map: CoverageMap,
737 }
738
739 impl CoverageProvider for FakeCoverageProvider {
740 fn parse_lcov(
741 &self,
742 _text: &str,
743 _strip_prefixes: &[String],
744 ) -> Result<CoverageMap, String> {
745 Ok(self.map.clone())
746 }
747
748 fn merge_coverage(&self, maps: Vec<CoverageMap>) -> CoverageMap {
749 let mut merged = BTreeMap::new();
750 for map in maps {
751 for (path, lines) in map {
752 merged.insert(path, lines);
753 }
754 }
755 merged
756 }
757 }
758
759 #[test]
764 fn test_e2e_uncovered() {
765 let diff = r#"diff --git a/src/lib.rs b/src/lib.rs
766new file mode 100644
767index 0000000..1111111
768--- /dev/null
769+++ b/src/lib.rs
770@@ -0,0 +1,3 @@
771+pub fn add(a: i32, b: i32) -> i32 {
772+ a + b
773+}
774"#;
775
776 let lcov = r#"TN:
777SF:src/lib.rs
778DA:1,0
779DA:2,0
780DA:3,0
781end_of_record
782"#;
783
784 let request = CheckRequest {
785 diff_text: diff.to_string(),
786 diff_file_path: Some("fixtures/diff/simple_added.patch".to_string()),
787 base_ref: None,
788 head_ref: None,
789 lcov_texts: vec![lcov.to_string()],
790 lcov_paths: vec!["fixtures/lcov/uncovered.info".to_string()],
791 threshold_pct: 80.0,
792 scope: Scope::Added,
793 ..Default::default()
794 };
795
796 let clock = FixedClock::new("2026-02-02T00:00:00Z");
797 let result = check_with_clock(request, &clock).unwrap();
798
799 assert_eq!(result.report.schema, SCHEMA_ID);
801 assert_eq!(result.report.verdict.status, VerdictStatus::Fail);
802 assert_eq!(result.report.data.changed_lines_total, 3);
803 assert_eq!(result.report.data.covered_lines, 0);
804 assert_eq!(result.report.data.uncovered_lines, 3);
805 assert_eq!(result.report.data.diff_coverage_pct, 0.0);
806
807 assert_eq!(result.report.findings.len(), 4);
809
810 assert_eq!(result.exit_code, 2);
812
813 assert!(result.markdown.contains("covguard"));
815 assert!(result.markdown.contains("fail"));
816
817 assert!(!result.annotations.is_empty());
819 assert!(result.annotations.contains("::error"));
820 }
821
822 #[test]
823 fn test_check_with_providers_uses_injected_ports() {
824 let request = CheckRequest {
825 diff_text: "diff --git a/src/lib.rs b/src/lib.rs\n@@ -1,1 @@\n+line\n".to_string(),
827 lcov_texts: vec!["SF:src/lib.rs\nDA:1,1\nend_of_record\n".to_string()],
828 scope: Scope::Added,
829 ..Default::default()
830 };
831
832 let mut changed_ranges = BTreeMap::new();
833 changed_ranges.insert("src/lib.rs".to_string(), vec![1..=1]);
834 let diff_provider = FakeDiffProvider {
835 parsed: covguard_ports::DiffParseResult {
836 changed_ranges,
837 binary_files: Vec::new(),
838 },
839 };
840
841 let mut coverage_lines = BTreeMap::new();
842 coverage_lines.insert(1, 1);
843 let mut coverage_map = BTreeMap::new();
844 coverage_map.insert("src/lib.rs".to_string(), coverage_lines);
845 let coverage_provider = FakeCoverageProvider { map: coverage_map };
846
847 let clock = FixedClock::new("2026-02-02T00:00:00Z");
848 let result = check_with_providers_and_reader(
849 request,
850 &clock,
851 &NullReader,
852 &diff_provider,
853 &coverage_provider,
854 )
855 .unwrap();
856
857 assert_eq!(result.report.verdict.status, VerdictStatus::Pass);
858 assert_eq!(result.report.data.changed_lines_total, 1);
859 assert_eq!(result.report.data.covered_lines, 1);
860 assert_eq!(result.report.findings.len(), 0);
861 }
862
863 #[test]
864 fn test_e2e_covered() {
865 let diff = r#"diff --git a/src/lib.rs b/src/lib.rs
866new file mode 100644
867index 0000000..1111111
868--- /dev/null
869+++ b/src/lib.rs
870@@ -0,0 +1,3 @@
871+pub fn add(a: i32, b: i32) -> i32 {
872+ a + b
873+}
874"#;
875
876 let lcov = r#"TN:
877SF:src/lib.rs
878DA:1,1
879DA:2,1
880DA:3,1
881end_of_record
882"#;
883
884 let request = CheckRequest {
885 diff_text: diff.to_string(),
886 diff_file_path: Some("fixtures/diff/simple_added.patch".to_string()),
887 base_ref: None,
888 head_ref: None,
889 lcov_texts: vec![lcov.to_string()],
890 lcov_paths: vec!["fixtures/lcov/covered.info".to_string()],
891 threshold_pct: 80.0,
892 scope: Scope::Added,
893 ..Default::default()
894 };
895
896 let clock = FixedClock::new("2026-02-02T00:00:00Z");
897 let result = check_with_clock(request, &clock).unwrap();
898
899 assert_eq!(result.report.verdict.status, VerdictStatus::Pass);
901 assert_eq!(result.report.data.changed_lines_total, 3);
902 assert_eq!(result.report.data.covered_lines, 3);
903 assert_eq!(result.report.data.uncovered_lines, 0);
904 assert_eq!(result.report.data.diff_coverage_pct, 100.0);
905
906 assert!(result.report.findings.is_empty());
908
909 assert_eq!(result.exit_code, 0);
911
912 assert!(result.markdown.contains("pass"));
914 }
915
916 #[test]
921 fn test_error_bad_diff() {
922 let diff = "not a valid diff at all\nrandom garbage";
923 let lcov = "TN:\nSF:src/lib.rs\nDA:1,1\nend_of_record\n";
924
925 let request = CheckRequest {
926 diff_text: diff.to_string(),
927 diff_file_path: None,
928 base_ref: None,
929 head_ref: None,
930 lcov_texts: vec![lcov.to_string()],
931 lcov_paths: vec![],
932 threshold_pct: 80.0,
933 scope: Scope::Added,
934 ..Default::default()
935 };
936
937 let result = check(request).expect("invalid diff should return error report");
938 assert_eq!(result.exit_code, 1);
939 assert_eq!(result.report.verdict.status, VerdictStatus::Fail);
940 assert!(
941 result
942 .report
943 .findings
944 .iter()
945 .any(|f| f.code == CODE_INVALID_DIFF)
946 );
947 assert!(
948 result
949 .report
950 .findings
951 .iter()
952 .any(|f| f.code == CODE_RUNTIME_ERROR)
953 );
954 }
955
956 #[test]
957 fn test_error_bad_lcov() {
958 let diff = r#"diff --git a/src/lib.rs b/src/lib.rs
959new file mode 100644
960--- /dev/null
961+++ b/src/lib.rs
962@@ -0,0 +1,1 @@
963+fn main() {}
964"#;
965
966 let lcov = "DA:1,1\nend_of_record\n";
968
969 let request = CheckRequest {
970 diff_text: diff.to_string(),
971 diff_file_path: None,
972 base_ref: None,
973 head_ref: None,
974 lcov_texts: vec![lcov.to_string()],
975 lcov_paths: vec![],
976 threshold_pct: 80.0,
977 scope: Scope::Added,
978 ..Default::default()
979 };
980
981 let result = check(request).expect("invalid lcov should return error report");
982 assert_eq!(result.exit_code, 1);
983 assert_eq!(result.report.verdict.status, VerdictStatus::Fail);
984 assert!(
985 result
986 .report
987 .findings
988 .iter()
989 .any(|f| f.code == CODE_INVALID_LCOV)
990 );
991 assert!(
992 result
993 .report
994 .findings
995 .iter()
996 .any(|f| f.code == CODE_RUNTIME_ERROR)
997 );
998 }
999
1000 #[test]
1005 fn test_check_request_default() {
1006 let request = CheckRequest::default();
1007 assert_eq!(request.threshold_pct, 80.0);
1008 assert_eq!(request.scope, Scope::Added);
1009 assert!(request.diff_text.is_empty());
1010 assert!(request.lcov_texts.is_empty());
1011 }
1012
1013 #[test]
1014 fn test_clock_trait() {
1015 let clock = SystemClock;
1016 let now = clock.now();
1017 assert!(now.timestamp() > 0);
1019 }
1020
1021 #[test]
1022 fn test_fixed_clock() {
1023 let clock = FixedClock::new("2026-02-02T12:30:45Z");
1024 let time = clock.now();
1025 assert_eq!(
1026 time.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
1027 "2026-02-02T12:30:45Z"
1028 );
1029 }
1030
1031 #[test]
1032 fn test_empty_diff() {
1033 let request = CheckRequest {
1034 diff_text: String::new(),
1035 diff_file_path: None,
1036 base_ref: None,
1037 head_ref: None,
1038 lcov_texts: vec!["TN:\nSF:src/lib.rs\nDA:1,1\nend_of_record\n".to_string()],
1039 lcov_paths: vec![],
1040 threshold_pct: 80.0,
1041 scope: Scope::Added,
1042 ..Default::default()
1043 };
1044
1045 let result = check(request).unwrap();
1046 assert_eq!(result.report.verdict.status, VerdictStatus::Pass);
1047 assert_eq!(result.report.data.changed_lines_total, 0);
1048 assert_eq!(result.exit_code, 0);
1049 }
1050
1051 #[test]
1052 fn test_exit_codes() {
1053 let request = CheckRequest {
1055 diff_text: String::new(),
1056 diff_file_path: None,
1057 base_ref: None,
1058 head_ref: None,
1059 lcov_texts: vec![String::new()],
1060 lcov_paths: vec![],
1061 threshold_pct: 80.0,
1062 scope: Scope::Added,
1063 ..Default::default()
1064 };
1065 let result = check(request).unwrap();
1066 assert_eq!(result.exit_code, 0);
1067
1068 let diff =
1070 "diff --git a/x.rs b/x.rs\n--- /dev/null\n+++ b/x.rs\n@@ -0,0 +1,1 @@\n+fn x() {}\n";
1071 let lcov = "TN:\nSF:x.rs\nDA:1,0\nend_of_record\n";
1072 let request = CheckRequest {
1073 diff_text: diff.to_string(),
1074 diff_file_path: None,
1075 base_ref: None,
1076 head_ref: None,
1077 lcov_texts: vec![lcov.to_string()],
1078 lcov_paths: vec![],
1079 threshold_pct: 80.0,
1080 scope: Scope::Added,
1081 ..Default::default()
1082 };
1083 let result = check(request).unwrap();
1084 assert_eq!(result.exit_code, 2);
1085 }
1086
1087 #[test]
1088 fn test_build_report_timestamp() {
1089 use chrono::TimeZone;
1090
1091 let eval = EvalOutput {
1092 findings: vec![],
1093 verdict: VerdictStatus::Pass,
1094 metrics: covguard_domain::Metrics::default(),
1095 };
1096
1097 let request = CheckRequest::default();
1098 let started_at = chrono::Utc.with_ymd_and_hms(2026, 2, 2, 10, 30, 0).unwrap();
1099 let ended_at = chrono::Utc.with_ymd_and_hms(2026, 2, 2, 10, 30, 1).unwrap();
1100
1101 let report = build_report(eval, &request, started_at, ended_at, 0, None);
1102
1103 assert_eq!(report.run.started_at, "2026-02-02T10:30:00Z");
1104 assert_eq!(
1105 report.run.ended_at,
1106 Some("2026-02-02T10:30:01Z".to_string())
1107 );
1108 assert_eq!(report.run.duration_ms, Some(1000));
1109 }
1110
1111 #[test]
1112 fn test_build_report_tool_info() {
1113 let eval = EvalOutput {
1114 findings: vec![],
1115 verdict: VerdictStatus::Pass,
1116 metrics: covguard_domain::Metrics::default(),
1117 };
1118
1119 let request = CheckRequest::default();
1120 let started_at = chrono::Utc::now();
1121 let ended_at = started_at;
1122
1123 let report = build_report(eval, &request, started_at, ended_at, 0, None);
1124
1125 assert_eq!(report.tool.name, "covguard");
1126 assert_eq!(report.tool.version, "0.1.0");
1127 }
1128
1129 #[test]
1130 fn test_render_markdown() {
1131 let report = Report::default();
1132 let md = render_markdown(&report);
1133 assert!(md.contains("covguard"));
1134 }
1135
1136 #[test]
1137 fn test_render_annotations_empty() {
1138 let report = Report::default();
1139 let ann = render_annotations(&report);
1140 assert!(ann.is_empty());
1141 }
1142
1143 #[test]
1144 fn test_scope_touched() {
1145 let diff =
1146 "diff --git a/x.rs b/x.rs\n--- /dev/null\n+++ b/x.rs\n@@ -0,0 +1,1 @@\n+fn x() {}\n";
1147 let lcov = "TN:\nSF:x.rs\nDA:1,1\nend_of_record\n";
1148
1149 let request = CheckRequest {
1150 diff_text: diff.to_string(),
1151 diff_file_path: None,
1152 base_ref: None,
1153 head_ref: None,
1154 lcov_texts: vec![lcov.to_string()],
1155 lcov_paths: vec![],
1156 threshold_pct: 80.0,
1157 scope: Scope::Touched,
1158 ..Default::default()
1159 };
1160
1161 let result = check(request).unwrap();
1162 assert_eq!(result.report.data.scope, "touched");
1163 }
1164
1165 #[test]
1166 fn test_git_refs_metadata() {
1167 let diff =
1168 "diff --git a/x.rs b/x.rs\n--- /dev/null\n+++ b/x.rs\n@@ -0,0 +1,1 @@\n+fn x() {}\n";
1169 let lcov = "TN:\nSF:x.rs\nDA:1,1\nend_of_record\n";
1170
1171 let request = CheckRequest {
1172 diff_text: diff.to_string(),
1173 diff_file_path: None,
1174 base_ref: Some("main".to_string()),
1175 head_ref: Some("feature".to_string()),
1176 lcov_texts: vec![lcov.to_string()],
1177 lcov_paths: vec!["coverage.info".to_string()],
1178 threshold_pct: 80.0,
1179 scope: Scope::Added,
1180 ..Default::default()
1181 };
1182
1183 let result = check(request).unwrap();
1184 assert_eq!(result.report.data.inputs.diff_source, "git-refs");
1185 assert_eq!(result.report.data.inputs.base, Some("main".to_string()));
1186 assert_eq!(result.report.data.inputs.head, Some("feature".to_string()));
1187 assert!(result.report.data.inputs.diff_file.is_none());
1188 }
1189
1190 #[test]
1191 fn test_diff_file_metadata() {
1192 let diff =
1193 "diff --git a/x.rs b/x.rs\n--- /dev/null\n+++ b/x.rs\n@@ -0,0 +1,1 @@\n+fn x() {}\n";
1194 let lcov = "TN:\nSF:x.rs\nDA:1,1\nend_of_record\n";
1195
1196 let request = CheckRequest {
1197 diff_text: diff.to_string(),
1198 diff_file_path: Some("my.patch".to_string()),
1199 base_ref: None,
1200 head_ref: None,
1201 lcov_texts: vec![lcov.to_string()],
1202 lcov_paths: vec!["coverage.info".to_string()],
1203 threshold_pct: 80.0,
1204 scope: Scope::Added,
1205 ..Default::default()
1206 };
1207
1208 let result = check(request).unwrap();
1209 assert_eq!(result.report.data.inputs.diff_source, "diff-file");
1210 assert_eq!(
1211 result.report.data.inputs.diff_file,
1212 Some("my.patch".to_string())
1213 );
1214 assert!(result.report.data.inputs.base.is_none());
1215 assert!(result.report.data.inputs.head.is_none());
1216 }
1217
1218 #[test]
1223 fn test_snapshot_report_uncovered() {
1224 let diff = r#"diff --git a/src/lib.rs b/src/lib.rs
1225new file mode 100644
1226index 0000000..1111111
1227--- /dev/null
1228+++ b/src/lib.rs
1229@@ -0,0 +1,3 @@
1230+pub fn add(a: i32, b: i32) -> i32 {
1231+ a + b
1232+}
1233"#;
1234
1235 let lcov = r#"TN:
1236SF:src/lib.rs
1237DA:1,0
1238DA:2,0
1239DA:3,0
1240end_of_record
1241"#;
1242
1243 let request = CheckRequest {
1244 diff_text: diff.to_string(),
1245 diff_file_path: Some("fixtures/diff/simple_added.patch".to_string()),
1246 base_ref: None,
1247 head_ref: None,
1248 lcov_texts: vec![lcov.to_string()],
1249 lcov_paths: vec!["fixtures/lcov/uncovered.info".to_string()],
1250 threshold_pct: 80.0,
1251 scope: Scope::Added,
1252 ..Default::default()
1253 };
1254
1255 let clock = FixedClock::new("2026-02-02T00:00:00Z");
1256 let result = check_with_clock(request, &clock).unwrap();
1257
1258 let report_json: serde_json::Value = serde_json::to_value(&result.report).unwrap();
1259 insta::assert_json_snapshot!("report_uncovered", report_json);
1260 }
1261
1262 #[test]
1263 fn test_snapshot_report_covered() {
1264 let diff = r#"diff --git a/src/lib.rs b/src/lib.rs
1265new file mode 100644
1266index 0000000..1111111
1267--- /dev/null
1268+++ b/src/lib.rs
1269@@ -0,0 +1,3 @@
1270+pub fn add(a: i32, b: i32) -> i32 {
1271+ a + b
1272+}
1273"#;
1274
1275 let lcov = r#"TN:
1276SF:src/lib.rs
1277DA:1,1
1278DA:2,1
1279DA:3,1
1280end_of_record
1281"#;
1282
1283 let request = CheckRequest {
1284 diff_text: diff.to_string(),
1285 diff_file_path: Some("fixtures/diff/simple_added.patch".to_string()),
1286 base_ref: None,
1287 head_ref: None,
1288 lcov_texts: vec![lcov.to_string()],
1289 lcov_paths: vec!["fixtures/lcov/covered.info".to_string()],
1290 threshold_pct: 80.0,
1291 scope: Scope::Added,
1292 ..Default::default()
1293 };
1294
1295 let clock = FixedClock::new("2026-02-02T00:00:00Z");
1296 let result = check_with_clock(request, &clock).unwrap();
1297
1298 let report_json: serde_json::Value = serde_json::to_value(&result.report).unwrap();
1299 insta::assert_json_snapshot!("report_covered", report_json);
1300 }
1301
1302 #[test]
1303 fn test_snapshot_report_partial() {
1304 let diff = r#"diff --git a/src/lib.rs b/src/lib.rs
1305new file mode 100644
1306index 0000000..1111111
1307--- /dev/null
1308+++ b/src/lib.rs
1309@@ -0,0 +1,5 @@
1310+pub fn add(a: i32, b: i32) -> i32 {
1311+ a + b
1312+}
1313+pub fn sub(a: i32, b: i32) -> i32 {
1314+ a - b
1315"#;
1316
1317 let lcov = r#"TN:
1318SF:src/lib.rs
1319DA:1,1
1320DA:2,1
1321DA:3,1
1322DA:4,0
1323DA:5,0
1324end_of_record
1325"#;
1326
1327 let request = CheckRequest {
1328 diff_text: diff.to_string(),
1329 diff_file_path: Some("fixtures/diff/partial.patch".to_string()),
1330 base_ref: None,
1331 head_ref: None,
1332 lcov_texts: vec![lcov.to_string()],
1333 lcov_paths: vec!["fixtures/lcov/partial.info".to_string()],
1334 threshold_pct: 80.0,
1335 scope: Scope::Added,
1336 ..Default::default()
1337 };
1338
1339 let clock = FixedClock::new("2026-02-02T00:00:00Z");
1340 let result = check_with_clock(request, &clock).unwrap();
1341
1342 let report_json: serde_json::Value = serde_json::to_value(&result.report).unwrap();
1343 insta::assert_json_snapshot!("report_partial", report_json);
1344 }
1345
1346 #[test]
1347 fn test_snapshot_report_empty_diff() {
1348 let diff = "";
1349 let lcov = r#"TN:
1350SF:src/lib.rs
1351DA:1,1
1352end_of_record
1353"#;
1354
1355 let request = CheckRequest {
1356 diff_text: diff.to_string(),
1357 diff_file_path: None,
1358 base_ref: None,
1359 head_ref: None,
1360 lcov_texts: vec![lcov.to_string()],
1361 lcov_paths: vec!["fixtures/lcov/covered.info".to_string()],
1362 threshold_pct: 80.0,
1363 scope: Scope::Added,
1364 ..Default::default()
1365 };
1366
1367 let clock = FixedClock::new("2026-02-02T00:00:00Z");
1368 let result = check_with_clock(request, &clock).unwrap();
1369
1370 let report_json: serde_json::Value = serde_json::to_value(&result.report).unwrap();
1371 insta::assert_json_snapshot!("report_empty_diff", report_json);
1372 }
1373
1374 #[test]
1375 fn test_snapshot_markdown_uncovered() {
1376 let diff = r#"diff --git a/src/lib.rs b/src/lib.rs
1377new file mode 100644
1378index 0000000..1111111
1379--- /dev/null
1380+++ b/src/lib.rs
1381@@ -0,0 +1,3 @@
1382+pub fn add(a: i32, b: i32) -> i32 {
1383+ a + b
1384+}
1385"#;
1386
1387 let lcov = r#"TN:
1388SF:src/lib.rs
1389DA:1,0
1390DA:2,0
1391DA:3,0
1392end_of_record
1393"#;
1394
1395 let request = CheckRequest {
1396 diff_text: diff.to_string(),
1397 diff_file_path: Some("fixtures/diff/simple_added.patch".to_string()),
1398 base_ref: None,
1399 head_ref: None,
1400 lcov_texts: vec![lcov.to_string()],
1401 lcov_paths: vec!["fixtures/lcov/uncovered.info".to_string()],
1402 threshold_pct: 80.0,
1403 scope: Scope::Added,
1404 ..Default::default()
1405 };
1406
1407 let clock = FixedClock::new("2026-02-02T00:00:00Z");
1408 let result = check_with_clock(request, &clock).unwrap();
1409
1410 insta::assert_snapshot!("full_markdown_uncovered", result.markdown);
1411 }
1412
1413 #[test]
1414 fn test_snapshot_sarif_uncovered() {
1415 let diff = r#"diff --git a/src/lib.rs b/src/lib.rs
1416new file mode 100644
1417index 0000000..1111111
1418--- /dev/null
1419+++ b/src/lib.rs
1420@@ -0,0 +1,3 @@
1421+pub fn add(a: i32, b: i32) -> i32 {
1422+ a + b
1423+}
1424"#;
1425
1426 let lcov = r#"TN:
1427SF:src/lib.rs
1428DA:1,0
1429DA:2,0
1430DA:3,0
1431end_of_record
1432"#;
1433
1434 let request = CheckRequest {
1435 diff_text: diff.to_string(),
1436 diff_file_path: Some("fixtures/diff/simple_added.patch".to_string()),
1437 base_ref: None,
1438 head_ref: None,
1439 lcov_texts: vec![lcov.to_string()],
1440 lcov_paths: vec!["fixtures/lcov/uncovered.info".to_string()],
1441 threshold_pct: 80.0,
1442 scope: Scope::Added,
1443 ..Default::default()
1444 };
1445
1446 let clock = FixedClock::new("2026-02-02T00:00:00Z");
1447 let result = check_with_clock(request, &clock).unwrap();
1448
1449 let sarif_json: serde_json::Value = serde_json::from_str(&result.sarif).unwrap();
1450 insta::assert_json_snapshot!("full_sarif_uncovered", sarif_json);
1451 }
1452
1453 #[test]
1458 fn test_sensor_schema_skip_on_missing_coverage() {
1459 let diff = r#"diff --git a/src/lib.rs b/src/lib.rs
1460new file mode 100644
1461--- /dev/null
1462+++ b/src/lib.rs
1463@@ -0,0 +1,1 @@
1464+fn main() {}
1465"#;
1466
1467 let request = CheckRequest {
1468 diff_text: diff.to_string(),
1469 diff_file_path: Some("test.patch".to_string()),
1470 base_ref: None,
1471 head_ref: None,
1472 lcov_texts: vec![], lcov_paths: vec![],
1474 threshold_pct: 80.0,
1475 scope: Scope::Added,
1476 sensor_schema: true, ..Default::default()
1478 };
1479
1480 let clock = FixedClock::new("2026-02-02T00:00:00Z");
1481 let result = check_with_clock(request, &clock).unwrap();
1482
1483 assert_eq!(result.report.schema, SCHEMA_ID);
1485 assert!(result.report.run.capabilities.is_none());
1486 assert_eq!(result.report.verdict.status, VerdictStatus::Skip);
1487 assert_eq!(result.exit_code, 0);
1488
1489 let receipt = result.cockpit_receipt.as_ref().unwrap();
1491 assert_eq!(receipt.schema, SENSOR_SCHEMA_ID);
1492 let capabilities = receipt.run.capabilities.as_ref().unwrap();
1493 assert_eq!(capabilities.inputs.diff.status, InputStatus::Available);
1494 assert_eq!(
1495 capabilities.inputs.coverage.status,
1496 InputStatus::Unavailable
1497 );
1498 assert_eq!(
1499 capabilities.inputs.coverage.reason,
1500 Some("missing_lcov".to_string())
1501 );
1502
1503 assert!(
1505 receipt
1506 .verdict
1507 .reasons
1508 .contains(&"missing_lcov".to_string())
1509 );
1510
1511 assert!(receipt.findings.is_empty());
1513 }
1514
1515 #[test]
1516 fn test_sensor_schema_includes_capabilities_on_success() {
1517 let diff = r#"diff --git a/src/lib.rs b/src/lib.rs
1518new file mode 100644
1519--- /dev/null
1520+++ b/src/lib.rs
1521@@ -0,0 +1,1 @@
1522+fn main() {}
1523"#;
1524
1525 let lcov = "TN:\nSF:src/lib.rs\nDA:1,1\nend_of_record\n";
1526
1527 let request = CheckRequest {
1528 diff_text: diff.to_string(),
1529 diff_file_path: Some("test.patch".to_string()),
1530 base_ref: None,
1531 head_ref: None,
1532 lcov_texts: vec![lcov.to_string()],
1533 lcov_paths: vec!["coverage.info".to_string()],
1534 threshold_pct: 80.0,
1535 scope: Scope::Added,
1536 sensor_schema: true, ..Default::default()
1538 };
1539
1540 let clock = FixedClock::new("2026-02-02T00:00:00Z");
1541 let result = check_with_clock(request, &clock).unwrap();
1542
1543 assert_eq!(result.report.schema, SCHEMA_ID);
1545 assert!(result.report.run.capabilities.is_none());
1546 assert_eq!(result.report.verdict.status, VerdictStatus::Pass);
1547
1548 let receipt = result.cockpit_receipt.as_ref().unwrap();
1550 assert_eq!(receipt.schema, SENSOR_SCHEMA_ID);
1551 let capabilities = receipt.run.capabilities.as_ref().unwrap();
1552 assert_eq!(capabilities.inputs.diff.status, InputStatus::Available);
1553 assert_eq!(capabilities.inputs.coverage.status, InputStatus::Available);
1554 }
1555
1556 #[test]
1557 fn test_standard_schema_no_capabilities() {
1558 let diff = r#"diff --git a/src/lib.rs b/src/lib.rs
1559new file mode 100644
1560--- /dev/null
1561+++ b/src/lib.rs
1562@@ -0,0 +1,1 @@
1563+fn main() {}
1564"#;
1565
1566 let lcov = "TN:\nSF:src/lib.rs\nDA:1,1\nend_of_record\n";
1567
1568 let request = CheckRequest {
1569 diff_text: diff.to_string(),
1570 diff_file_path: Some("test.patch".to_string()),
1571 base_ref: None,
1572 head_ref: None,
1573 lcov_texts: vec![lcov.to_string()],
1574 lcov_paths: vec!["coverage.info".to_string()],
1575 threshold_pct: 80.0,
1576 scope: Scope::Added,
1577 sensor_schema: false, ..Default::default()
1579 };
1580
1581 let clock = FixedClock::new("2026-02-02T00:00:00Z");
1582 let result = check_with_clock(request, &clock).unwrap();
1583
1584 assert_eq!(result.report.schema, SCHEMA_ID);
1586
1587 assert!(result.report.run.capabilities.is_none());
1589
1590 assert!(result.cockpit_receipt.is_none());
1592 }
1593
1594 #[test]
1595 fn test_snapshot_report_skip_no_coverage() {
1596 let diff = r#"diff --git a/src/lib.rs b/src/lib.rs
1597new file mode 100644
1598--- /dev/null
1599+++ b/src/lib.rs
1600@@ -0,0 +1,1 @@
1601+fn main() {}
1602"#;
1603
1604 let request = CheckRequest {
1605 diff_text: diff.to_string(),
1606 diff_file_path: Some("fixtures/diff/simple.patch".to_string()),
1607 base_ref: None,
1608 head_ref: None,
1609 lcov_texts: vec![],
1610 lcov_paths: vec![],
1611 threshold_pct: 80.0,
1612 scope: Scope::Added,
1613 sensor_schema: true,
1614 ..Default::default()
1615 };
1616
1617 let clock = FixedClock::new("2026-02-02T00:00:00Z");
1618 let result = check_with_clock(request, &clock).unwrap();
1619
1620 let report_json: serde_json::Value = serde_json::to_value(&result.report).unwrap();
1622 insta::assert_json_snapshot!("report_skip_no_coverage", report_json);
1623 }
1624
1625 #[test]
1630 fn test_max_findings_zero_produces_truncation_metadata() {
1631 let diff = r#"diff --git a/src/lib.rs b/src/lib.rs
1632new file mode 100644
1633index 0000000..1111111
1634--- /dev/null
1635+++ b/src/lib.rs
1636@@ -0,0 +1,3 @@
1637+pub fn add(a: i32, b: i32) -> i32 {
1638+ a + b
1639+}
1640"#;
1641
1642 let lcov = r#"TN:
1643SF:src/lib.rs
1644DA:1,0
1645DA:2,0
1646DA:3,0
1647end_of_record
1648"#;
1649
1650 let request = CheckRequest {
1651 diff_text: diff.to_string(),
1652 diff_file_path: Some("test.patch".to_string()),
1653 lcov_texts: vec![lcov.to_string()],
1654 lcov_paths: vec!["coverage.info".to_string()],
1655 threshold_pct: 80.0,
1656 scope: Scope::Added,
1657 max_findings: Some(0),
1658 ..Default::default()
1659 };
1660
1661 let clock = FixedClock::new("2026-02-02T00:00:00Z");
1662 let result = check_with_clock(request, &clock).unwrap();
1663
1664 assert!(result.report.findings.is_empty());
1666
1667 let truncation = result
1669 .report
1670 .data
1671 .truncation
1672 .as_ref()
1673 .expect("truncation metadata should be present");
1674 assert!(truncation.findings_truncated);
1675 assert_eq!(truncation.shown, 0);
1676 assert!(
1677 truncation.total > 0,
1678 "total should reflect pre-truncation count"
1679 );
1680
1681 let has_trunc_reason = result
1683 .report
1684 .verdict
1685 .reasons
1686 .contains(&REASON_TRUNCATED.to_string());
1687 assert!(has_trunc_reason, "reasons should include 'truncated'");
1688 }
1689
1690 #[test]
1695 fn test_cockpit_receipt_has_truncated_findings() {
1696 let diff = r#"diff --git a/src/lib.rs b/src/lib.rs
1697new file mode 100644
1698index 0000000..1111111
1699--- /dev/null
1700+++ b/src/lib.rs
1701@@ -0,0 +1,3 @@
1702+pub fn add(a: i32, b: i32) -> i32 {
1703+ a + b
1704+}
1705"#;
1706
1707 let lcov = r#"TN:
1708SF:src/lib.rs
1709DA:1,0
1710DA:2,0
1711DA:3,0
1712end_of_record
1713"#;
1714
1715 let request = CheckRequest {
1716 diff_text: diff.to_string(),
1717 diff_file_path: Some("test.patch".to_string()),
1718 lcov_texts: vec![lcov.to_string()],
1719 lcov_paths: vec!["coverage.info".to_string()],
1720 threshold_pct: 80.0,
1721 scope: Scope::Added,
1722 sensor_schema: true,
1723 max_findings: Some(2),
1724 ..Default::default()
1725 };
1726
1727 let clock = FixedClock::new("2026-02-02T00:00:00Z");
1728 let result = check_with_clock(request, &clock).unwrap();
1729
1730 assert_eq!(result.report.findings.len(), 4); assert!(result.report.data.truncation.is_none());
1733 assert_eq!(result.report.schema, SCHEMA_ID);
1734 assert!(result.report.run.capabilities.is_none());
1735
1736 let receipt = result.cockpit_receipt.as_ref().unwrap();
1738 assert_eq!(receipt.findings.len(), 2);
1739 assert_eq!(receipt.schema, SENSOR_SCHEMA_ID);
1740 let truncation = receipt.data.truncation.as_ref().unwrap();
1741 assert!(truncation.findings_truncated);
1742 assert_eq!(truncation.shown, 2);
1743 assert_eq!(truncation.total, 4);
1744 }
1745
1746 #[test]
1747 fn test_cockpit_receipt_counts_reflect_full_set() {
1748 let diff = r#"diff --git a/src/lib.rs b/src/lib.rs
1749new file mode 100644
1750index 0000000..1111111
1751--- /dev/null
1752+++ b/src/lib.rs
1753@@ -0,0 +1,3 @@
1754+pub fn add(a: i32, b: i32) -> i32 {
1755+ a + b
1756+}
1757"#;
1758
1759 let lcov = r#"TN:
1760SF:src/lib.rs
1761DA:1,0
1762DA:2,0
1763DA:3,0
1764end_of_record
1765"#;
1766
1767 let request = CheckRequest {
1768 diff_text: diff.to_string(),
1769 diff_file_path: Some("test.patch".to_string()),
1770 lcov_texts: vec![lcov.to_string()],
1771 lcov_paths: vec!["coverage.info".to_string()],
1772 threshold_pct: 80.0,
1773 scope: Scope::Added,
1774 sensor_schema: true,
1775 max_findings: Some(1),
1776 ..Default::default()
1777 };
1778
1779 let clock = FixedClock::new("2026-02-02T00:00:00Z");
1780 let result = check_with_clock(request, &clock).unwrap();
1781
1782 let receipt = result.cockpit_receipt.as_ref().unwrap();
1784 assert_eq!(receipt.findings.len(), 1); assert_eq!(receipt.verdict.counts.error, 4); }
1787
1788 #[test]
1789 fn test_domain_report_never_has_capabilities() {
1790 let diff =
1792 "diff --git a/x.rs b/x.rs\n--- /dev/null\n+++ b/x.rs\n@@ -0,0 +1,1 @@\n+fn x() {}\n";
1793 let lcov = "TN:\nSF:x.rs\nDA:1,1\nend_of_record\n";
1794
1795 let request = CheckRequest {
1796 diff_text: diff.to_string(),
1797 lcov_texts: vec![lcov.to_string()],
1798 sensor_schema: false,
1799 ..Default::default()
1800 };
1801 let clock = FixedClock::new("2026-02-02T00:00:00Z");
1802 let result = check_with_clock(request, &clock).unwrap();
1803 assert!(result.report.run.capabilities.is_none());
1804
1805 let request = CheckRequest {
1807 diff_text: diff.to_string(),
1808 lcov_texts: vec![lcov.to_string()],
1809 sensor_schema: true,
1810 ..Default::default()
1811 };
1812 let result = check_with_clock(request, &clock).unwrap();
1813 assert!(result.report.run.capabilities.is_none());
1814 assert_eq!(result.report.schema, SCHEMA_ID);
1815
1816 let receipt = result.cockpit_receipt.as_ref().unwrap();
1818 assert!(receipt.run.capabilities.is_some());
1819 }
1820
1821 #[test]
1822 fn test_renderers_use_full_findings() {
1823 let diff = r#"diff --git a/src/lib.rs b/src/lib.rs
1824new file mode 100644
1825index 0000000..1111111
1826--- /dev/null
1827+++ b/src/lib.rs
1828@@ -0,0 +1,3 @@
1829+pub fn add(a: i32, b: i32) -> i32 {
1830+ a + b
1831+}
1832"#;
1833
1834 let lcov = r#"TN:
1835SF:src/lib.rs
1836DA:1,0
1837DA:2,0
1838DA:3,0
1839end_of_record
1840"#;
1841
1842 let request = CheckRequest {
1843 diff_text: diff.to_string(),
1844 diff_file_path: Some("test.patch".to_string()),
1845 lcov_texts: vec![lcov.to_string()],
1846 lcov_paths: vec!["coverage.info".to_string()],
1847 threshold_pct: 80.0,
1848 scope: Scope::Added,
1849 sensor_schema: true,
1850 max_findings: Some(1), ..Default::default()
1852 };
1853
1854 let clock = FixedClock::new("2026-02-02T00:00:00Z");
1855 let result = check_with_clock(request, &clock).unwrap();
1856
1857 let receipt = result.cockpit_receipt.as_ref().unwrap();
1859 assert_eq!(receipt.findings.len(), 1);
1860
1861 assert_eq!(result.report.findings.len(), 4);
1864 assert!(result.markdown.contains("src/lib.rs"));
1866 }
1867
1868 #[test]
1873 fn test_build_debug_empty_returns_none() {
1874 assert!(build_debug(&[]).is_none());
1875 }
1876
1877 #[test]
1878 fn test_build_debug_populated() {
1879 let debug = build_debug(&["assets/logo.png".to_string()]).expect("debug");
1880 assert_eq!(debug["binary_files_count"], 1);
1881 assert_eq!(debug["binary_files"][0], "assets/logo.png");
1882 }
1883
1884 #[test]
1885 fn test_is_invalid_diff_detection() {
1886 assert!(!is_invalid_diff(""));
1887 assert!(!is_invalid_diff(" \n\t"));
1888 assert!(!is_invalid_diff("diff --git a/a.rs b/a.rs"));
1889 assert!(!is_invalid_diff("@@ -1 +1 @@"));
1890 assert!(!is_invalid_diff("+++ b/a.rs"));
1891 assert!(!is_invalid_diff("--- a/a.rs"));
1892 assert!(!is_invalid_diff("rename to a.rs"));
1893 assert!(is_invalid_diff("just some random text"));
1894 }
1895
1896 #[test]
1897 fn test_build_reasons_pass_no_changes() {
1898 let output = EvalOutput {
1899 findings: vec![],
1900 verdict: VerdictStatus::Pass,
1901 metrics: Metrics {
1902 changed_lines_total: 0,
1903 ..Metrics::default()
1904 },
1905 };
1906
1907 let reasons = build_reasons(&output);
1908 assert_eq!(reasons, vec![REASON_NO_CHANGED_LINES.to_string()]);
1909 }
1910
1911 #[test]
1912 fn test_build_reasons_pass_with_changes() {
1913 let output = EvalOutput {
1914 findings: vec![],
1915 verdict: VerdictStatus::Pass,
1916 metrics: Metrics {
1917 changed_lines_total: 3,
1918 ..Metrics::default()
1919 },
1920 };
1921
1922 let reasons = build_reasons(&output);
1923 assert_eq!(reasons, vec![REASON_DIFF_COVERED.to_string()]);
1924 }
1925
1926 #[test]
1927 fn test_build_reasons_warn_with_uncovered_and_threshold() {
1928 let finding = Finding {
1929 severity: covguard_types::Severity::Error,
1930 check_id: "diff.coverage_below_threshold".to_string(),
1931 code: CODE_COVERAGE_BELOW_THRESHOLD.to_string(),
1932 message: "below threshold".to_string(),
1933 location: None,
1934 data: None,
1935 fingerprint: None,
1936 };
1937
1938 let output = EvalOutput {
1939 findings: vec![finding],
1940 verdict: VerdictStatus::Warn,
1941 metrics: Metrics {
1942 changed_lines_total: 5,
1943 uncovered_lines: 2,
1944 ..Metrics::default()
1945 },
1946 };
1947
1948 let reasons = build_reasons(&output);
1949 assert!(reasons.contains(&REASON_UNCOVERED_LINES.to_string()));
1950 assert!(reasons.contains(&REASON_BELOW_THRESHOLD.to_string()));
1951 }
1952
1953 #[test]
1954 fn test_build_reasons_skip() {
1955 let output = EvalOutput {
1956 findings: vec![],
1957 verdict: VerdictStatus::Skip,
1958 metrics: Metrics::default(),
1959 };
1960
1961 let reasons = build_reasons(&output);
1962 assert_eq!(reasons, vec![REASON_SKIPPED.to_string()]);
1963 }
1964
1965 #[test]
1966 fn test_detect_ignored_lines_with_reader() {
1967 let mut changed_ranges = BTreeMap::new();
1968 changed_ranges.insert("src/lib.rs".to_string(), vec![1..=3]);
1969 changed_ranges.insert("src/main.rs".to_string(), vec![10..=11]);
1970
1971 let reader = MapReader::new(vec![
1972 ("src/lib.rs", 2, "let x = 1; // covguard: ignore"),
1973 ("src/main.rs", 11, "# covguard: ignore"),
1974 ]);
1975
1976 let ignored = detect_ignored_lines(&changed_ranges, &reader);
1977 assert_eq!(
1978 ignored.get("src/lib.rs").cloned(),
1979 Some(BTreeSet::from([2]))
1980 );
1981 assert_eq!(
1982 ignored.get("src/main.rs").cloned(),
1983 Some(BTreeSet::from([11]))
1984 );
1985 }
1986
1987 #[test]
1988 fn test_detect_ignored_lines_empty_when_no_directives() {
1989 let mut changed_ranges = BTreeMap::new();
1990 changed_ranges.insert("src/lib.rs".to_string(), vec![1..=2]);
1991
1992 let reader = MapReader::new(vec![("src/lib.rs", 1, "let x = 1;")]);
1993 let ignored = detect_ignored_lines(&changed_ranges, &reader);
1994 assert!(ignored.is_empty());
1995 }
1996
1997 #[test]
1998 fn test_app_error_from_diff_error() {
1999 use covguard_adapters_diff::DiffError;
2000
2001 let err = DiffError::InvalidFormat("bad diff".to_string());
2002 let app: AppError = err.into();
2003 assert!(matches!(
2004 app,
2005 AppError::DiffParse(ref msg) if msg.contains("bad diff")
2006 ));
2007 }
2008
2009 #[test]
2010 fn test_app_error_from_lcov_error() {
2011 use covguard_adapters_coverage::LcovError;
2012
2013 let err = LcovError::InvalidFormat("bad lcov".to_string());
2014 let app: AppError = err.into();
2015 assert!(matches!(
2016 app,
2017 AppError::LcovParse(ref msg) if msg.contains("bad lcov")
2018 ));
2019 }
2020
2021 #[test]
2022 fn test_error_bad_diff_parse_branch() {
2023 let diff = r#"diff --git a/src/lib.rs b/src/lib.rs
2024index 1111111..2222222 100644
2025--- a/src/lib.rs
2026+++ b/src/lib.rs
2027@@ -1,1 @@
2028+line
2029"#;
2030
2031 let lcov = "TN:\nSF:src/lib.rs\nDA:1,1\nend_of_record\n";
2032 let request = CheckRequest {
2033 diff_text: diff.to_string(),
2034 lcov_texts: vec![lcov.to_string()],
2035 ..Default::default()
2036 };
2037
2038 let clock = FixedClock::new("2026-02-02T00:00:00Z");
2039 let result = check_with_clock(request, &clock).unwrap();
2040
2041 assert!(
2042 result
2043 .report
2044 .findings
2045 .iter()
2046 .any(|f| f.code == CODE_INVALID_DIFF)
2047 );
2048 }
2049
2050 #[test]
2051 fn test_error_lcov_missing_sf_records() {
2052 let diff = r#"diff --git a/src/lib.rs b/src/lib.rs
2053new file mode 100644
2054--- /dev/null
2055+++ b/src/lib.rs
2056@@ -0,0 +1,1 @@
2057+fn main() {}
2058"#;
2059
2060 let lcov = "TN:\nDA:1,1\nend_of_record\n";
2061 let request = CheckRequest {
2062 diff_text: diff.to_string(),
2063 lcov_texts: vec![lcov.to_string()],
2064 ..Default::default()
2065 };
2066
2067 let clock = FixedClock::new("2026-02-02T00:00:00Z");
2068 let result = check_with_clock(request, &clock).unwrap();
2069 assert!(
2070 result
2071 .report
2072 .findings
2073 .iter()
2074 .any(|f| f.code == CODE_INVALID_LCOV)
2075 );
2076 }
2077
2078 #[test]
2079 fn test_error_lcov_parse_failure_branch() {
2080 let diff = r#"diff --git a/src/lib.rs b/src/lib.rs
2081new file mode 100644
2082--- /dev/null
2083+++ b/src/lib.rs
2084@@ -0,0 +1,1 @@
2085+fn main() {}
2086"#;
2087
2088 let lcov = "TN:\nSF:src/lib.rs\nDA:abc,1\nend_of_record\n";
2089 let request = CheckRequest {
2090 diff_text: diff.to_string(),
2091 lcov_texts: vec![lcov.to_string()],
2092 ..Default::default()
2093 };
2094
2095 let clock = FixedClock::new("2026-02-02T00:00:00Z");
2096 let result = check_with_clock(request, &clock).unwrap();
2097 assert!(
2098 result
2099 .report
2100 .findings
2101 .iter()
2102 .any(|f| f.code == CODE_INVALID_LCOV)
2103 );
2104 }
2105
2106 #[test]
2107 fn test_excluded_files_count_incremented() {
2108 let diff = r#"diff --git a/src/lib.rs b/src/lib.rs
2109new file mode 100644
2110--- /dev/null
2111+++ b/src/lib.rs
2112@@ -0,0 +1,1 @@
2113+fn main() {}
2114"#;
2115 let lcov = "TN:\nSF:src/lib.rs\nDA:1,1\nend_of_record\n";
2116 let request = CheckRequest {
2117 diff_text: diff.to_string(),
2118 lcov_texts: vec![lcov.to_string()],
2119 exclude_patterns: vec!["src/**".to_string()],
2120 ..Default::default()
2121 };
2122
2123 let clock = FixedClock::new("2026-02-02T00:00:00Z");
2124 let result = check_with_clock(request, &clock).unwrap();
2125
2126 assert_eq!(result.report.data.excluded_files_count, 1);
2127 }
2128
2129 #[test]
2130 fn test_truncate_findings_no_truncation_when_under_limit() {
2131 let findings = vec![Finding::uncovered_line("src/lib.rs", 1, 0)];
2132 let (truncated, trunc) = covguard_output::truncate_findings(findings.clone(), Some(5));
2133 assert_eq!(truncated.len(), findings.len());
2134 assert!(trunc.is_none());
2135 }
2136
2137 #[test]
2138 fn test_build_error_report_pair_with_capabilities() {
2139 let request = CheckRequest {
2140 base_ref: Some("main".to_string()),
2141 head_ref: Some("feature".to_string()),
2142 sensor_schema: true,
2143 scope: Scope::Touched,
2144 ..Default::default()
2145 };
2146 let now = chrono::Utc::now();
2147 let (domain, receipt) = build_error_report_pair(
2148 &request,
2149 now,
2150 now,
2151 CODE_INVALID_DIFF,
2152 "bad diff",
2153 true,
2154 false,
2155 );
2156
2157 assert_eq!(domain.data.inputs.diff_source, "git-refs");
2158 let receipt = receipt.expect("receipt should exist");
2159 let capabilities = receipt.run.capabilities.expect("capabilities");
2160 assert_eq!(capabilities.inputs.diff.status, InputStatus::Available);
2161 assert_eq!(
2162 capabilities.inputs.coverage.status,
2163 InputStatus::Unavailable
2164 );
2165 }
2166
2167 #[test]
2168 fn test_build_error_report_pair_diff_missing() {
2169 let request = CheckRequest {
2170 sensor_schema: true,
2171 scope: Scope::Added,
2172 ..Default::default()
2173 };
2174 let now = chrono::Utc::now();
2175 let (_domain, receipt) = build_error_report_pair(
2176 &request,
2177 now,
2178 now,
2179 CODE_INVALID_DIFF,
2180 "bad diff",
2181 false,
2182 true,
2183 );
2184
2185 let receipt = receipt.expect("receipt should exist");
2186 let capabilities = receipt.run.capabilities.expect("capabilities");
2187 assert_eq!(capabilities.inputs.diff.status, InputStatus::Unavailable);
2188 assert_eq!(capabilities.inputs.coverage.status, InputStatus::Available);
2189 }
2190
2191 #[test]
2192 fn test_build_error_report_pair_without_capabilities() {
2193 let request = CheckRequest {
2194 sensor_schema: false,
2195 ..Default::default()
2196 };
2197 let now = chrono::Utc::now();
2198 let (_domain, receipt) = build_error_report_pair(
2199 &request,
2200 now,
2201 now,
2202 CODE_INVALID_DIFF,
2203 "bad diff",
2204 true,
2205 true,
2206 );
2207 assert!(receipt.is_none());
2208 }
2209
2210 #[test]
2211 fn test_build_skip_report_pair_sensor_schema_false_stdin() {
2212 let request = CheckRequest {
2213 sensor_schema: false,
2214 scope: Scope::Touched,
2215 ..Default::default()
2216 };
2217 let now = chrono::Utc::now();
2218 let (domain, receipt) =
2219 build_skip_report_pair(&request, now, now, false, true, REASON_MISSING_LCOV);
2220 assert_eq!(domain.data.inputs.diff_source, "stdin");
2221 assert_eq!(domain.data.scope, "touched");
2222 assert!(receipt.is_none());
2223 }
2224
2225 #[test]
2226 fn test_build_skip_report_pair_diff_present_cov_missing() {
2227 let request = CheckRequest {
2228 base_ref: Some("main".to_string()),
2229 head_ref: Some("feature".to_string()),
2230 sensor_schema: true,
2231 ..Default::default()
2232 };
2233 let now = chrono::Utc::now();
2234 let (_domain, receipt) =
2235 build_skip_report_pair(&request, now, now, true, false, REASON_MISSING_LCOV);
2236 let receipt = receipt.expect("receipt should exist");
2237 let capabilities = receipt.run.capabilities.expect("capabilities");
2238 assert_eq!(capabilities.inputs.diff.status, InputStatus::Available);
2239 assert_eq!(
2240 capabilities.inputs.coverage.status,
2241 InputStatus::Unavailable
2242 );
2243 }
2244
2245 #[test]
2246 fn test_build_skip_report_pair_diff_missing_cov_present() {
2247 let request = CheckRequest {
2248 sensor_schema: true,
2249 ..Default::default()
2250 };
2251 let now = chrono::Utc::now();
2252 let (_domain, receipt) =
2253 build_skip_report_pair(&request, now, now, false, true, REASON_MISSING_LCOV);
2254 let receipt = receipt.expect("receipt should exist");
2255 let capabilities = receipt.run.capabilities.expect("capabilities");
2256 assert_eq!(capabilities.inputs.diff.status, InputStatus::Unavailable);
2257 assert_eq!(capabilities.inputs.coverage.status, InputStatus::Available);
2258 }
2259
2260 #[test]
2261 fn test_render_wrappers_with_limits() {
2262 let report = Report::default();
2263 let md = render_markdown_with_limit(&report, 1);
2264 let annotations = render_annotations_with_limit(&report, 1);
2265 let sarif = render_sarif_with_limit(&report, 1);
2266
2267 assert!(!md.is_empty());
2268 assert!(annotations.is_empty());
2269 assert!(sarif.contains("\"version\""));
2270 }
2271}