1use std::collections::BTreeMap;
8use std::ops::RangeInclusive;
9
10pub use covguard_directives::has_ignore_directive;
11pub use covguard_policy::{FailOn, MissingBehavior, Scope};
12use covguard_types::{
13 CODE_COVERAGE_BELOW_THRESHOLD, CODE_MISSING_COVERAGE_FOR_FILE, CODE_UNCOVERED_LINE, Finding,
14 Location, Severity, VerdictStatus, compute_fingerprint,
15};
16
17#[derive(Debug, Clone)]
23pub struct Policy {
24 pub scope: Scope,
26 pub threshold_pct: f64,
28 pub max_uncovered_lines: Option<u32>,
30 pub missing_coverage: MissingBehavior,
32 pub missing_file: MissingBehavior,
34 pub fail_on: FailOn,
36 pub ignore_directives_enabled: bool,
38}
39
40impl Default for Policy {
41 fn default() -> Self {
42 Self {
43 scope: Scope::Added,
44 threshold_pct: 80.0,
45 max_uncovered_lines: None,
46 fail_on: FailOn::Error,
47 missing_coverage: MissingBehavior::Warn,
48 missing_file: MissingBehavior::Warn,
49 ignore_directives_enabled: true,
50 }
51 }
52}
53
54#[derive(Debug, Clone)]
60pub struct EvalInput {
61 pub changed_ranges: BTreeMap<String, Vec<RangeInclusive<u32>>>,
64 pub coverage: BTreeMap<String, BTreeMap<u32, u32>>,
67 pub policy: Policy,
69 pub ignored_lines: BTreeMap<String, std::collections::BTreeSet<u32>>,
72}
73
74#[derive(Debug, Clone, Default, PartialEq)]
76pub struct Metrics {
77 pub changed_lines_total: u32,
79 pub covered_lines: u32,
81 pub uncovered_lines: u32,
83 pub missing_lines: u32,
85 pub ignored_lines: u32,
87 pub diff_coverage_pct: f64,
89}
90
91#[derive(Debug, Clone)]
93pub struct EvalOutput {
94 pub findings: Vec<Finding>,
96 pub verdict: VerdictStatus,
98 pub metrics: Metrics,
100}
101
102pub fn evaluate(input: EvalInput) -> EvalOutput {
117 let mut findings = Vec::new();
118 let mut covered_lines = 0u32;
119 let mut uncovered_lines = 0u32;
120 let mut missing_lines = 0u32;
121 let mut ignored_lines_count = 0u32;
122 let mut missing_files: BTreeMap<String, u32> = BTreeMap::new();
123 let mut missing_lines_for_pct = 0u32;
124 let mut uncovered_details: Vec<(String, u32, u32)> = Vec::new();
125
126 for (path, ranges) in &input.changed_ranges {
128 let file_coverage = input.coverage.get(path);
129 let file_ignored = input.ignored_lines.get(path);
130
131 for range in ranges {
133 for line in range.clone() {
134 if input.policy.ignore_directives_enabled
136 && file_ignored.is_some_and(|ignored| ignored.contains(&line))
137 {
138 ignored_lines_count += 1;
139 continue;
140 }
141
142 match file_coverage {
143 Some(coverage_map) => {
144 match coverage_map.get(&line) {
145 Some(&hits) if hits > 0 => {
146 covered_lines += 1;
148 }
149 Some(&hits) => {
150 uncovered_lines += 1;
152 uncovered_details.push((path.clone(), line, hits));
153 }
154 None => {
155 missing_lines += 1;
157 if input.policy.missing_coverage != MissingBehavior::Skip {
158 missing_lines_for_pct += 1;
159 }
160 }
161 }
162 }
163 None => {
164 missing_lines += 1;
166 if input.policy.missing_file != MissingBehavior::Skip {
167 missing_lines_for_pct += 1;
168 }
169 *missing_files.entry(path.clone()).or_insert(0) += 1;
170 }
171 }
172 }
173 }
174 }
175
176 let changed_lines_total = covered_lines + uncovered_lines + missing_lines;
177 let diff_coverage_pct =
178 calc_coverage_pct(covered_lines, uncovered_lines, missing_lines_for_pct);
179
180 let uncovered_severity = match input.policy.max_uncovered_lines {
181 Some(max) if uncovered_lines <= max => Severity::Info,
182 _ => Severity::Error,
183 };
184
185 for (path, line, hits) in uncovered_details {
186 let line_str = line.to_string();
187 let fp = compute_fingerprint(&[CODE_UNCOVERED_LINE, &path, &line_str]);
188 findings.push(Finding {
189 severity: uncovered_severity,
190 check_id: "diff.uncovered_line".to_string(),
191 code: CODE_UNCOVERED_LINE.to_string(),
192 message: format!("Uncovered changed line (hits={}).", hits),
193 location: Some(Location {
194 path,
195 line: Some(line),
196 col: None,
197 }),
198 data: Some(serde_json::json!({ "hits": hits })),
199 fingerprint: Some(fp),
200 });
201 }
202
203 let missing_file_severity = match input.policy.missing_file {
205 MissingBehavior::Skip => None,
206 MissingBehavior::Warn => Some(Severity::Warn),
207 MissingBehavior::Fail => Some(Severity::Error),
208 };
209 if let Some(severity) = missing_file_severity {
210 for (path, count) in &missing_files {
211 let fp = compute_fingerprint(&[CODE_MISSING_COVERAGE_FOR_FILE, path]);
212 findings.push(Finding {
213 severity,
214 check_id: "diff.missing_coverage_for_file".to_string(),
215 code: CODE_MISSING_COVERAGE_FOR_FILE.to_string(),
216 message: format!(
217 "Missing coverage data for file ({} line(s) without coverage).",
218 count
219 ),
220 location: Some(Location {
221 path: path.clone(),
222 line: None,
223 col: None,
224 }),
225 data: Some(serde_json::json!({
226 "missing_lines": count,
227 "missing_file": true
228 })),
229 fingerprint: Some(fp),
230 });
231 }
232 }
233
234 if changed_lines_total > 0 && diff_coverage_pct < input.policy.threshold_pct {
236 let fp = compute_fingerprint(&[CODE_COVERAGE_BELOW_THRESHOLD, "covguard"]);
237 findings.push(Finding {
238 severity: Severity::Error,
239 check_id: "diff.coverage_below_threshold".to_string(),
240 code: CODE_COVERAGE_BELOW_THRESHOLD.to_string(),
241 message: format!(
242 "Diff coverage {:.1}% is below threshold {:.1}%.",
243 diff_coverage_pct, input.policy.threshold_pct
244 ),
245 location: None,
246 data: Some(serde_json::json!({
247 "actual_pct": diff_coverage_pct,
248 "threshold_pct": input.policy.threshold_pct
249 })),
250 fingerprint: Some(fp),
251 });
252 }
253
254 sort_findings(&mut findings);
256
257 let verdict = determine_verdict(&findings, &input.policy);
259
260 let metrics = Metrics {
261 changed_lines_total,
262 covered_lines,
263 uncovered_lines,
264 missing_lines,
265 ignored_lines: ignored_lines_count,
266 diff_coverage_pct,
267 };
268
269 EvalOutput {
270 findings,
271 verdict,
272 metrics,
273 }
274}
275
276pub fn calc_coverage_pct(covered: u32, uncovered: u32, missing: u32) -> f64 {
280 let total = covered + uncovered + missing;
281 if total == 0 {
282 return 100.0;
283 }
284 (covered as f64 / total as f64) * 100.0
285}
286
287pub fn sort_findings(findings: &mut [Finding]) {
291 findings.sort_by(|a, b| {
292 let severity_cmp = b.severity.cmp(&a.severity);
294 if severity_cmp != std::cmp::Ordering::Equal {
295 return severity_cmp;
296 }
297
298 let path_a = a.location.as_ref().map(|l| l.path.as_str()).unwrap_or("");
300 let path_b = b.location.as_ref().map(|l| l.path.as_str()).unwrap_or("");
301 let path_cmp = path_a.cmp(path_b);
302 if path_cmp != std::cmp::Ordering::Equal {
303 return path_cmp;
304 }
305
306 let line_a = a.location.as_ref().and_then(|l| l.line).unwrap_or(u32::MAX);
308 let line_b = b.location.as_ref().and_then(|l| l.line).unwrap_or(u32::MAX);
309 let line_cmp = line_a.cmp(&line_b);
310 if line_cmp != std::cmp::Ordering::Equal {
311 return line_cmp;
312 }
313
314 let check_id_cmp = a.check_id.cmp(&b.check_id);
316 if check_id_cmp != std::cmp::Ordering::Equal {
317 return check_id_cmp;
318 }
319
320 let code_cmp = a.code.cmp(&b.code);
322 if code_cmp != std::cmp::Ordering::Equal {
323 return code_cmp;
324 }
325
326 a.message.cmp(&b.message)
328 });
329}
330
331fn determine_verdict(findings: &[Finding], policy: &Policy) -> VerdictStatus {
333 let has_errors = findings.iter().any(|f| f.severity == Severity::Error);
334 let has_warns = findings.iter().any(|f| f.severity == Severity::Warn);
335
336 match policy.fail_on {
337 FailOn::Error => {
338 if has_errors {
339 VerdictStatus::Fail
340 } else if has_warns {
341 VerdictStatus::Warn
342 } else {
343 VerdictStatus::Pass
344 }
345 }
346 FailOn::Warn => {
347 if has_errors || has_warns {
348 VerdictStatus::Fail
349 } else {
350 VerdictStatus::Pass
351 }
352 }
353 FailOn::Never => {
354 if has_errors || has_warns {
355 VerdictStatus::Warn
356 } else {
357 VerdictStatus::Pass
358 }
359 }
360 }
361}
362
363#[cfg(test)]
368mod tests {
369 use super::*;
370
371 fn make_input(
373 changed: Vec<(&str, Vec<RangeInclusive<u32>>)>,
374 coverage: Vec<(&str, Vec<(u32, u32)>)>,
375 ) -> EvalInput {
376 let changed_ranges = changed
377 .into_iter()
378 .map(|(path, ranges)| (path.to_string(), ranges))
379 .collect();
380
381 let coverage = coverage
382 .into_iter()
383 .map(|(path, lines)| {
384 let line_map = lines.into_iter().collect();
385 (path.to_string(), line_map)
386 })
387 .collect();
388
389 EvalInput {
390 changed_ranges,
391 coverage,
392 policy: Policy::default(),
393 ignored_lines: BTreeMap::new(),
394 }
395 }
396
397 fn make_input_with_ignored(
399 changed: Vec<(&str, Vec<RangeInclusive<u32>>)>,
400 coverage: Vec<(&str, Vec<(u32, u32)>)>,
401 ignored: Vec<(&str, Vec<u32>)>,
402 ) -> EvalInput {
403 let mut input = make_input(changed, coverage);
404 input.ignored_lines = ignored
405 .into_iter()
406 .map(|(path, lines)| (path.to_string(), lines.into_iter().collect()))
407 .collect();
408 input
409 }
410
411 #[test]
412 fn test_scope_as_str() {
413 assert_eq!(Scope::Added.as_str(), "added");
414 assert_eq!(Scope::Touched.as_str(), "touched");
415 }
416
417 #[test]
418 fn test_all_lines_covered_pass() {
419 let input = make_input(
420 vec![("src/lib.rs", vec![1..=3])],
421 vec![("src/lib.rs", vec![(1, 1), (2, 2), (3, 1)])],
422 );
423
424 let output = evaluate(input);
425
426 assert_eq!(output.verdict, VerdictStatus::Pass);
427 assert!(
429 !output
430 .findings
431 .iter()
432 .any(|f| f.code == CODE_UNCOVERED_LINE)
433 );
434 assert_eq!(output.metrics.covered_lines, 3);
435 assert_eq!(output.metrics.uncovered_lines, 0);
436 assert_eq!(output.metrics.missing_lines, 0);
437 assert_eq!(output.metrics.diff_coverage_pct, 100.0);
438 }
439
440 #[test]
441 fn test_all_lines_uncovered_fail() {
442 let input = make_input(
443 vec![("src/lib.rs", vec![1..=3])],
444 vec![("src/lib.rs", vec![(1, 0), (2, 0), (3, 0)])],
445 );
446
447 let output = evaluate(input);
448
449 assert_eq!(output.verdict, VerdictStatus::Fail);
450 let uncovered_findings: Vec<_> = output
452 .findings
453 .iter()
454 .filter(|f| f.code == CODE_UNCOVERED_LINE)
455 .collect();
456 assert_eq!(uncovered_findings.len(), 3);
457 assert_eq!(output.metrics.covered_lines, 0);
458 assert_eq!(output.metrics.uncovered_lines, 3);
459 assert_eq!(output.metrics.diff_coverage_pct, 0.0);
460 }
461
462 #[test]
463 fn test_mixed_coverage() {
464 let input = make_input(
465 vec![("src/lib.rs", vec![1..=4])],
466 vec![("src/lib.rs", vec![(1, 1), (2, 0), (3, 1), (4, 0)])],
467 );
468
469 let output = evaluate(input);
470
471 assert_eq!(output.verdict, VerdictStatus::Fail);
472 assert_eq!(output.metrics.covered_lines, 2);
473 assert_eq!(output.metrics.uncovered_lines, 2);
474 assert_eq!(output.metrics.diff_coverage_pct, 50.0);
475
476 let uncovered_findings: Vec<_> = output
478 .findings
479 .iter()
480 .filter(|f| f.code == CODE_UNCOVERED_LINE)
481 .collect();
482 assert_eq!(uncovered_findings.len(), 2);
483 }
484
485 #[test]
486 fn test_empty_diff_pass() {
487 let input = make_input(vec![], vec![("src/lib.rs", vec![(1, 0)])]);
488
489 let output = evaluate(input);
490
491 assert_eq!(output.verdict, VerdictStatus::Pass);
492 assert!(output.findings.is_empty());
493 assert_eq!(output.metrics.changed_lines_total, 0);
494 assert_eq!(output.metrics.diff_coverage_pct, 100.0);
495 }
496
497 #[test]
498 fn test_missing_coverage_data() {
499 let input = make_input(vec![("src/new.rs", vec![1..=2])], vec![]);
501
502 let output = evaluate(input);
503
504 assert_eq!(output.metrics.missing_lines, 2);
506 assert_eq!(output.metrics.covered_lines, 0);
507 assert_eq!(output.metrics.uncovered_lines, 0);
508 assert_eq!(output.metrics.diff_coverage_pct, 0.0);
510 assert!(
511 output
512 .findings
513 .iter()
514 .any(|f| f.code == CODE_MISSING_COVERAGE_FOR_FILE)
515 );
516 }
517
518 #[test]
519 fn test_missing_line_within_file_counts_as_missing() {
520 let mut input = make_input(
521 vec![("src/lib.rs", vec![1..=2])],
522 vec![("src/lib.rs", vec![(1, 1)])],
523 );
524 input.policy.missing_coverage = MissingBehavior::Warn;
525
526 let output = evaluate(input);
527
528 assert_eq!(output.metrics.covered_lines, 1);
529 assert_eq!(output.metrics.uncovered_lines, 0);
530 assert_eq!(output.metrics.missing_lines, 1);
531 assert_eq!(output.metrics.diff_coverage_pct, 50.0);
532 }
533
534 #[test]
535 fn test_missing_file_fail_severity_error() {
536 let mut input = make_input(vec![("src/new.rs", vec![1..=1])], vec![]);
537 input.policy.missing_file = MissingBehavior::Fail;
538
539 let output = evaluate(input);
540
541 let missing = output
542 .findings
543 .iter()
544 .find(|f| f.code == CODE_MISSING_COVERAGE_FOR_FILE)
545 .expect("missing file finding");
546 assert_eq!(missing.severity, Severity::Error);
547 }
548
549 #[test]
550 fn test_missing_coverage_skip_excludes_from_percentage() {
551 let mut input = make_input(vec![("src/new.rs", vec![1..=2])], vec![]);
552 input.policy.missing_file = MissingBehavior::Skip;
553 input.policy.missing_coverage = MissingBehavior::Skip;
554
555 let output = evaluate(input);
556
557 assert_eq!(output.metrics.missing_lines, 2);
559 assert_eq!(output.metrics.diff_coverage_pct, 100.0);
561 }
562
563 #[test]
564 fn test_max_uncovered_lines_tolerance_marks_info() {
565 let mut input = make_input(
566 vec![("src/lib.rs", vec![1..=2])],
567 vec![("src/lib.rs", vec![(1, 0), (2, 0)])],
568 );
569 input.policy.max_uncovered_lines = Some(5);
570
571 let output = evaluate(input);
572
573 assert!(
575 output
576 .findings
577 .iter()
578 .filter(|f| f.code == CODE_UNCOVERED_LINE)
579 .all(|f| f.severity == Severity::Info)
580 );
581 }
582
583 #[test]
584 fn test_below_threshold_finding() {
585 let mut input = make_input(
586 vec![("src/lib.rs", vec![1..=10])],
587 vec![(
588 "src/lib.rs",
589 vec![
590 (1, 1),
591 (2, 1),
592 (3, 1),
593 (4, 1),
594 (5, 1),
595 (6, 1),
596 (7, 1),
597 (8, 0),
598 (9, 0),
599 (10, 0),
600 ],
601 )],
602 );
603 input.policy.threshold_pct = 80.0;
604
605 let output = evaluate(input);
606
607 assert_eq!(output.verdict, VerdictStatus::Fail);
609 assert!(
610 output
611 .findings
612 .iter()
613 .any(|f| f.code == CODE_COVERAGE_BELOW_THRESHOLD)
614 );
615 }
616
617 #[test]
618 fn test_above_threshold_pass() {
619 let mut input = make_input(
620 vec![("src/lib.rs", vec![1..=10])],
621 vec![(
622 "src/lib.rs",
623 vec![
624 (1, 1),
625 (2, 1),
626 (3, 1),
627 (4, 1),
628 (5, 1),
629 (6, 1),
630 (7, 1),
631 (8, 1),
632 (9, 1),
633 (10, 0),
634 ],
635 )],
636 );
637 input.policy.threshold_pct = 80.0;
638
639 let output = evaluate(input);
640
641 assert_eq!(output.metrics.diff_coverage_pct, 90.0);
643 assert_eq!(output.verdict, VerdictStatus::Fail);
645 }
646
647 #[test]
648 fn test_deterministic_ordering() {
649 let input = make_input(
650 vec![("src/z.rs", vec![1..=1]), ("src/a.rs", vec![2..=2, 1..=1])],
651 vec![
652 ("src/z.rs", vec![(1, 0)]),
653 ("src/a.rs", vec![(1, 0), (2, 0)]),
654 ],
655 );
656
657 let output = evaluate(input);
658
659 let uncovered: Vec<_> = output
661 .findings
662 .iter()
663 .filter(|f| f.code == CODE_UNCOVERED_LINE)
664 .collect();
665
666 assert_eq!(uncovered.len(), 3);
668
669 let paths_lines: Vec<_> = uncovered
670 .iter()
671 .map(|f| {
672 let loc = f.location.as_ref().unwrap();
673 (loc.path.as_str(), loc.line.unwrap())
674 })
675 .collect();
676
677 assert_eq!(
678 paths_lines,
679 vec![("src/a.rs", 1), ("src/a.rs", 2), ("src/z.rs", 1)]
680 );
681 }
682
683 #[test]
684 fn test_sort_findings_tiebreakers() {
685 fn make_finding(
686 path: &str,
687 line: u32,
688 check_id: &str,
689 code: &str,
690 message: &str,
691 ) -> Finding {
692 Finding {
693 severity: Severity::Error,
694 check_id: check_id.to_string(),
695 code: code.to_string(),
696 message: message.to_string(),
697 location: Some(Location {
698 path: path.to_string(),
699 line: Some(line),
700 col: None,
701 }),
702 data: None,
703 fingerprint: None,
704 }
705 }
706
707 let mut by_line = vec![
708 make_finding("src/lib.rs", 2, "a", "code.a", "m1"),
709 make_finding("src/lib.rs", 1, "a", "code.a", "m1"),
710 ];
711 sort_findings(&mut by_line);
712 assert_eq!(by_line[0].location.as_ref().unwrap().line, Some(1));
713
714 let mut by_check = vec![
715 make_finding("src/lib.rs", 1, "b", "code.a", "m1"),
716 make_finding("src/lib.rs", 1, "a", "code.a", "m1"),
717 ];
718 sort_findings(&mut by_check);
719 assert_eq!(by_check[0].check_id, "a");
720
721 let mut by_code = vec![
722 make_finding("src/lib.rs", 1, "a", "code.b", "m1"),
723 make_finding("src/lib.rs", 1, "a", "code.a", "m1"),
724 ];
725 sort_findings(&mut by_code);
726 assert_eq!(by_code[0].code, "code.a");
727
728 let mut by_message = vec![
729 make_finding("src/lib.rs", 1, "a", "code.a", "b"),
730 make_finding("src/lib.rs", 1, "a", "code.a", "a"),
731 ];
732 sort_findings(&mut by_message);
733 assert_eq!(by_message[0].message, "a");
734 }
735
736 #[test]
737 fn test_fail_on_never() {
738 let mut input = make_input(
739 vec![("src/lib.rs", vec![1..=1])],
740 vec![("src/lib.rs", vec![(1, 0)])],
741 );
742 input.policy.fail_on = FailOn::Never;
743
744 let output = evaluate(input);
745
746 assert_eq!(output.verdict, VerdictStatus::Warn);
748 }
749
750 #[test]
751 fn test_fail_on_never_passes_without_findings() {
752 let mut input = make_input(vec![], vec![]);
753 input.policy.fail_on = FailOn::Never;
754
755 let output = evaluate(input);
756
757 assert_eq!(output.verdict, VerdictStatus::Pass);
758 }
759
760 #[test]
761 fn test_fail_on_warn_fails_on_warnings() {
762 let mut input = make_input(vec![("src/new.rs", vec![1..=1])], vec![]);
763 input.policy.fail_on = FailOn::Warn;
764 input.policy.missing_file = MissingBehavior::Warn;
765
766 let output = evaluate(input);
767
768 assert_eq!(output.verdict, VerdictStatus::Fail);
769 }
770
771 #[test]
772 fn test_fail_on_error_warns_on_only_warnings() {
773 let mut input = make_input(vec![("src/new.rs", vec![1..=1])], vec![]);
774 input.policy.fail_on = FailOn::Error;
775 input.policy.missing_file = MissingBehavior::Warn;
776 input.policy.threshold_pct = 0.0;
777
778 let output = evaluate(input);
779
780 assert_eq!(output.verdict, VerdictStatus::Warn);
781 }
782
783 #[test]
784 fn test_fail_on_warn() {
785 let mut input = make_input(
786 vec![("src/lib.rs", vec![1..=1])],
787 vec![("src/lib.rs", vec![(1, 1)])],
788 );
789 input.policy.fail_on = FailOn::Warn;
790 input.policy.threshold_pct = 100.0;
791
792 let output = evaluate(input);
793
794 assert_eq!(output.verdict, VerdictStatus::Pass);
796 }
797
798 #[test]
799 fn test_calc_coverage_pct_zero_total() {
800 assert_eq!(calc_coverage_pct(0, 0, 0), 100.0);
801 }
802
803 #[test]
804 fn test_calc_coverage_pct_all_covered() {
805 assert_eq!(calc_coverage_pct(10, 0, 0), 100.0);
806 }
807
808 #[test]
809 fn test_calc_coverage_pct_none_covered() {
810 assert_eq!(calc_coverage_pct(0, 10, 0), 0.0);
811 }
812
813 #[test]
814 fn test_calc_coverage_pct_half_covered() {
815 assert_eq!(calc_coverage_pct(5, 5, 0), 50.0);
816 }
817
818 #[test]
819 fn test_calc_coverage_pct_with_missing() {
820 assert_eq!(calc_coverage_pct(5, 3, 2), 50.0);
823 }
824
825 #[test]
826 fn test_multiple_files() {
827 let input = make_input(
828 vec![("src/a.rs", vec![1..=2]), ("src/b.rs", vec![1..=2])],
829 vec![
830 ("src/a.rs", vec![(1, 1), (2, 1)]),
831 ("src/b.rs", vec![(1, 0), (2, 0)]),
832 ],
833 );
834
835 let output = evaluate(input);
836
837 assert_eq!(output.metrics.covered_lines, 2);
838 assert_eq!(output.metrics.uncovered_lines, 2);
839 assert_eq!(output.metrics.diff_coverage_pct, 50.0);
840 }
841
842 #[test]
843 fn test_non_contiguous_ranges() {
844 let input = make_input(
845 vec![("src/lib.rs", vec![1..=2, 10..=12])],
846 vec![(
847 "src/lib.rs",
848 vec![(1, 1), (2, 1), (10, 0), (11, 0), (12, 0)],
849 )],
850 );
851
852 let output = evaluate(input);
853
854 assert_eq!(output.metrics.changed_lines_total, 5);
855 assert_eq!(output.metrics.covered_lines, 2);
856 assert_eq!(output.metrics.uncovered_lines, 3);
857 }
858
859 #[test]
860 fn test_ignored_lines_skipped() {
861 let input = make_input_with_ignored(
862 vec![("src/lib.rs", vec![1..=3])],
863 vec![("src/lib.rs", vec![(1, 0), (2, 0), (3, 0)])],
864 vec![("src/lib.rs", vec![2])], );
866
867 let output = evaluate(input);
868
869 assert_eq!(output.metrics.uncovered_lines, 2);
871 assert_eq!(output.metrics.ignored_lines, 1);
872 assert_eq!(output.metrics.changed_lines_total, 2); }
874
875 #[test]
876 fn test_ignored_lines_all_ignored() {
877 let input = make_input_with_ignored(
878 vec![("src/lib.rs", vec![1..=3])],
879 vec![("src/lib.rs", vec![(1, 0), (2, 0), (3, 0)])],
880 vec![("src/lib.rs", vec![1, 2, 3])], );
882
883 let output = evaluate(input);
884
885 assert_eq!(output.verdict, VerdictStatus::Pass);
887 assert_eq!(output.metrics.uncovered_lines, 0);
888 assert_eq!(output.metrics.ignored_lines, 3);
889 assert_eq!(output.metrics.changed_lines_total, 0);
890 assert!(output.findings.is_empty());
891 }
892
893 #[test]
894 fn test_ignored_lines_disabled_in_policy() {
895 let mut input = make_input_with_ignored(
896 vec![("src/lib.rs", vec![1..=3])],
897 vec![("src/lib.rs", vec![(1, 0), (2, 0), (3, 0)])],
898 vec![("src/lib.rs", vec![1, 2, 3])], );
900 input.policy.ignore_directives_enabled = false;
901
902 let output = evaluate(input);
903
904 assert_eq!(output.metrics.uncovered_lines, 3);
906 assert_eq!(output.metrics.ignored_lines, 0);
907 }
908
909 #[test]
910 fn test_ignored_lines_pass_when_uncovered_ignored() {
911 let input = make_input_with_ignored(
913 vec![("src/lib.rs", vec![1..=3])],
914 vec![("src/lib.rs", vec![(1, 1), (2, 1), (3, 0)])],
915 vec![("src/lib.rs", vec![3])], );
917
918 let output = evaluate(input);
919
920 assert_eq!(output.verdict, VerdictStatus::Pass);
922 assert_eq!(output.metrics.covered_lines, 2);
923 assert_eq!(output.metrics.uncovered_lines, 0);
924 assert_eq!(output.metrics.ignored_lines, 1);
925 assert_eq!(output.metrics.diff_coverage_pct, 100.0);
926 }
927
928 #[test]
929 fn test_threshold_exactly_at_boundary() {
930 let mut input = make_input(
933 vec![("src/lib.rs", vec![1..=5])],
934 vec![("src/lib.rs", vec![(1, 1), (2, 1), (3, 1), (4, 1), (5, 0)])], );
936 input.policy.threshold_pct = 80.0;
937
938 let output = evaluate(input);
939
940 assert_eq!(output.verdict, VerdictStatus::Fail); assert_eq!(output.metrics.diff_coverage_pct, 80.0);
944 assert_eq!(output.findings.len(), 1);
946 assert_eq!(output.findings[0].code, CODE_UNCOVERED_LINE);
947 }
948
949 #[test]
950 fn test_threshold_slightly_below_boundary() {
951 let mut input = make_input(
953 vec![("src/lib.rs", vec![1..=10])],
954 vec![(
955 "src/lib.rs",
956 vec![
957 (1, 1),
958 (2, 1),
959 (3, 1),
960 (4, 1),
961 (5, 1),
962 (6, 1),
963 (7, 1),
964 (8, 0),
965 (9, 0),
966 (10, 0),
967 ],
968 )], );
970 input.policy.threshold_pct = 80.0;
971
972 let output = evaluate(input);
973
974 assert_eq!(output.verdict, VerdictStatus::Fail);
976 assert!(output.metrics.diff_coverage_pct < 80.0);
977 }
978
979 #[test]
980 fn test_large_line_numbers() {
981 let input = make_input(
983 vec![("src/lib.rs", vec![1000000..=1000002])],
984 vec![("src/lib.rs", vec![(1000000, 1), (1000001, 0), (1000002, 1)])],
985 );
986
987 let output = evaluate(input);
988
989 assert_eq!(output.metrics.changed_lines_total, 3);
990 assert_eq!(output.metrics.covered_lines, 2);
991 assert_eq!(output.metrics.uncovered_lines, 1);
992 }
993
994 #[test]
995 fn test_empty_file_path() {
996 let input = make_input(vec![("", vec![1..=1])], vec![("", vec![(1, 0)])]);
999
1000 let output = evaluate(input);
1001
1002 assert_eq!(output.metrics.uncovered_lines, 1);
1004 assert!(!output.findings.is_empty());
1006 }
1007
1008 #[test]
1009 fn test_unicode_in_path() {
1010 let unicode_path = "src/日本語/файл.rs";
1012 let input = make_input(
1013 vec![(unicode_path, vec![1..=1])],
1014 vec![(unicode_path, vec![(1, 0)])],
1015 );
1016
1017 let output = evaluate(input);
1018
1019 assert_eq!(output.metrics.uncovered_lines, 1);
1021 assert_eq!(output.metrics.changed_lines_total, 1);
1022 assert!(
1024 !output.findings.is_empty(),
1025 "Should have findings for uncovered line"
1026 );
1027 if let Some(finding) = output.findings.first() {
1029 if let Some(loc) = &finding.location {
1030 assert_eq!(loc.path, unicode_path);
1031 }
1032 }
1033 }
1034
1035 #[test]
1036 fn test_zero_threshold() {
1037 let mut input = make_input(
1039 vec![("src/lib.rs", vec![1..=3])],
1040 vec![("src/lib.rs", vec![(1, 0), (2, 0), (3, 0)])], );
1042 input.policy.threshold_pct = 0.0;
1043
1044 let output = evaluate(input);
1045
1046 assert_eq!(output.verdict, VerdictStatus::Fail); }
1050
1051 #[test]
1052 fn test_100_percent_threshold_all_covered() {
1053 let mut input = make_input(
1055 vec![("src/lib.rs", vec![1..=3])],
1056 vec![("src/lib.rs", vec![(1, 1), (2, 1), (3, 1)])], );
1058 input.policy.threshold_pct = 100.0;
1059
1060 let output = evaluate(input);
1061
1062 assert_eq!(output.verdict, VerdictStatus::Pass);
1063 assert_eq!(output.metrics.diff_coverage_pct, 100.0);
1064 }
1065
1066 #[test]
1067 fn test_single_line_coverage() {
1068 let input = make_input(
1070 vec![("src/lib.rs", vec![1..=1])],
1071 vec![("src/lib.rs", vec![(1, 5)])], );
1073
1074 let output = evaluate(input);
1075
1076 assert_eq!(output.metrics.changed_lines_total, 1);
1077 assert_eq!(output.metrics.covered_lines, 1);
1078 assert_eq!(output.metrics.diff_coverage_pct, 100.0);
1079 assert!(output.findings.is_empty());
1080 }
1081}
1082
1083#[cfg(test)]
1084mod proptest_tests {
1085 use super::*;
1086 use proptest::prelude::*;
1087
1088 proptest! {
1089 #[test]
1090 fn coverage_pct_always_in_range(covered in 0u32..1000, uncovered in 0u32..1000, missing in 0u32..1000) {
1091 let pct = calc_coverage_pct(covered, uncovered, missing);
1092 prop_assert!(pct >= 0.0);
1093 prop_assert!(pct <= 100.0);
1094 }
1095
1096 #[test]
1097 fn coverage_pct_is_deterministic(covered in 0u32..1000, uncovered in 0u32..1000, missing in 0u32..1000) {
1098 let pct1 = calc_coverage_pct(covered, uncovered, missing);
1099 let pct2 = calc_coverage_pct(covered, uncovered, missing);
1100 prop_assert_eq!(pct1, pct2);
1101 }
1102
1103 #[test]
1104 fn findings_order_is_deterministic(
1105 path1 in "[a-z]{1,10}",
1106 path2 in "[a-z]{1,10}",
1107 line1 in 1u32..100,
1108 line2 in 1u32..100,
1109 ) {
1110 let mut findings = vec![
1111 Finding {
1112 severity: Severity::Error,
1113 check_id: "test".to_string(),
1114 code: "test.code".to_string(),
1115 message: "msg".to_string(),
1116 location: Some(Location { path: path1.clone(), line: Some(line1), col: None }),
1117 data: None,
1118 fingerprint: None,
1119 },
1120 Finding {
1121 severity: Severity::Error,
1122 check_id: "test".to_string(),
1123 code: "test.code".to_string(),
1124 message: "msg".to_string(),
1125 location: Some(Location { path: path2.clone(), line: Some(line2), col: None }),
1126 data: None,
1127 fingerprint: None,
1128 },
1129 ];
1130
1131 let mut findings_copy = findings.clone();
1132
1133 sort_findings(&mut findings);
1134 sort_findings(&mut findings_copy);
1135
1136 for (f1, f2) in findings.iter().zip(findings_copy.iter()) {
1138 prop_assert_eq!(
1139 f1.location.as_ref().map(|l| (&l.path, l.line)),
1140 f2.location.as_ref().map(|l| (&l.path, l.line))
1141 );
1142 }
1143 }
1144 }
1145}