1use crate::complexity::FunctionComplexity;
32use crate::coverage::FileCoverage;
33use crate::score::crap;
34use serde::{Deserialize, Serialize};
35use std::borrow::Cow;
36use std::collections::hash_map::Entry;
37use std::collections::{HashMap, HashSet};
38use std::path::{Component, Path, PathBuf};
39
40#[derive(Debug, Clone, Serialize, serde::Deserialize)]
42pub struct CrapEntry {
43 pub file: PathBuf,
44 pub function: String,
45 pub line: usize,
46 pub cyclomatic: f64,
47 pub coverage: Option<f64>,
51 pub crap: f64,
52 #[serde(rename = "crate", default, skip_serializing_if = "Option::is_none")]
57 pub crate_name: Option<String>,
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
66#[serde(rename_all = "lowercase")]
67pub enum SortOrder {
68 #[default]
70 Crap,
71 File,
74}
75
76fn file_order_key(e: &CrapEntry) -> (String, &str, usize) {
79 (
80 e.file.to_string_lossy().replace('\\', "/"),
81 e.function.as_str(),
82 e.line,
83 )
84}
85
86pub fn sort_entries(
92 entries: &mut [CrapEntry],
93 order: SortOrder,
94) {
95 match order {
96 SortOrder::Crap => entries.sort_by(|a, b| {
97 b.crap
98 .partial_cmp(&a.crap)
99 .unwrap_or(std::cmp::Ordering::Equal)
100 }),
101 SortOrder::File => entries.sort_by(|a, b| file_order_key(a).cmp(&file_order_key(b))),
102 }
103}
104
105#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
107#[serde(rename_all = "lowercase")]
108pub enum MissingCoveragePolicy {
109 Pessimistic,
112 Optimistic,
115 Skip,
117}
118
119pub const SCOPE_EXAMPLE_CAP: usize = 10;
123
124#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct StrayFiles {
128 pub count: usize,
129 pub examples: Vec<PathBuf>,
130}
131
132impl StrayFiles {
133 fn new(mut files: Vec<PathBuf>) -> Self {
134 files.sort();
135 let count = files.len();
136 files.truncate(SCOPE_EXAMPLE_CAP);
137 Self {
138 count,
139 examples: files,
140 }
141 }
142}
143
144#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct ScopeDiagnostics {
150 pub analyzed_files: usize,
152 pub lcov_files: usize,
154 pub matched_files: usize,
156 pub source_only: StrayFiles,
158 pub lcov_only: StrayFiles,
160}
161
162pub struct MergeResult {
164 pub entries: Vec<CrapEntry>,
166 pub diagnostics: Option<ScopeDiagnostics>,
169}
170
171#[expect(
174 clippy::needless_pass_by_value,
175 reason = "callers always have a fresh HashMap they don't reuse; taking by value matches the consuming pipeline and avoids `&cov` boilerplate at every call site"
176)]
177#[must_use]
178pub fn merge(
179 complexity: Vec<FunctionComplexity>,
180 coverage: HashMap<PathBuf, FileCoverage>,
181 policy: MissingCoveragePolicy,
182) -> MergeResult {
183 let index = PathIndex::build(&coverage);
184 let has_coverage = !coverage.is_empty();
185
186 let mut mapped_files: HashSet<PathBuf> = HashSet::new();
187 let mut seen_files: HashSet<PathBuf> = HashSet::new();
188 let mut used_lcov_keys: HashSet<PathBuf> = HashSet::new();
191
192 let mut entries: Vec<CrapEntry> = complexity
193 .into_iter()
194 .filter_map(|fc| {
195 let hit = index.lookup(&fc.file);
196 let cov = hit.map(|found| found.cov.coverage_in_span(fc.start_line, fc.end_line));
197
198 if has_coverage {
199 if let Some(found) = hit {
200 mapped_files.insert(fc.file.clone());
201 used_lcov_keys.extend(found.spellings.iter().map(|s| s.to_path_buf()));
202 }
203 seen_files.insert(fc.file.clone());
204 }
205
206 let cov_for_scoring = match (cov, policy) {
207 (Some(c), _) => c,
208 (None, MissingCoveragePolicy::Pessimistic) => 0.0,
209 (None, MissingCoveragePolicy::Optimistic) => 100.0,
210 (None, MissingCoveragePolicy::Skip) => return None,
211 };
212
213 let crap_score = crap(fc.cyclomatic, cov_for_scoring);
214 Some(CrapEntry {
215 file: fc.file,
216 function: fc.name,
217 line: fc.start_line,
218 cyclomatic: fc.cyclomatic,
219 coverage: cov,
220 crap: crap_score,
221 crate_name: None,
222 })
223 })
224 .collect();
225
226 entries.sort_by(|a, b| {
227 b.crap
228 .partial_cmp(&a.crap)
229 .unwrap_or(std::cmp::Ordering::Equal)
230 });
231
232 let diagnostics = has_coverage.then(|| {
233 let source_only: Vec<PathBuf> = seen_files
234 .iter()
235 .filter(|f| !mapped_files.contains(*f))
236 .cloned()
237 .collect();
238 let lcov_only: Vec<PathBuf> = coverage
244 .keys()
245 .filter(|k| !used_lcov_keys.contains(*k))
246 .cloned()
247 .collect();
248 ScopeDiagnostics {
249 analyzed_files: seen_files.len(),
250 lcov_files: coverage.len(),
251 matched_files: mapped_files.len(),
252 source_only: StrayFiles::new(source_only),
253 lcov_only: StrayFiles::new(lcov_only),
254 }
255 });
256
257 MergeResult {
258 entries,
259 diagnostics,
260 }
261}
262
263struct IndexedCoverage<'a> {
268 spellings: Vec<&'a Path>,
272 cov: Cow<'a, FileCoverage>,
273}
274
275struct PathIndex<'a> {
279 by_absolute: HashMap<PathBuf, IndexedCoverage<'a>>,
283 by_relative: Vec<(PathBuf, IndexedCoverage<'a>)>,
289}
290
291impl<'a> PathIndex<'a> {
292 fn build(coverage: &'a HashMap<PathBuf, FileCoverage>) -> Self {
293 let mut by_absolute = HashMap::new();
294 let mut by_suffix = HashMap::new();
295
296 for (raw_path, cov) in coverage {
297 if let Some(abs) = fast_path_key(raw_path) {
298 insert_or_merge(&mut by_absolute, abs, raw_path, cov);
299 } else {
300 let key = normalized(raw_path);
307 if !key.as_os_str().is_empty() {
308 insert_or_merge(&mut by_suffix, key, raw_path, cov);
309 }
310 }
311 }
312
313 Self {
314 by_absolute,
315 by_relative: by_suffix.into_iter().collect(),
316 }
317 }
318
319 fn lookup(
322 &self,
323 query: &Path,
324 ) -> Option<&IndexedCoverage<'a>> {
325 if let Ok(abs) = query.canonicalize()
327 && let Some(hit) = self.by_absolute.get(&abs)
328 {
329 return Some(hit);
330 }
331
332 self.by_relative
339 .iter()
340 .filter(|(needle, _)| path_has_suffix(query, needle))
341 .max_by_key(|(needle, _)| needle.components().count())
342 .map(|(_, hit)| hit)
343 }
344}
345
346fn fast_path_key(raw_path: &Path) -> Option<PathBuf> {
359 if raw_path.is_absolute() {
360 raw_path.canonicalize().ok()
361 } else {
362 None
363 }
364}
365
366fn normalized(path: &Path) -> PathBuf {
371 path.components()
372 .filter(|c| !matches!(c, Component::CurDir))
373 .collect()
374}
375
376fn insert_or_merge<'a>(
380 map: &mut HashMap<PathBuf, IndexedCoverage<'a>>,
381 key: PathBuf,
382 raw_path: &'a Path,
383 cov: &'a FileCoverage,
384) {
385 match map.entry(key) {
386 Entry::Occupied(mut slot) => {
387 let indexed = slot.get_mut();
388 indexed.spellings.push(raw_path);
389 indexed.cov.to_mut().merge_from(cov);
390 },
391 Entry::Vacant(slot) => {
392 slot.insert(IndexedCoverage {
393 spellings: vec![raw_path],
394 cov: Cow::Borrowed(cov),
395 });
396 },
397 }
398}
399
400fn path_has_suffix(
406 haystack: &Path,
407 needle: &Path,
408) -> bool {
409 let hay: Vec<_> = haystack.components().collect();
410 let nee: Vec<_> = needle.components().collect();
411 if nee.len() > hay.len() {
412 return false;
413 }
414 hay[hay.len() - nee.len()..] == nee[..]
415}
416
417#[cfg(test)]
418#[expect(
419 clippy::float_cmp,
420 reason = "coverage % is computed from integer line counts; exact equality is the right comparison"
421)]
422mod tests {
423 use super::*;
424 use std::collections::BTreeMap;
425 use std::path::PathBuf;
426
427 fn cov_with(lines: &[(u32, u64)]) -> FileCoverage {
428 FileCoverage {
429 lines: lines.iter().copied().collect::<BTreeMap<_, _>>(),
430 }
431 }
432
433 #[test]
434 fn suffix_match_works_for_relative_coverage_paths() {
435 let mut cov_map = HashMap::new();
438 cov_map.insert(PathBuf::from("src/foo.rs"), cov_with(&[(10, 1), (11, 1)]));
439 let index = PathIndex::build(&cov_map);
440
441 let complexity_path = PathBuf::from("/home/alice/project/src/foo.rs");
442 let result = index.lookup(&complexity_path);
443 assert!(result.is_some(), "expected suffix match to succeed");
444 }
445
446 #[test]
447 fn suffix_match_rejects_partial_component_matches() {
448 let a = PathBuf::from("/project/src/oofoo.rs");
451 let b = PathBuf::from("foo.rs");
452 assert!(!path_has_suffix(&a, &b));
453 }
454
455 #[test]
456 fn equal_length_paths_match_when_identical() {
457 let a = PathBuf::from("/project/src/foo.rs");
460 let b = PathBuf::from("/project/src/foo.rs");
461 assert!(
462 path_has_suffix(&a, &b),
463 "identical paths must match as a suffix"
464 );
465 }
466
467 #[test]
468 fn longer_needle_does_not_match() {
469 let hay = PathBuf::from("src/foo.rs");
471 let needle = PathBuf::from("/abs/project/src/foo.rs");
472 assert!(!path_has_suffix(&hay, &needle));
473 }
474
475 #[test]
476 fn longest_matching_suffix_wins_over_shorter_ambiguous_key() {
477 let mut cov_map = HashMap::new();
482 cov_map.insert(PathBuf::from("src/lib.rs"), cov_with(&[(1, 7)]));
483 cov_map.insert(PathBuf::from("vendor/dep/src/lib.rs"), cov_with(&[(1, 0)]));
484 let index = PathIndex::build(&cov_map);
485
486 let vendor = index
487 .lookup(Path::new("/repo/vendor/dep/src/lib.rs"))
488 .expect("vendor query matches");
489 assert_eq!(
490 vendor.cov.coverage_in_span(1, 1),
491 0.0,
492 "nested query must bind to the vendor key (line 1: 0 hits)"
493 );
494
495 let root = index
496 .lookup(Path::new("/repo/src/lib.rs"))
497 .expect("root query matches");
498 assert_eq!(
499 root.cov.coverage_in_span(1, 1),
500 100.0,
501 "the shorter key still serves its own queries"
502 );
503
504 let complexity = vec![
507 FunctionComplexity {
508 file: PathBuf::from("/repo/src/lib.rs"),
509 name: "rooted".into(),
510 start_line: 1,
511 end_line: 1,
512 cyclomatic: 1.0,
513 },
514 FunctionComplexity {
515 file: PathBuf::from("/repo/vendor/dep/src/lib.rs"),
516 name: "vendored".into(),
517 start_line: 1,
518 end_line: 1,
519 cyclomatic: 1.0,
520 },
521 ];
522 let result = merge(complexity, cov_map, MissingCoveragePolicy::Pessimistic);
523 let diag = result.diagnostics.expect("diagnostics present");
524 assert_eq!(diag.matched_files, 2);
525 assert_eq!(
526 diag.lcov_only.count, 0,
527 "both ambiguous keys were consumed by their own queries"
528 );
529 }
530
531 #[test]
532 fn component_equal_spellings_merge_into_one_entry() {
533 let mut cov_map = HashMap::new();
537 cov_map.insert(PathBuf::from("src/lib.rs"), cov_with(&[(1, 2)]));
538 cov_map.insert(PathBuf::from("./src/lib.rs"), cov_with(&[(1, 3), (2, 1)]));
539 let index = PathIndex::build(&cov_map);
540 assert_eq!(
541 index.by_relative.len(),
542 1,
543 "spelling variants collapse to one suffix-tier entry"
544 );
545
546 let hit = index
547 .lookup(Path::new("/repo/src/lib.rs"))
548 .expect("query matches the merged entry");
549 assert_eq!(hit.cov.lines.get(&1), Some(&5), "hits sum: 2 + 3");
550 assert_eq!(hit.cov.lines.get(&2), Some(&1));
551 assert_eq!(hit.cov.coverage_in_span(1, 2), 100.0);
552
553 let complexity = vec![FunctionComplexity {
555 file: PathBuf::from("/repo/src/lib.rs"),
556 name: "f".into(),
557 start_line: 1,
558 end_line: 2,
559 cyclomatic: 1.0,
560 }];
561 let result = merge(complexity, cov_map, MissingCoveragePolicy::Pessimistic);
562 let diag = result.diagnostics.expect("diagnostics present");
563 assert_eq!(
564 diag.lcov_only.count, 0,
565 "both spellings of a consumed entry are consumed"
566 );
567 }
568
569 #[test]
570 fn degenerate_lcov_keys_never_wildcard_match() {
571 let mut cov_map = HashMap::new();
577 cov_map.insert(PathBuf::from("."), cov_with(&[(1, 1)]));
578 cov_map.insert(PathBuf::from(""), cov_with(&[(1, 1)]));
579 cov_map.insert(PathBuf::from("src/foo.rs"), cov_with(&[(1, 1)]));
580 let index = PathIndex::build(&cov_map);
581
582 assert!(
583 index.lookup(Path::new("/repo/src/bar.rs")).is_none(),
584 "a file with no real LCOV record must stay unmatched"
585 );
586 assert!(
587 index.lookup(Path::new("/repo/src/foo.rs")).is_some(),
588 "legitimate keys still match"
589 );
590
591 let complexity = vec![FunctionComplexity {
592 file: PathBuf::from("/repo/src/bar.rs"),
593 name: "unmapped".into(),
594 start_line: 1,
595 end_line: 1,
596 cyclomatic: 1.0,
597 }];
598 let result = merge(complexity, cov_map, MissingCoveragePolicy::Pessimistic);
599 let diag = result.diagnostics.expect("diagnostics present");
600 assert_eq!(
601 diag.source_only.count, 1,
602 "the unmapped file is reported, not silently bound"
603 );
604 assert_eq!(
605 diag.lcov_only.count, 3,
606 "degenerate keys and the unconsumed real key are strays"
607 );
608 }
609
610 #[test]
611 fn distinct_relative_files_never_merge() {
612 let mut cov_map = HashMap::new();
615 cov_map.insert(PathBuf::from("a/util.rs"), cov_with(&[(1, 1)]));
616 cov_map.insert(PathBuf::from("b/util.rs"), cov_with(&[(1, 0)]));
617 let index = PathIndex::build(&cov_map);
618 assert_eq!(index.by_relative.len(), 2);
619
620 let a = index.lookup(Path::new("/repo/a/util.rs")).expect("a match");
621 assert_eq!(a.cov.coverage_in_span(1, 1), 100.0);
622 let b = index.lookup(Path::new("/repo/b/util.rs")).expect("b match");
623 assert_eq!(b.cov.coverage_in_span(1, 1), 0.0);
624 }
625
626 #[cfg(unix)]
627 #[test]
628 fn absolute_aliases_merge_line_data_instead_of_last_write_wins() {
629 let dir = tempfile::tempdir().expect("tempdir");
635 let real = dir.path().join("a.rs");
636 std::fs::write(&real, "pub fn f() {}\npub fn g() {}\n").expect("write");
637 let link = dir.path().join("link.rs");
638 std::os::unix::fs::symlink(&real, &link).expect("symlink");
639
640 let mut cov_map = HashMap::new();
641 cov_map.insert(real.clone(), cov_with(&[(1, 1), (2, 0)]));
642 cov_map.insert(link, cov_with(&[(1, 0), (2, 1)]));
643
644 let complexity = vec![FunctionComplexity {
645 file: real,
646 name: "f".into(),
647 start_line: 1,
648 end_line: 2,
649 cyclomatic: 1.0,
650 }];
651
652 let result = merge(complexity, cov_map, MissingCoveragePolicy::Pessimistic);
653 let entry = &result.entries[0];
654 assert_eq!(
655 entry.coverage,
656 Some(100.0),
657 "merged legs cover both lines; either leg alone would give 50%"
658 );
659 let diag = result.diagnostics.expect("diagnostics present");
660 assert_eq!(diag.lcov_only.count, 0);
661 }
662
663 #[test]
664 fn merge_sorts_by_descending_crap() {
665 let complexity = vec![
666 FunctionComplexity {
667 file: PathBuf::from("a.rs"),
668 name: "easy".into(),
669 start_line: 1,
670 end_line: 3,
671 cyclomatic: 1.0,
672 },
673 FunctionComplexity {
674 file: PathBuf::from("a.rs"),
675 name: "hard".into(),
676 start_line: 10,
677 end_line: 30,
678 cyclomatic: 10.0,
679 },
680 ];
681 let result = merge(
682 complexity,
683 HashMap::new(),
684 MissingCoveragePolicy::Pessimistic,
685 );
686 assert_eq!(result.entries[0].function, "hard");
687 assert_eq!(result.entries[1].function, "easy");
688 }
689
690 #[test]
691 fn skip_policy_drops_rows_without_coverage() {
692 let complexity = vec![FunctionComplexity {
693 file: PathBuf::from("nowhere.rs"),
694 name: "foo".into(),
695 start_line: 1,
696 end_line: 5,
697 cyclomatic: 3.0,
698 }];
699 let result = merge(complexity, HashMap::new(), MissingCoveragePolicy::Skip);
700 assert!(result.entries.is_empty());
701 }
702
703 #[test]
704 fn relative_coverage_paths_are_not_resolved_against_cwd() {
705 let mut cov_map = HashMap::new();
716 cov_map.insert(PathBuf::from("src/lib.rs"), cov_with(&[(10, 1)]));
717 let index = PathIndex::build(&cov_map);
718
719 assert!(
722 index.by_absolute.is_empty(),
723 "relative coverage paths must not populate by_absolute"
724 );
725 assert_eq!(index.by_relative.len(), 1);
726
727 let found = index.lookup(Path::new("/somewhere/else/src/lib.rs"));
730 assert!(found.is_some());
731 }
732
733 #[test]
734 fn unmapped_files_reported_when_lcov_provided() {
735 let mut cov_map = HashMap::new();
736 cov_map.insert(PathBuf::from("src/foo.rs"), cov_with(&[(1, 1)]));
737
738 let complexity = vec![
739 FunctionComplexity {
740 file: PathBuf::from("/project/src/foo.rs"),
741 name: "matched".into(),
742 start_line: 1,
743 end_line: 3,
744 cyclomatic: 1.0,
745 },
746 FunctionComplexity {
747 file: PathBuf::from("/project/src/bar.rs"),
748 name: "unmatched".into(),
749 start_line: 1,
750 end_line: 3,
751 cyclomatic: 1.0,
752 },
753 ];
754
755 let result = merge(complexity, cov_map, MissingCoveragePolicy::Pessimistic);
756 let diag = result.diagnostics.expect("lcov provided → diagnostics");
757 assert_eq!(diag.analyzed_files, 2);
758 assert_eq!(diag.lcov_files, 1);
759 assert_eq!(diag.matched_files, 1);
760 assert_eq!(diag.source_only.count, 1);
761 assert_eq!(
762 diag.source_only.examples,
763 vec![PathBuf::from("/project/src/bar.rs")]
764 );
765 assert_eq!(diag.lcov_only.count, 0, "the only LCOV entry was consumed");
766 }
767
768 #[test]
769 fn lcov_only_files_are_reported() {
770 let mut cov_map = HashMap::new();
772 cov_map.insert(PathBuf::from("src/foo.rs"), cov_with(&[(1, 1)]));
773 cov_map.insert(PathBuf::from("src/phantom_a.rs"), cov_with(&[(1, 1)]));
774 cov_map.insert(PathBuf::from("src/phantom_b.rs"), cov_with(&[(1, 1)]));
775
776 let complexity = vec![FunctionComplexity {
777 file: PathBuf::from("/project/src/foo.rs"),
778 name: "matched".into(),
779 start_line: 1,
780 end_line: 3,
781 cyclomatic: 1.0,
782 }];
783
784 let result = merge(complexity, cov_map, MissingCoveragePolicy::Pessimistic);
785 let diag = result.diagnostics.expect("diagnostics present");
786 assert_eq!(diag.lcov_files, 3);
787 assert_eq!(diag.matched_files, 1);
788 assert_eq!(diag.lcov_only.count, 2);
789 assert_eq!(
790 diag.lcov_only.examples,
791 vec![
792 PathBuf::from("src/phantom_a.rs"),
793 PathBuf::from("src/phantom_b.rs")
794 ],
795 "lcov_only examples must be sorted"
796 );
797 }
798
799 #[cfg(unix)]
800 #[test]
801 fn symlink_alias_of_a_consumed_key_is_not_lcov_only() {
802 let dir = tempfile::tempdir().expect("tempdir");
808 let real = dir.path().join("a.rs");
809 std::fs::write(&real, "pub fn f() {}\n").expect("write");
810 let link = dir.path().join("link.rs");
811 std::os::unix::fs::symlink(&real, &link).expect("symlink");
812
813 let mut cov_map = HashMap::new();
814 cov_map.insert(real.clone(), cov_with(&[(1, 1)]));
815 cov_map.insert(link, cov_with(&[(1, 1)]));
816
817 let complexity = vec![FunctionComplexity {
818 file: real,
819 name: "f".into(),
820 start_line: 1,
821 end_line: 1,
822 cyclomatic: 1.0,
823 }];
824
825 let result = merge(complexity, cov_map, MissingCoveragePolicy::Pessimistic);
826 let diag = result.diagnostics.expect("diagnostics present");
827 assert_eq!(diag.matched_files, 1);
828 assert_eq!(
829 diag.lcov_only.count, 0,
830 "an alias of a consumed key is not a stray"
831 );
832 assert_eq!(diag.source_only.count, 0);
833 }
834
835 #[test]
836 fn relative_key_is_never_treated_as_an_alias() {
837 let abs = PathBuf::from("src/merge.rs")
845 .canonicalize()
846 .expect("crate-root CWD");
847
848 let mut cov_map = HashMap::new();
849 cov_map.insert(abs.clone(), cov_with(&[(1, 1)]));
850 cov_map.insert(PathBuf::from("src/merge.rs"), cov_with(&[(1, 1)]));
851
852 let complexity = vec![FunctionComplexity {
853 file: abs,
854 name: "f".into(),
855 start_line: 1,
856 end_line: 1,
857 cyclomatic: 1.0,
858 }];
859
860 let result = merge(complexity, cov_map, MissingCoveragePolicy::Pessimistic);
861 let diag = result.diagnostics.expect("diagnostics present");
862 assert_eq!(diag.matched_files, 1);
863 assert_eq!(
864 diag.lcov_only.count, 1,
865 "the relative spelling stays a stray — CWD resolution is forbidden"
866 );
867 assert_eq!(diag.lcov_only.examples, vec![PathBuf::from("src/merge.rs")]);
868 }
869
870 #[test]
871 fn shared_lcov_entry_consumed_by_multiple_files_is_not_lcov_only() {
872 let mut cov_map = HashMap::new();
875 cov_map.insert(PathBuf::from("src/lib.rs"), cov_with(&[(1, 1)]));
876
877 let complexity = vec![
878 FunctionComplexity {
879 file: PathBuf::from("/a/src/lib.rs"),
880 name: "one".into(),
881 start_line: 1,
882 end_line: 3,
883 cyclomatic: 1.0,
884 },
885 FunctionComplexity {
886 file: PathBuf::from("/b/src/lib.rs"),
887 name: "two".into(),
888 start_line: 1,
889 end_line: 3,
890 cyclomatic: 1.0,
891 },
892 ];
893
894 let result = merge(complexity, cov_map, MissingCoveragePolicy::Pessimistic);
895 let diag = result.diagnostics.expect("diagnostics present");
896 assert_eq!(diag.matched_files, 2);
897 assert_eq!(diag.lcov_only.count, 0);
898 }
899
900 #[test]
901 fn stray_examples_are_capped_but_count_is_exact() {
902 let files: Vec<PathBuf> = (0..SCOPE_EXAMPLE_CAP + 3)
903 .map(|i| PathBuf::from(format!("src/f{i:02}.rs")))
904 .collect();
905 let strays = StrayFiles::new(files);
906 assert_eq!(strays.count, SCOPE_EXAMPLE_CAP + 3);
907 assert_eq!(strays.examples.len(), SCOPE_EXAMPLE_CAP);
908 assert_eq!(
909 strays.examples[0],
910 PathBuf::from("src/f00.rs"),
911 "examples are the sorted head, not an arbitrary subset"
912 );
913 }
914
915 #[test]
916 fn stray_examples_not_truncated_at_or_below_cap() {
917 let files: Vec<PathBuf> = (0..SCOPE_EXAMPLE_CAP)
918 .map(|i| PathBuf::from(format!("src/f{i:02}.rs")))
919 .collect();
920 let strays = StrayFiles::new(files);
921 assert_eq!(strays.count, SCOPE_EXAMPLE_CAP);
922 assert_eq!(strays.examples.len(), SCOPE_EXAMPLE_CAP);
923 }
924
925 fn crap_entry(
928 file: &str,
929 function: &str,
930 line: usize,
931 crap: f64,
932 ) -> CrapEntry {
933 CrapEntry {
934 file: PathBuf::from(file),
935 function: function.into(),
936 line,
937 cyclomatic: 1.0,
938 coverage: Some(100.0),
939 crap,
940 crate_name: None,
941 }
942 }
943
944 fn order(entries: &[CrapEntry]) -> Vec<(&str, usize)> {
945 entries
946 .iter()
947 .map(|e| (e.function.as_str(), e.line))
948 .collect()
949 }
950
951 #[test]
952 fn sort_order_default_is_crap() {
953 assert_eq!(SortOrder::default(), SortOrder::Crap);
954 }
955
956 #[test]
957 fn sort_entries_crap_orders_by_score_descending() {
958 let mut entries = vec![
960 crap_entry("src/a.rs", "low", 1, 1.0),
961 crap_entry("src/a.rs", "high", 2, 90.0),
962 crap_entry("src/a.rs", "mid", 3, 30.0),
963 ];
964 sort_entries(&mut entries, SortOrder::Crap);
965 assert_eq!(order(&entries), [("high", 2), ("mid", 3), ("low", 1)]);
966 }
967
968 #[test]
969 fn sort_entries_file_orders_by_file_then_function_then_line() {
970 let mut entries = vec![
972 crap_entry("src/b.rs", "zeta", 1, 99.0),
973 crap_entry("src/a.rs", "beta", 1, 5.0),
974 crap_entry("src/a.rs", "alpha", 1, 5.0),
975 ];
976 sort_entries(&mut entries, SortOrder::File);
977 assert_eq!(
978 order(&entries),
979 [("alpha", 1), ("beta", 1), ("zeta", 1)],
980 "file order is (file, function, line) ascending, ignoring CRAP"
981 );
982 }
983
984 #[test]
985 fn sort_entries_file_tie_breaks_on_line() {
986 let mut entries = vec![
988 crap_entry("src/a.rs", "new", 50, 5.0),
989 crap_entry("src/a.rs", "new", 10, 5.0),
990 ];
991 sort_entries(&mut entries, SortOrder::File);
992 assert_eq!(order(&entries), [("new", 10), ("new", 50)]);
993 }
994
995 #[test]
996 fn sort_entries_file_normalizes_separators() {
997 let mut entries = vec![
1000 crap_entry("src\\b.rs", "b", 1, 5.0),
1001 crap_entry("src/a.rs", "a", 1, 5.0),
1002 ];
1003 sort_entries(&mut entries, SortOrder::File);
1004 assert_eq!(order(&entries), [("a", 1), ("b", 1)]);
1005 }
1006
1007 #[test]
1008 fn no_diagnostics_when_no_lcov_provided() {
1009 let complexity = vec![FunctionComplexity {
1010 file: PathBuf::from("src/foo.rs"),
1011 name: "foo".into(),
1012 start_line: 1,
1013 end_line: 3,
1014 cyclomatic: 1.0,
1015 }];
1016 let result = merge(
1017 complexity,
1018 HashMap::new(),
1019 MissingCoveragePolicy::Pessimistic,
1020 );
1021 assert!(
1022 result.diagnostics.is_none(),
1023 "no lcov → no scope diagnostics, no warnings"
1024 );
1025 }
1026}