Skip to main content

covguard_domain/
lib.rs

1//! Pure domain evaluation logic for covguard.
2//!
3//! This crate implements the core policy evaluation with no side effects.
4//! It takes changed line ranges and coverage data, applies a policy,
5//! and produces findings with a verdict.
6
7use 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// ============================================================================
18// Policy Configuration
19// ============================================================================
20
21/// Policy configuration for coverage evaluation.
22#[derive(Debug, Clone)]
23pub struct Policy {
24    /// Scope of lines to evaluate.
25    pub scope: Scope,
26    /// Minimum diff coverage percentage threshold.
27    pub threshold_pct: f64,
28    /// Maximum allowed uncovered lines (optional).
29    pub max_uncovered_lines: Option<u32>,
30    /// How to handle missing coverage lines in files with coverage data.
31    pub missing_coverage: MissingBehavior,
32    /// How to handle files with no coverage data at all.
33    pub missing_file: MissingBehavior,
34    /// Determines when the evaluation should fail.
35    pub fail_on: FailOn,
36    /// Whether to honor `covguard: ignore` directives in source comments.
37    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// ============================================================================
55// Evaluation Input/Output
56// ============================================================================
57
58/// Input for policy evaluation.
59#[derive(Debug, Clone)]
60pub struct EvalInput {
61    /// Changed line ranges per file (repo-relative paths).
62    /// Key: file path, Value: list of inclusive line ranges.
63    pub changed_ranges: BTreeMap<String, Vec<RangeInclusive<u32>>>,
64    /// Coverage data per file.
65    /// Key: file path, Value: map of line number to hit count.
66    pub coverage: BTreeMap<String, BTreeMap<u32, u32>>,
67    /// Policy configuration.
68    pub policy: Policy,
69    /// Lines to ignore (from `covguard: ignore` directives).
70    /// Key: file path, Value: set of line numbers to skip.
71    pub ignored_lines: BTreeMap<String, std::collections::BTreeSet<u32>>,
72}
73
74/// Metrics from the evaluation.
75#[derive(Debug, Clone, Default, PartialEq)]
76pub struct Metrics {
77    /// Total number of changed lines in scope.
78    pub changed_lines_total: u32,
79    /// Number of covered lines (hits > 0).
80    pub covered_lines: u32,
81    /// Number of uncovered lines (hits == 0).
82    pub uncovered_lines: u32,
83    /// Number of lines with missing coverage data (no record).
84    pub missing_lines: u32,
85    /// Number of lines ignored via `covguard: ignore` directive.
86    pub ignored_lines: u32,
87    /// Diff coverage percentage.
88    pub diff_coverage_pct: f64,
89}
90
91/// Output from policy evaluation.
92#[derive(Debug, Clone)]
93pub struct EvalOutput {
94    /// List of findings (sorted deterministically).
95    pub findings: Vec<Finding>,
96    /// Overall verdict.
97    pub verdict: VerdictStatus,
98    /// Aggregated metrics.
99    pub metrics: Metrics,
100}
101
102// ============================================================================
103// Evaluation Logic
104// ============================================================================
105
106/// Evaluate changed lines against coverage data under the given policy.
107///
108/// This is the main entry point for policy evaluation. It:
109/// 1. Iterates through all changed lines
110/// 2. Skips lines with `covguard: ignore` directives (if enabled)
111/// 3. Checks coverage status for each line
112/// 4. Creates findings for uncovered lines
113/// 5. Calculates metrics
114/// 6. Determines the verdict
115/// 7. Sorts findings deterministically
116pub 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    // Evaluate each file
127    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        // Process each range
132        for range in ranges {
133            for line in range.clone() {
134                // Check if this line should be ignored
135                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                                // Line is covered
147                                covered_lines += 1;
148                            }
149                            Some(&hits) => {
150                                // Line is uncovered (hits == 0)
151                                uncovered_lines += 1;
152                                uncovered_details.push((path.clone(), line, hits));
153                            }
154                            None => {
155                                // No coverage data for this line (missing)
156                                missing_lines += 1;
157                                if input.policy.missing_coverage != MissingBehavior::Skip {
158                                    missing_lines_for_pct += 1;
159                                }
160                            }
161                        }
162                    }
163                    None => {
164                        // No coverage data for this file (missing)
165                        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    // Missing file findings (file-level)
204    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    // Check if below threshold
235    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 deterministically
255    sort_findings(&mut findings);
256
257    // Determine verdict
258    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
276/// Calculate coverage percentage.
277///
278/// Returns 100.0 if there are no lines to evaluate (vacuous truth).
279pub 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
287/// Sort findings deterministically.
288///
289/// Order: severity (error > warn > info) > path > line > check_id > code > message
290pub fn sort_findings(findings: &mut [Finding]) {
291    findings.sort_by(|a, b| {
292        // Severity: error > warn > info (reverse order of Ord)
293        let severity_cmp = b.severity.cmp(&a.severity);
294        if severity_cmp != std::cmp::Ordering::Equal {
295            return severity_cmp;
296        }
297
298        // Path (lexical)
299        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        // Line (ascending, None last)
307        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        // check_id
315        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        // code
321        let code_cmp = a.code.cmp(&b.code);
322        if code_cmp != std::cmp::Ordering::Equal {
323            return code_cmp;
324        }
325
326        // message
327        a.message.cmp(&b.message)
328    });
329}
330
331/// Determine the verdict based on findings and policy.
332fn 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// ============================================================================
364// Tests
365// ============================================================================
366
367#[cfg(test)]
368mod tests {
369    use super::*;
370
371    /// Helper to create a simple EvalInput.
372    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    /// Helper to create an EvalInput with ignored lines.
398    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        // Only check that there are no uncovered line findings
428        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        // Should have uncovered line findings (one per line) + threshold finding
451        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        // Check that uncovered lines have findings
477        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        // Changed lines with no coverage data for file
500        let input = make_input(vec![("src/new.rs", vec![1..=2])], vec![]);
501
502        let output = evaluate(input);
503
504        // Missing lines affect metrics and create missing file findings
505        assert_eq!(output.metrics.missing_lines, 2);
506        assert_eq!(output.metrics.covered_lines, 0);
507        assert_eq!(output.metrics.uncovered_lines, 0);
508        // 0 covered out of 2 missing = 0%
509        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        // Missing lines still counted in metrics
558        assert_eq!(output.metrics.missing_lines, 2);
559        // But excluded from coverage percentage (no covered/uncovered)
560        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        // Uncovered lines within tolerance become info findings
574        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        // 70% coverage < 80% threshold
608        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        // 90% coverage >= 80% threshold, but still has uncovered line
642        assert_eq!(output.metrics.diff_coverage_pct, 90.0);
643        // Still fails because of uncovered line finding
644        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        // Filter to just uncovered line findings
660        let uncovered: Vec<_> = output
661            .findings
662            .iter()
663            .filter(|f| f.code == CODE_UNCOVERED_LINE)
664            .collect();
665
666        // Should be sorted: src/a.rs:1, src/a.rs:2, src/z.rs:1
667        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        // Even with errors, verdict should be warn (not fail)
747        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        // All lines covered, threshold met
795        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        // 5 covered, 3 uncovered, 2 missing = 10 total
821        // 5/10 = 50%
822        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])], // Line 2 is ignored
865        );
866
867        let output = evaluate(input);
868
869        // Line 2 should be ignored, so only 2 uncovered lines
870        assert_eq!(output.metrics.uncovered_lines, 2);
871        assert_eq!(output.metrics.ignored_lines, 1);
872        assert_eq!(output.metrics.changed_lines_total, 2); // Only non-ignored lines counted
873    }
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])], // All lines ignored
881        );
882
883        let output = evaluate(input);
884
885        // All lines ignored, so no findings
886        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])], // All lines ignored
899        );
900        input.policy.ignore_directives_enabled = false;
901
902        let output = evaluate(input);
903
904        // Ignore directives disabled, so all lines should be evaluated
905        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        // 2 covered lines, 1 uncovered but ignored line
912        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])], // Line 3 is ignored
916        );
917
918        let output = evaluate(input);
919
920        // Should pass because the uncovered line is ignored
921        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        // Test when coverage is exactly at threshold (80%)
931        // Note: Even at exactly 80%, uncovered lines still generate error findings
932        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)])], // 4/5 = 80%
935        );
936        input.policy.threshold_pct = 80.0;
937
938        let output = evaluate(input);
939
940        // Coverage is exactly at threshold, so no "below threshold" finding
941        // But there's still an uncovered line which generates an error finding
942        assert_eq!(output.verdict, VerdictStatus::Fail); // Fail due to uncovered line error
943        assert_eq!(output.metrics.diff_coverage_pct, 80.0);
944        // Should NOT have a "below threshold" finding, only uncovered line finding
945        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        // Test when coverage is just below threshold (79.9% vs 80%)
952        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            )], // 7/10 = 70%
969        );
970        input.policy.threshold_pct = 80.0;
971
972        let output = evaluate(input);
973
974        // Below threshold should fail
975        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        // Test with large line numbers to ensure no overflow
982        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        // Test with empty file path (edge case)
997        // Note: Empty paths may generate additional findings (e.g., missing file)
998        let input = make_input(vec![("", vec![1..=1])], vec![("", vec![(1, 0)])]);
999
1000        let output = evaluate(input);
1001
1002        // Should still work with empty path - uncovered line is detected
1003        assert_eq!(output.metrics.uncovered_lines, 1);
1004        // There may be multiple findings (uncovered line + potentially missing file)
1005        assert!(!output.findings.is_empty());
1006    }
1007
1008    #[test]
1009    fn test_unicode_in_path() {
1010        // Test with unicode characters in path
1011        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        // Verify the metrics are correct
1020        assert_eq!(output.metrics.uncovered_lines, 1);
1021        assert_eq!(output.metrics.changed_lines_total, 1);
1022        // Verify findings exist
1023        assert!(
1024            !output.findings.is_empty(),
1025            "Should have findings for uncovered line"
1026        );
1027        // Verify the finding has the correct path if location exists
1028        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        // Test with zero threshold (always pass)
1038        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)])], // All uncovered
1041        );
1042        input.policy.threshold_pct = 0.0;
1043
1044        let output = evaluate(input);
1045
1046        // Should pass with 0% threshold even though all lines uncovered
1047        // (but still fail due to uncovered lines being errors)
1048        assert_eq!(output.verdict, VerdictStatus::Fail); // Still fails due to error-level findings
1049    }
1050
1051    #[test]
1052    fn test_100_percent_threshold_all_covered() {
1053        // Test with 100% threshold and all lines covered
1054        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)])], // All covered
1057        );
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        // Test with single line file
1069        let input = make_input(
1070            vec![("src/lib.rs", vec![1..=1])],
1071            vec![("src/lib.rs", vec![(1, 5)])], // 5 hits
1072        );
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            // Order should be the same
1137            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}