1use std::collections::{BTreeMap, BTreeSet, HashMap};
8
9use serde::{Deserialize, Serialize};
10
11use crate::interned::Id;
12use crate::{
13 agent_json::pagination,
14 coverage_analysis::{CoverageSummary, is_independence_pair},
15 coverage_index::{
16 CoverageDimension, CoverageIndex, CoverageIndexError, CoverageViewId, IndexedCoverageModel,
17 IndexedDecisionGap, IndexedDimensionCoverage, IndexedFileGap, IndexedGapDimensions,
18 IndexedHitMetadata, IndexedMeasurement, IndexedOutcomeCounts, IndexedScopeEntry,
19 IndexedSourceScope, IndexedSummaryConfidence, IndexedTestSummary,
20 },
21 coverage_report::{
22 CoverageConfidence, CoverageView, DecisionMeta, ReportError, SourceLine, TestAttempt,
23 TestProvenance, TransportStats, coverage_summary_for_tests,
24 },
25};
26use supercov_contracts::AgentPagination;
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
29#[serde(rename_all = "lowercase")]
30pub enum MinimizeMetric {
31 All,
32 Lines,
33 Statements,
34 Functions,
35 Branches,
36 Mcdc,
37}
38
39#[derive(Debug, Clone, PartialEq, Serialize)]
40#[serde(rename_all = "camelCase")]
41pub struct MinimumTestSetResult {
42 pub optimal: bool,
43 #[serde(serialize_with = "crate::coverage_analysis::serialize_javascript_number")]
44 pub target: f64,
45 pub metric: MinimizeMetric,
46 pub selected: Vec<String>,
47 pub expanded: Vec<String>,
48 pub summary: CoverageSummary,
49 pub explored_states: usize,
50}
51
52#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
53pub struct CoverageMinimizedTest {
54 pub id: String,
55 pub name: String,
56 #[serde(skip_serializing_if = "Option::is_none")]
57 pub file: Option<String>,
58 pub runner: String,
59 pub kind: String,
60}
61
62#[derive(Debug, Clone, PartialEq, Serialize)]
63#[serde(rename_all = "camelCase")]
64pub struct CoverageMinimizeData {
65 pub run: String,
66 pub filters: CoverageQueryFilters,
67 pub optimal: bool,
68 #[serde(serialize_with = "crate::coverage_analysis::serialize_javascript_number")]
69 pub target: f64,
70 pub metric: MinimizeMetric,
71 pub selected: Vec<String>,
72 pub expanded: Vec<String>,
73 pub summary: CoverageSummary,
74 pub explored_states: usize,
75 pub selected_count: usize,
76 pub total_candidate_tests: usize,
77 pub tests: Vec<CoverageMinimizedTest>,
78}
79
80#[derive(Debug, Clone, Copy)]
81pub struct CoverageMinimizeQueryOptions<'a> {
82 pub run: &'a str,
83 pub view_id: CoverageViewId,
84 pub kind: Option<&'a str>,
85 pub runner: Option<&'a str>,
86 pub target: f64,
87 pub metric: MinimizeMetric,
88 pub max_states: usize,
89 pub offset: usize,
90 pub limit: usize,
91}
92
93pub fn coverage_minimize_query(
94 view: &CoverageView,
95 options: CoverageMinimizeQueryOptions<'_>,
96) -> Result<(CoverageMinimizeData, AgentPagination), QueryError> {
97 if options.limit == 0 {
98 return Err(QueryError::InvalidPagination);
99 }
100 let selected_ids = if options.kind.is_none() && options.runner.is_none() {
101 None
102 } else {
103 let ids = view
104 .tests
105 .iter()
106 .filter(|test| {
107 options.kind.is_none_or(|kind| test.provenance.kind == kind)
108 && options
109 .runner
110 .is_none_or(|runner| test.provenance.runner == runner)
111 })
112 .map(|test| test.id.clone())
113 .collect::<BTreeSet<_>>();
114 if ids.is_empty() {
115 return Err(QueryError::TestFilterEmpty {
116 kind: options.kind.map(str::to_owned),
117 runner: options.runner.map(str::to_owned),
118 });
119 }
120 Some(ids)
121 };
122 let mut solver_view = view.clone();
123 if let Some(selected) = &selected_ids {
124 solver_view.tests.retain(|test| selected.contains(&test.id));
125 }
126 let minimized = minimum_test_set(
127 &solver_view,
128 options.target,
129 options.metric,
130 options.max_states,
131 )?;
132 let selected_details = minimized
133 .selected
134 .iter()
135 .map(|id| {
136 let test = view
137 .tests
138 .iter()
139 .find(|test| test.id == *id)
140 .ok_or(QueryError::InvalidRecordSelection)?;
141 Ok(CoverageMinimizedTest {
142 id: id.clone(),
143 name: test.name.clone(),
144 file: test.file.clone(),
145 runner: test.provenance.runner.clone(),
146 kind: test.provenance.kind.clone(),
147 })
148 })
149 .collect::<Result<Vec<_>, QueryError>>()?;
150 let total = selected_details.len();
151 let tests = selected_details
152 .iter()
153 .skip(options.offset)
154 .take(options.limit)
155 .cloned()
156 .collect::<Vec<_>>();
157 let returned = tests.len();
158 let total_candidate_tests = solver_view
159 .tests
160 .iter()
161 .filter(|test| test.role == "test")
162 .count();
163 Ok((
164 CoverageMinimizeData {
165 run: options.run.into(),
166 filters: query_filters(options.view_id, options.kind, options.runner),
167 optimal: minimized.optimal,
168 target: minimized.target,
169 metric: minimized.metric,
170 selected: minimized.selected,
171 expanded: minimized.expanded,
172 summary: minimized.summary,
173 explored_states: minimized.explored_states,
174 selected_count: total,
175 total_candidate_tests,
176 tests,
177 },
178 pagination(options.offset, options.limit, returned, total),
179 ))
180}
181
182#[derive(Debug)]
183pub enum QueryError {
184 InvalidTarget(f64),
185 UnattributedEvidence,
186 TargetUnreachable {
187 metric: MinimizeMetric,
188 target: f64,
189 reachable: f64,
190 },
191 ComplexityLimit {
192 candidate_tests: usize,
193 obligations: usize,
194 explored_states: usize,
195 max_states: usize,
196 target: f64,
197 metric: MinimizeMetric,
198 },
199 Analysis(ReportError),
200 Index(CoverageIndexError),
201 InvalidPagination,
202 TestFilterEmpty {
203 kind: Option<String>,
204 runner: Option<String>,
205 },
206 TestNotFound(String),
207 DecisionNotFound(String),
208 SourceNotFound(String),
209 AmbiguousSelector {
210 selector: String,
211 matches: Vec<String>,
212 },
213 InvalidRecordSelection,
214 ScopeUnavailable,
215}
216
217impl From<ReportError> for QueryError {
218 fn from(value: ReportError) -> Self {
219 Self::Analysis(value)
220 }
221}
222
223impl From<CoverageIndexError> for QueryError {
224 fn from(value: CoverageIndexError) -> Self {
225 Self::Index(value)
226 }
227}
228
229#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
230#[serde(rename_all = "camelCase")]
231pub struct CoverageQueryFilters {
232 pub outcome: String,
233 pub kind: Option<String>,
234 pub runner: Option<String>,
235}
236
237#[derive(Debug, Clone, PartialEq, Serialize)]
238#[serde(rename_all = "camelCase")]
239pub struct CoverageFilesData {
240 pub run: String,
241 pub filters: CoverageQueryFilters,
242 pub metric: MinimizeMetric,
243 pub files: Vec<IndexedFileGap>,
244}
245
246#[derive(Debug, Clone, PartialEq, Serialize)]
247#[serde(rename_all = "camelCase")]
248pub struct CoverageGapsData {
249 pub run: String,
250 pub filters: CoverageQueryFilters,
251 pub metric: MinimizeMetric,
252 pub gaps: Vec<IndexedFileGap>,
253}
254
255#[derive(Debug, Clone, PartialEq, Serialize)]
256#[serde(rename_all = "camelCase")]
257pub struct CoverageKindsData {
258 pub run: String,
259 pub filters: CoverageQueryFilters,
260 pub kinds: Vec<IndexedDimensionCoverage>,
261}
262
263#[derive(Debug, Clone, PartialEq, Serialize)]
264#[serde(rename_all = "camelCase")]
265pub struct CoverageRunnersData {
266 pub run: String,
267 pub filters: CoverageQueryFilters,
268 pub runners: Vec<IndexedDimensionCoverage>,
269}
270
271#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
273#[serde(rename_all = "camelCase")]
274pub struct TestAttributionCounts {
275 pub exact: usize,
277 pub by_time: usize,
280 pub partial: usize,
282 pub run_wide: usize,
284}
285
286impl TestAttributionCounts {
287 pub fn total(&self) -> usize {
288 self.exact + self.by_time + self.partial + self.run_wide
289 }
290}
291
292#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
293#[serde(rename_all = "camelCase")]
294pub struct CoverageDiagnostic {
295 pub code: String,
296 pub severity: String,
297 pub message: String,
298}
299
300#[derive(Debug, Clone, PartialEq, Serialize)]
301#[serde(rename_all = "camelCase")]
302pub struct CoverageSummaryData {
303 pub test_kind_sources: BTreeMap<String, usize>,
304 #[serde(skip_serializing_if = "Option::is_none")]
305 pub assertion_coverage: Option<serde_json::Value>,
306 pub run: String,
307 #[serde(default)]
308 pub command: Vec<String>,
309 #[serde(default, skip_serializing_if = "Vec::is_empty")]
312 pub merged_across: Vec<String>,
313 #[serde(default, skip_serializing_if = "Vec::is_empty")]
316 pub companions: Vec<crate::run_store::CompanionRun>,
317 #[serde(default, skip_serializing_if = "Vec::is_empty")]
318 pub hints: Vec<String>,
319 #[serde(skip_serializing_if = "Option::is_none")]
320 pub workspace: Option<String>,
321 pub filters: CoverageQueryFilters,
322 pub model: IndexedCoverageModel,
323 pub generated_at: String,
324 pub valid: bool,
325 #[serde(skip_serializing_if = "Option::is_none")]
326 pub test_exit_code: Option<i32>,
327 pub stale: bool,
328 pub stale_reasons: Vec<String>,
329 pub structurally_complete: bool,
330 pub complete: bool,
331 pub coverage: CoverageSummary,
332 pub measurement: IndexedMeasurement,
333 pub coverage_by_kind: Vec<IndexedDimensionCoverage>,
334 #[serde(skip_serializing_if = "Option::is_none")]
338 pub coverage_by_tests: Option<CoverageSummary>,
339 #[serde(skip_serializing_if = "Option::is_none")]
340 pub e2e_gap_context: Option<CoverageKindGapContext>,
341 pub coverage_by_runner: Vec<IndexedDimensionCoverage>,
342 pub attribution: crate::coverage_index::IndexedAttribution,
343 pub test_attribution: TestAttributionCounts,
349 #[serde(skip_serializing_if = "Option::is_none")]
350 pub transport: Option<TransportStats>,
351 pub diagnostics: Vec<CoverageDiagnostic>,
352 #[serde(skip_serializing_if = "Option::is_none")]
353 pub confidence: Option<IndexedSummaryConfidence>,
354 pub files_with_gaps: usize,
355 pub files_with_coverage_gaps: usize,
356 pub files_with_measurement_limitations: usize,
357 pub tests: usize,
358 pub setups: usize,
359 pub test_outcomes: IndexedOutcomeCounts,
360 #[serde(skip_serializing_if = "Option::is_none")]
361 pub source_scope: Option<IndexedSourceScope>,
362 #[serde(skip_serializing_if = "Option::is_none")]
365 pub unclassified_source: Option<UnclassifiedSource>,
366}
367
368#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
371#[serde(rename_all = "camelCase")]
372pub struct ScopeGroup {
373 pub status: String,
374 pub directory: String,
375 pub reason: String,
376 pub files: usize,
377}
378
379#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
380#[serde(rename_all = "camelCase")]
381pub struct ScopeDirectory {
382 pub directory: String,
383 pub files: usize,
384}
385
386#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
390#[serde(rename_all = "camelCase")]
391pub struct UnclassifiedSource {
392 pub directories: Vec<ScopeDirectory>,
393 pub roots: Vec<String>,
394}
395
396fn scope_directory(entry: &IndexedScopeEntry) -> String {
400 let package = entry.package_root.as_deref().filter(|root| *root != ".");
401 let local = package
402 .and_then(|root| entry.file.strip_prefix(root))
403 .and_then(|rest| rest.strip_prefix('/'))
404 .unwrap_or(&entry.file);
405 match (package, local.split_once('/')) {
406 (Some(package), Some((first, _))) => format!("{package}/{first}"),
407 (Some(package), None) => package.to_owned(),
408 (None, Some((first, _))) => first.to_owned(),
409 (None, None) => ".".to_owned(),
410 }
411}
412
413fn scope_status_rank(status: &str) -> u8 {
414 match status {
415 "ambiguous" => 0,
416 "included" => 1,
417 "excluded" => 2,
418 _ => 3,
419 }
420}
421
422pub fn scope_groups(entries: &[IndexedScopeEntry]) -> Vec<ScopeGroup> {
425 let mut groups = BTreeMap::<(u8, String, String, String), usize>::new();
426 for entry in entries {
427 *groups
428 .entry((
429 scope_status_rank(&entry.status),
430 entry.status.clone(),
431 scope_directory(entry),
432 entry.reason.clone(),
433 ))
434 .or_default() += 1;
435 }
436 let mut groups = groups
437 .into_iter()
438 .map(|((rank, status, directory, reason), files)| {
439 (
440 rank,
441 ScopeGroup {
442 status,
443 directory,
444 reason,
445 files,
446 },
447 )
448 })
449 .collect::<Vec<_>>();
450 groups.sort_by(|(left_rank, left), (right_rank, right)| {
451 left_rank
452 .cmp(right_rank)
453 .then_with(|| right.files.cmp(&left.files))
454 .then_with(|| left.directory.cmp(&right.directory))
455 .then_with(|| left.reason.cmp(&right.reason))
456 });
457 groups.into_iter().map(|(_, group)| group).collect()
458}
459
460pub fn unclassified_source(
461 roots: &[String],
462 entries: &[IndexedScopeEntry],
463) -> Option<UnclassifiedSource> {
464 let mut places = BTreeMap::<String, usize>::new();
465 let mut named = Vec::<(String, usize)>::new();
466 for entry in entries.iter().filter(|entry| entry.status == "ambiguous") {
467 let directory = scope_directory(entry);
468 *places.entry(directory.clone()).or_default() += 1;
469 let root = if entry.file.contains('/') && directory != "." {
472 directory
473 } else {
474 entry.file.clone()
475 };
476 match named.iter_mut().find(|(name, _)| *name == root) {
477 Some((_, files)) => *files += 1,
478 None => named.push((root, 1)),
479 }
480 }
481 if places.is_empty() {
482 return None;
483 }
484 let mut directories = places
485 .into_iter()
486 .map(|(directory, files)| ScopeDirectory { directory, files })
487 .collect::<Vec<_>>();
488 directories.sort_by(|left, right| {
489 right
490 .files
491 .cmp(&left.files)
492 .then_with(|| left.directory.cmp(&right.directory))
493 });
494 named.sort_by(|left, right| right.1.cmp(&left.1).then_with(|| left.0.cmp(&right.0)));
495 let mut all = roots.to_vec();
496 for (root, _) in named {
497 if !all.contains(&root) {
498 all.push(root);
499 }
500 }
501 Some(UnclassifiedSource {
502 directories,
503 roots: all,
504 })
505}
506
507#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
508#[serde(rename_all = "camelCase")]
509pub struct CoverageKindGapContext {
510 pub kind: String,
511 pub other_kinds: Vec<String>,
512 pub covered_elsewhere: IndexedGapDimensions,
513 pub uncovered_everywhere: IndexedGapDimensions,
514}
515
516#[derive(Debug, Clone)]
517pub struct CoverageSummaryQueryOptions<'a> {
518 pub run: &'a str,
519 pub view: CoverageViewId,
520 pub kind: Option<&'a str>,
521 pub runner: Option<&'a str>,
522 pub valid: bool,
523 pub test_exit_code: Option<i32>,
524 pub stale: bool,
525 pub stale_reasons: Vec<String>,
526}
527
528#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
529pub struct ScopeCounts {
530 pub included: usize,
531 pub excluded: usize,
532 pub ambiguous: usize,
533}
534
535#[derive(Debug, Clone, PartialEq, Serialize)]
536#[serde(rename_all = "camelCase")]
537pub struct CoverageScopeData {
538 pub run: String,
539 pub filters: CoverageQueryFilters,
540 pub kind: String,
541 pub language: String,
542 pub model: String,
543 #[serde(skip_serializing_if = "Option::is_none")]
544 pub mode: Option<String>,
545 pub roots: Vec<String>,
546 #[serde(skip_serializing_if = "Option::is_none")]
547 pub unit: Option<String>,
548 #[serde(skip_serializing_if = "Option::is_none")]
549 pub measurement_complete: Option<bool>,
550 pub counts: ScopeCounts,
551 pub measurement: IndexedMeasurement,
552 pub groups: Vec<ScopeGroup>,
554 #[serde(skip_serializing_if = "Option::is_none")]
555 pub unclassified_source: Option<UnclassifiedSource>,
556 pub entries: Vec<IndexedScopeEntry>,
557}
558
559#[derive(Debug, Clone, Copy)]
560pub struct CoverageScopeQueryOptions<'a> {
561 pub run: &'a str,
562 pub view: CoverageViewId,
563 pub kind: Option<&'a str>,
564 pub runner: Option<&'a str>,
565 pub offset: usize,
566 pub limit: usize,
567}
568
569#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
570pub struct CoverageLocation {
571 pub file: String,
572 pub line: usize,
573}
574
575#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
576pub struct CoverageCoveringTest {
577 pub id: String,
578 pub name: String,
579 pub provenance: TestProvenance,
580}
581
582#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
583#[serde(rename_all = "camelCase")]
584pub struct CoverageCoveringPhase {
585 pub id: String,
586 pub kind: String,
587 pub operation: String,
588 #[serde(skip_serializing_if = "Option::is_none")]
589 pub source: Option<String>,
590 pub test: String,
591 #[serde(skip_serializing_if = "Option::is_none")]
592 pub status: Option<String>,
593 #[serde(skip_serializing_if = "Option::is_none")]
594 pub caused_by_phase_id: Option<String>,
595}
596
597#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
598#[serde(rename_all = "camelCase")]
599pub struct CoverageAnchor {
600 pub kind: String,
601 pub id: String,
602 pub column: usize,
603 pub covered: bool,
604 #[serde(skip_serializing_if = "Option::is_none")]
605 pub source: Option<String>,
606 #[serde(skip_serializing_if = "Option::is_none")]
607 pub missing: Option<String>,
608 pub covering_tests: usize,
609 #[serde(skip_serializing_if = "Option::is_none")]
610 pub covered_conditions: Option<usize>,
611 #[serde(skip_serializing_if = "Option::is_none")]
612 pub conditions: Option<usize>,
613}
614
615#[derive(Debug, Clone, PartialEq, Serialize)]
616#[serde(rename_all = "camelCase")]
617pub struct CoverageCoversLineData {
618 pub run: String,
619 pub filters: CoverageQueryFilters,
620 pub location: CoverageLocation,
621 #[serde(skip_serializing_if = "Option::is_none")]
622 pub source: Option<String>,
623 #[serde(skip_serializing_if = "Option::is_none")]
624 pub source_origin: Option<String>,
625 pub covered: bool,
626 pub confidence: CoverageConfidence,
627 pub total_tests: usize,
628 pub total_phases: usize,
629 pub total_anchored: usize,
630 pub covered_anchored: usize,
631 pub total_limitations: usize,
632 pub total_remaining: usize,
633 pub tests: Vec<CoverageCoveringTest>,
634 pub phases: Vec<CoverageCoveringPhase>,
635 pub anchored: Vec<CoverageAnchor>,
636 pub limitations: Vec<CoverageFileLimitation>,
637 pub remaining: Vec<CoverageFileObligation>,
638 #[serde(skip_serializing_if = "Option::is_none")]
641 pub assertions: Option<serde_json::Value>,
642}
643
644#[derive(Debug, Clone, PartialEq, Serialize)]
645#[serde(rename_all = "camelCase")]
646pub struct CoverageCoversAnchorsData {
647 pub run: String,
648 pub filters: CoverageQueryFilters,
649 pub location: CoverageLocation,
650 #[serde(skip_serializing_if = "Option::is_none")]
651 pub source: Option<String>,
652 #[serde(skip_serializing_if = "Option::is_none")]
653 pub source_origin: Option<String>,
654 pub line_obligation: bool,
655 pub anchored: Vec<CoverageAnchor>,
656 pub total_anchored: usize,
657 pub covered_anchored: usize,
658 pub total_limitations: usize,
659 pub limitations: Vec<CoverageFileLimitation>,
660 pub total_remaining: usize,
661 pub remaining: Vec<CoverageFileObligation>,
662 pub total_tests: usize,
663 pub tests: Vec<CoverageCoveringTest>,
664 #[serde(skip_serializing_if = "Option::is_none")]
667 pub assertions: Option<serde_json::Value>,
668}
669
670#[derive(Debug, Clone, PartialEq, Serialize)]
671#[serde(untagged)]
672pub enum CoverageCoversData {
673 Line(CoverageCoversLineData),
674 Anchors(CoverageCoversAnchorsData),
675}
676
677#[derive(Debug, Clone, Copy)]
678pub struct CoverageCoversQueryOptions<'a> {
679 pub run: &'a str,
680 pub view: CoverageViewId,
681 pub kind: Option<&'a str>,
682 pub runner: Option<&'a str>,
683 pub file: &'a str,
684 pub line: usize,
685 pub offset: usize,
686 pub limit: usize,
687}
688
689fn query_filters(
690 view: CoverageViewId,
691 kind: Option<&str>,
692 runner: Option<&str>,
693) -> CoverageQueryFilters {
694 CoverageQueryFilters {
695 outcome: match view {
696 CoverageViewId::All => "all",
697 CoverageViewId::Passed => "passed",
698 CoverageViewId::Failed => "failed",
699 }
700 .into(),
701 kind: kind.map(str::to_owned),
702 runner: runner.map(str::to_owned),
703 }
704}
705
706fn selected_test_ids(
707 tests: &[IndexedTestSummary],
708 kind: Option<&str>,
709 runner: Option<&str>,
710) -> Result<Option<BTreeSet<String>>, QueryError> {
711 if kind.is_none() && runner.is_none() {
712 return Ok(None);
713 }
714 let selected = tests
715 .iter()
716 .filter(|test| {
717 kind.is_none_or(|kind| test.provenance.kind == kind)
718 && runner.is_none_or(|runner| test.provenance.runner == runner)
719 })
720 .map(|test| test.id.clone())
721 .collect::<BTreeSet<_>>();
722 if selected.is_empty() {
723 return Err(QueryError::TestFilterEmpty {
724 kind: kind.map(str::to_owned),
725 runner: runner.map(str::to_owned),
726 });
727 }
728 Ok(Some(selected))
729}
730
731pub fn coverage_covers_query(
732 index: &CoverageIndex<'_>,
733 options: CoverageCoversQueryOptions<'_>,
734) -> Result<(CoverageCoversData, AgentPagination), QueryError> {
735 if options.limit == 0 {
736 return Err(QueryError::InvalidPagination);
737 }
738 let tests = index.test_summaries(options.view)?;
739 let selected = selected_test_ids(&tests, options.kind, options.runner)?;
740 let selected_includes = |id: &str| selected.as_ref().is_none_or(|ids| ids.contains(id));
741 let filters = query_filters(options.view, options.kind, options.runner);
742 let location = CoverageLocation {
743 file: options.file.into(),
744 line: options.line,
745 };
746 let metadata = index
748 .hit_metadata_in_file(options.view, options.file)?
749 .into_iter()
750 .map(|value| (value.id.clone(), value))
751 .collect::<HashMap<_, _>>();
752 let decisions = index
753 .decision_details_in_file(options.view, options.file)?
754 .into_iter()
755 .map(|value| (value.meta.id.clone(), value))
756 .collect::<HashMap<_, _>>();
757 let anchors = index.anchors(options.view, options.file, options.line)?;
758 let tests_by_id = tests
759 .iter()
760 .map(|test| (test.id.clone(), test.clone()))
761 .collect::<HashMap<_, _>>();
762 let mut anchor_test_ids = anchors
763 .iter()
764 .flat_map(|anchor| anchor.tests.iter())
765 .filter(|id| selected_includes(id))
766 .cloned()
767 .collect::<BTreeSet<_>>();
768 for anchor in anchors.iter().filter(|anchor| anchor.kind == "branch") {
769 anchor_test_ids.extend(
770 metadata
771 .values()
772 .filter(|detail| detail.parent_id.as_deref() == Some(anchor.id.as_str()))
773 .flat_map(|detail| detail.tests.iter())
774 .filter(|id| selected_includes(id))
775 .cloned(),
776 );
777 }
778 let all_anchor_tests = anchor_test_ids
779 .iter()
780 .map(|id| {
781 let test = tests_by_id.get(id);
782 CoverageCoveringTest {
783 id: id.clone(),
784 name: test.map_or_else(|| id.clone(), |test| test.name.clone()),
785 provenance: test
786 .map_or_else(TestProvenance::default, |test| test.provenance.clone()),
787 }
788 })
789 .collect::<Vec<_>>();
790 let total_anchor_tests = all_anchor_tests.len();
791 let anchor_tests_page = all_anchor_tests
792 .iter()
793 .skip(options.offset)
794 .take(options.limit)
795 .cloned()
796 .collect::<Vec<_>>();
797 let render_anchor = |anchor: crate::coverage_index::IndexedAnchor| {
798 let branch_alternatives = if anchor.kind == "branch" {
799 metadata
800 .values()
801 .filter(|detail| {
802 detail.obligation == "branch"
803 && detail.parent_id.as_deref() == Some(anchor.id.as_str())
804 })
805 .collect::<Vec<_>>()
806 } else {
807 Vec::new()
808 };
809 let branch_tests = branch_alternatives
810 .iter()
811 .flat_map(|detail| detail.tests.iter())
812 .filter(|test| selected_includes(test))
813 .cloned()
814 .collect::<BTreeSet<_>>();
815 let covering_tests = if anchor.kind == "branch" {
816 branch_tests.len()
817 } else {
818 anchor
819 .tests
820 .iter()
821 .filter(|test| selected_includes(test))
822 .count()
823 };
824 let detail = metadata.get(&anchor.id);
825 let decision = decisions
826 .get(&anchor.id)
827 .cloned()
828 .map(|decision| selected_decision(decision, selected.as_ref()));
829 let conditions = decision.as_ref().map_or(anchor.conditions, |decision| {
830 Some(decision.conditions.len())
831 });
832 let covered_conditions = decision
833 .as_ref()
834 .map_or(anchor.covered_conditions, |decision| {
835 Some(
836 decision
837 .conditions
838 .iter()
839 .filter(|condition| condition.covered)
840 .count(),
841 )
842 });
843 let covered = match anchor.kind.as_str() {
844 "decision" => conditions == covered_conditions,
845 "branch" if !branch_alternatives.is_empty() => branch_alternatives
846 .iter()
847 .all(|detail| detail.tests.iter().any(|test| selected_includes(test))),
848 "branch" => anchor.covered,
849 _ => covering_tests > 0,
850 };
851 let branch_source = branch_alternatives
852 .first()
853 .map(|detail| detail.source.clone());
854 let missing_branch_alternatives = branch_alternatives
855 .iter()
856 .filter(|detail| !detail.tests.iter().any(|test| selected_includes(test)))
857 .filter_map(|detail| detail.alternative.clone())
858 .collect::<Vec<_>>();
859 CoverageAnchor {
860 kind: anchor.kind,
861 id: anchor.id,
862 column: anchor.column,
863 covered,
864 source: decision
865 .as_ref()
866 .map(|decision| decision.meta.source.clone())
867 .or(branch_source)
868 .or_else(|| {
869 detail.map(|detail| {
870 detail
871 .label
872 .clone()
873 .unwrap_or_else(|| detail.source.clone())
874 })
875 })
876 .and_then(|source| compact_source(&source)),
877 missing: if missing_branch_alternatives.is_empty() {
878 detail.and_then(|detail| detail.alternative.clone())
879 } else {
880 Some(missing_branch_alternatives.join("; "))
881 },
882 covering_tests,
883 covered_conditions,
884 conditions,
885 }
886 };
887 let rendered_anchors = anchors.into_iter().map(render_anchor).collect::<Vec<_>>();
888 let total_anchored = rendered_anchors.len();
889 let covered_anchored = rendered_anchors
890 .iter()
891 .filter(|anchor| anchor.covered)
892 .count();
893 let all_limitations = index
894 .limitations(options.view)?
895 .into_iter()
896 .filter(|limitation| limitation.file == options.file && limitation.line == options.line)
897 .map(|limitation| CoverageFileLimitation {
898 id: limitation.id,
899 kind: limitation.kind,
900 file: limitation.file,
901 line: limitation.line,
902 column: limitation.column,
903 source: limitation.source,
904 reason: limitation.reason,
905 blocking: limitation.blocking,
906 effect: "outside-measured-denominator".into(),
907 })
908 .collect::<Vec<_>>();
909 let total_limitations = all_limitations.len();
910 let limitations_page = all_limitations
911 .iter()
912 .skip(options.offset)
913 .take(options.limit)
914 .cloned()
915 .collect::<Vec<_>>();
916 let (file_detail, _) = coverage_file_detail_query(
917 index,
918 CoverageFileDetailOptions {
919 run: options.run,
920 view: options.view,
921 kind: options.kind,
922 runner: options.runner,
923 selector: options.file,
924 metric: MinimizeMetric::All,
925 offset: 0,
926 limit: usize::MAX,
927 },
928 )?;
929 let gap_line = file_detail
930 .gap_lines
931 .into_iter()
932 .find(|gap| gap.line == options.line);
933 let source = gap_line
939 .as_ref()
940 .and_then(|gap| gap.source.clone())
941 .or_else(|| {
942 rendered_anchors
943 .iter()
944 .filter_map(|anchor| anchor.source.clone())
945 .min_by_key(|source| source.len())
946 });
947 let all_remaining = gap_line.map_or_else(Vec::new, |gap| gap.obligations);
948 let total_remaining = all_remaining.len();
949 let remaining_page = all_remaining
950 .iter()
951 .skip(options.offset)
952 .take(options.limit)
953 .cloned()
954 .collect::<Vec<_>>();
955 let Some(line) = index.line(options.view, options.file, options.line)? else {
956 let anchored = rendered_anchors
957 .iter()
958 .skip(options.offset)
959 .take(options.limit)
960 .cloned()
961 .collect::<Vec<_>>();
962 let total = total_anchored.max(total_limitations).max(total_remaining);
963 let total = total.max(total_anchor_tests);
964 let returned = anchored
965 .len()
966 .max(limitations_page.len())
967 .max(remaining_page.len())
968 .max(anchor_tests_page.len());
969 return Ok((
970 CoverageCoversData::Anchors(CoverageCoversAnchorsData {
971 run: options.run.into(),
972 filters,
973 location,
974 source,
975 source_origin: None,
976 line_obligation: false,
977 anchored,
978 total_anchored,
979 covered_anchored,
980 total_limitations,
981 limitations: limitations_page,
982 total_remaining,
983 remaining: remaining_page,
984 total_tests: total_anchor_tests,
985 tests: anchor_tests_page,
986 assertions: None,
987 }),
988 pagination(options.offset, options.limit, returned, total),
989 ));
990 };
991 let all_tests = line
992 .tests
993 .iter()
994 .filter(|id| selected_includes(id))
995 .map(|id| {
996 let test = tests_by_id.get(id);
997 CoverageCoveringTest {
998 id: id.clone(),
999 name: test.map_or_else(|| id.clone(), |test| test.name.clone()),
1000 provenance: test
1001 .map_or_else(TestProvenance::default, |test| test.provenance.clone()),
1002 }
1003 })
1004 .collect::<Vec<_>>();
1005 let phases_by_id = index
1006 .phase_summaries(options.view)?
1007 .into_iter()
1008 .map(|phase| (phase.id.clone(), phase))
1009 .collect::<HashMap<_, _>>();
1010 let all_phases = line
1011 .phases
1012 .iter()
1013 .filter_map(|id| phases_by_id.get(id))
1014 .filter(|phase| selected_includes(&phase.test))
1015 .map(|phase| CoverageCoveringPhase {
1016 id: phase.id.clone(),
1017 kind: phase.kind.clone(),
1018 operation: phase.operation.clone(),
1019 source: phase.source.clone(),
1020 test: phase.test.clone(),
1021 status: phase.status.clone(),
1022 caused_by_phase_id: phase.caused_by_phase_id.clone(),
1023 })
1024 .collect::<Vec<_>>();
1025 let tests_page = all_tests
1026 .iter()
1027 .skip(options.offset)
1028 .take(options.limit)
1029 .cloned()
1030 .collect::<Vec<_>>();
1031 let phases_page = all_phases
1032 .iter()
1033 .skip(options.offset)
1034 .take(options.limit)
1035 .cloned()
1036 .collect::<Vec<_>>();
1037 let anchored_page = rendered_anchors
1038 .into_iter()
1039 .skip(options.offset)
1040 .take(options.limit)
1041 .collect::<Vec<_>>();
1042 let total = all_tests
1043 .len()
1044 .max(all_phases.len())
1045 .max(total_anchored)
1046 .max(total_limitations)
1047 .max(total_remaining);
1048 let returned = tests_page
1049 .len()
1050 .max(phases_page.len())
1051 .max(anchored_page.len())
1052 .max(limitations_page.len())
1053 .max(remaining_page.len());
1054 Ok((
1055 CoverageCoversData::Line(CoverageCoversLineData {
1056 run: options.run.into(),
1057 filters,
1058 location,
1059 source,
1060 source_origin: None,
1061 covered: line.tests.iter().any(|test| selected_includes(test)),
1062 confidence: line.confidence,
1063 total_tests: all_tests.len(),
1064 total_phases: all_phases.len(),
1065 total_anchored,
1066 covered_anchored,
1067 total_limitations,
1068 total_remaining,
1069 tests: tests_page,
1070 phases: phases_page,
1071 anchored: anchored_page,
1072 limitations: limitations_page,
1073 remaining: remaining_page,
1074 assertions: None,
1075 }),
1076 pagination(options.offset, options.limit, returned, total),
1077 ))
1078}
1079
1080#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1081pub struct CoverageTestMatch {
1082 pub id: String,
1083 pub name: String,
1084 pub outcome: String,
1085 pub provenance: TestProvenance,
1086}
1087
1088#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1089#[serde(rename_all = "camelCase")]
1090pub struct CoverageHitDetail {
1091 pub id: String,
1092 pub obligation: String,
1093 #[serde(skip_serializing_if = "Option::is_none")]
1094 pub branch_kind: Option<String>,
1095 #[serde(skip_serializing_if = "Option::is_none")]
1096 pub file: Option<String>,
1097 #[serde(skip_serializing_if = "Option::is_none")]
1098 pub line: Option<usize>,
1099 #[serde(skip_serializing_if = "Option::is_none")]
1100 pub column: Option<usize>,
1101 #[serde(skip_serializing_if = "Option::is_none")]
1102 pub label: Option<String>,
1103 #[serde(skip_serializing_if = "Option::is_none")]
1104 pub alternative: Option<String>,
1105}
1106
1107#[derive(Debug, Clone, PartialEq, Serialize)]
1108pub struct CoverageTestDecision {
1109 pub id: String,
1110 pub vectors: Vec<crate::coverage_analysis::McdcVector>,
1111 pub meta: DecisionMeta,
1112}
1113
1114#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1115#[serde(rename_all = "camelCase")]
1116pub struct CoverageTestPhase {
1117 pub id: String,
1118 pub kind: String,
1119 pub operation: String,
1120 #[serde(skip_serializing_if = "Option::is_none")]
1121 pub source: Option<String>,
1122 #[serde(skip_serializing_if = "Option::is_none")]
1123 pub status: Option<String>,
1124 #[serde(skip_serializing_if = "Option::is_none")]
1125 pub caused_by_phase_id: Option<String>,
1126 pub lines: usize,
1127 pub decisions: usize,
1128}
1129
1130#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1131pub struct CoverageTestTotals {
1132 pub lines: usize,
1133 pub hits: usize,
1134 pub decisions: usize,
1135 pub phases: usize,
1136}
1137
1138#[derive(Debug, Clone, PartialEq, Serialize)]
1139#[serde(rename_all = "camelCase")]
1140pub struct CoverageSelectedTest {
1141 pub id: String,
1142 pub name: String,
1143 #[serde(skip_serializing_if = "Option::is_none")]
1144 pub file: Option<String>,
1145 #[serde(skip_serializing_if = "Option::is_none")]
1146 pub title: Option<String>,
1147 pub retries: Vec<usize>,
1148 pub attempts: Vec<TestAttempt>,
1149 pub outcome: String,
1150 pub provenance: TestProvenance,
1151 pub role: String,
1152 pub attribution: String,
1156 pub hits: Vec<String>,
1157 pub decisions: Vec<CoverageTestDecision>,
1158 pub lines: Vec<SourceLine>,
1159 pub hit_details: Vec<CoverageHitDetail>,
1160 pub phases: Vec<CoverageTestPhase>,
1161 pub totals: CoverageTestTotals,
1162}
1163
1164#[derive(Debug, Clone, PartialEq, Serialize)]
1165pub struct CoverageTestMatchesData {
1166 pub run: String,
1167 pub filters: CoverageQueryFilters,
1168 pub tests: Vec<CoverageTestMatch>,
1169}
1170
1171#[derive(Debug, Clone, PartialEq, Serialize)]
1172#[serde(rename_all = "camelCase")]
1173pub struct CoverageTestDetailData {
1174 pub run: String,
1175 pub filters: CoverageQueryFilters,
1176 pub pagination_applies_to: String,
1177 pub tests: Vec<CoverageSelectedTest>,
1178}
1179
1180#[derive(Debug, Clone, PartialEq, Serialize)]
1181#[serde(untagged)]
1182pub enum CoverageTestData {
1183 Matches(CoverageTestMatchesData),
1184 Detail(CoverageTestDetailData),
1185}
1186
1187#[derive(Debug, Clone, Copy)]
1188pub struct CoverageTestQueryOptions<'a> {
1189 pub run: &'a str,
1190 pub view: CoverageViewId,
1191 pub kind: Option<&'a str>,
1192 pub runner: Option<&'a str>,
1193 pub selector: &'a str,
1194 pub offset: usize,
1195 pub limit: usize,
1196}
1197
1198fn hit_detail(id: &str, metadata: Option<&IndexedHitMetadata>) -> CoverageHitDetail {
1199 match metadata {
1200 Some(metadata) => CoverageHitDetail {
1201 id: id.into(),
1202 obligation: metadata.obligation.clone(),
1203 branch_kind: metadata.branch_kind.clone(),
1204 file: Some(metadata.file.clone()),
1205 line: Some(metadata.line),
1206 column: Some(metadata.column),
1207 label: metadata.label.clone(),
1208 alternative: metadata.alternative.clone(),
1209 },
1210 None => CoverageHitDetail {
1211 id: id.into(),
1212 obligation: "unknown".into(),
1213 branch_kind: None,
1214 file: None,
1215 line: None,
1216 column: None,
1217 label: None,
1218 alternative: None,
1219 },
1220 }
1221}
1222
1223pub fn coverage_test_query(
1224 index: &CoverageIndex<'_>,
1225 options: CoverageTestQueryOptions<'_>,
1226) -> Result<(CoverageTestData, AgentPagination), QueryError> {
1227 if options.limit == 0 {
1228 return Err(QueryError::InvalidPagination);
1229 }
1230 let tests = index.test_details(options.view)?;
1231 let summaries = tests
1232 .iter()
1233 .map(|test| test.summary.clone())
1234 .collect::<Vec<_>>();
1235 let selected = selected_test_ids(&summaries, options.kind, options.runner)?;
1236 let selector = options.selector.to_lowercase();
1237 let matches = tests
1238 .into_iter()
1239 .filter(|test| {
1240 selected
1241 .as_ref()
1242 .is_none_or(|ids| ids.contains(&test.summary.id))
1243 })
1244 .filter(|test| {
1245 test.summary.id == selector || test.summary.name.to_lowercase().contains(&selector)
1246 })
1247 .collect::<Vec<_>>();
1248 if matches.is_empty() {
1249 return Err(QueryError::TestNotFound(options.selector.into()));
1250 }
1251 let filters = query_filters(options.view, options.kind, options.runner);
1252 if matches.len() > 1 {
1253 let total = matches.len();
1254 let page = matches
1255 .into_iter()
1256 .skip(options.offset)
1257 .take(options.limit)
1258 .map(|test| CoverageTestMatch {
1259 id: test.summary.id,
1260 name: test.summary.name,
1261 outcome: test.summary.outcome,
1262 provenance: test.summary.provenance,
1263 })
1264 .collect::<Vec<_>>();
1265 let returned = page.len();
1266 return Ok((
1267 CoverageTestData::Matches(CoverageTestMatchesData {
1268 run: options.run.into(),
1269 filters,
1270 tests: page,
1271 }),
1272 pagination(options.offset, options.limit, returned, total),
1273 ));
1274 }
1275 let test = matches.into_iter().next().expect("one test match");
1276 let metadata = index
1277 .hit_metadata(options.view)?
1278 .into_iter()
1279 .map(|metadata| (metadata.id.clone(), metadata))
1280 .collect::<HashMap<_, _>>();
1281 let decisions = index
1282 .decision_metadata(options.view)?
1283 .into_iter()
1284 .map(|decision| (decision.id.clone(), decision))
1285 .collect::<HashMap<_, _>>();
1286 let all_phases = index
1287 .phase_summaries(options.view)?
1288 .into_iter()
1289 .filter(|phase| phase.test == test.summary.id)
1290 .map(|phase| CoverageTestPhase {
1291 id: phase.id,
1292 kind: phase.kind,
1293 operation: phase.operation,
1294 source: phase.source,
1295 status: phase.status,
1296 caused_by_phase_id: phase.caused_by_phase_id,
1297 lines: phase.lines,
1298 decisions: phase.decisions,
1299 })
1300 .collect::<Vec<_>>();
1301 let totals = CoverageTestTotals {
1302 lines: test.lines.len(),
1303 hits: test.hits.len(),
1304 decisions: test.decisions.len(),
1305 phases: all_phases.len(),
1306 };
1307 let total = totals
1308 .lines
1309 .max(totals.hits)
1310 .max(totals.decisions)
1311 .max(totals.phases);
1312 let lines = test
1313 .lines
1314 .iter()
1315 .skip(options.offset)
1316 .take(options.limit)
1317 .cloned()
1318 .collect::<Vec<_>>();
1319 let hits = test
1320 .hits
1321 .iter()
1322 .skip(options.offset)
1323 .take(options.limit)
1324 .cloned()
1325 .collect::<Vec<_>>();
1326 let hit_details = test
1327 .hits
1328 .iter()
1329 .skip(options.offset)
1330 .take(options.limit)
1331 .map(|id| hit_detail(id, metadata.get(id)))
1332 .collect::<Vec<_>>();
1333 let test_decisions = test
1334 .decisions
1335 .iter()
1336 .skip(options.offset)
1337 .take(options.limit)
1338 .map(|decision| {
1339 Ok(CoverageTestDecision {
1340 id: decision.id.clone(),
1341 vectors: decision.vectors.clone(),
1342 meta: decisions
1343 .get(&decision.id)
1344 .cloned()
1345 .ok_or(QueryError::InvalidRecordSelection)?,
1346 })
1347 })
1348 .collect::<Result<Vec<_>, QueryError>>()?;
1349 let phases = all_phases
1350 .into_iter()
1351 .skip(options.offset)
1352 .take(options.limit)
1353 .collect::<Vec<_>>();
1354 let returned = lines
1355 .len()
1356 .max(hits.len())
1357 .max(test_decisions.len())
1358 .max(phases.len());
1359 Ok((
1360 CoverageTestData::Detail(CoverageTestDetailData {
1361 run: options.run.into(),
1362 filters,
1363 pagination_applies_to:
1364 "lines, hits/hitDetails, decisions, and phases independently within the test".into(),
1365 tests: vec![CoverageSelectedTest {
1366 id: test.summary.id,
1367 name: test.summary.name,
1368 file: test.summary.file,
1369 title: test.summary.title,
1370 retries: test.retries,
1371 attempts: test.attempts,
1372 outcome: test.summary.outcome,
1373 provenance: test.summary.provenance,
1374 role: test.summary.role,
1375 attribution: test.summary.attribution,
1376 hits,
1377 decisions: test_decisions,
1378 lines,
1379 hit_details,
1380 phases,
1381 totals,
1382 }],
1383 }),
1384 pagination(options.offset, options.limit, returned, total),
1385 ))
1386}
1387
1388#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1389pub struct CoverageDecisionMatch {
1390 pub id: String,
1391 pub file: String,
1392 pub line: usize,
1393 pub column: usize,
1394 pub source: String,
1395}
1396
1397#[derive(Debug, Clone, PartialEq, Serialize)]
1398#[serde(rename_all = "camelCase")]
1399pub struct CoverageDecisionCondition {
1400 pub index: usize,
1401 pub source: String,
1402 pub covered: bool,
1403 #[serde(skip_serializing_if = "Option::is_none")]
1404 pub assertion_covered: Option<bool>,
1405 #[serde(skip_serializing_if = "Option::is_none")]
1406 pub witness: Option<[crate::coverage_analysis::McdcVector; 2]>,
1407 #[serde(skip_serializing_if = "Option::is_none")]
1408 pub witness_tests: Option<[Vec<Id>; 2]>,
1409}
1410
1411#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1412#[serde(rename_all = "camelCase")]
1413pub struct CoverageDecisionTotals {
1414 pub conditions: usize,
1415 pub vector_observations: usize,
1416 pub tests: usize,
1417}
1418
1419#[derive(Debug, Clone, PartialEq, Serialize)]
1420#[serde(rename_all = "camelCase")]
1421pub struct CoverageSelectedDecision {
1422 pub meta: DecisionMeta,
1423 pub executed: bool,
1424 pub covered: bool,
1425 pub vectors: Vec<crate::coverage_analysis::McdcVector>,
1426 pub vector_observations: Vec<crate::coverage_report::VectorObservation>,
1427 pub conditions: Vec<CoverageDecisionCondition>,
1428 pub tests: Vec<Id>,
1429 pub confidence: CoverageConfidence,
1430 pub totals: CoverageDecisionTotals,
1431}
1432
1433#[derive(Debug, Clone, PartialEq, Serialize)]
1434pub struct CoverageDecisionMatchesData {
1435 pub run: String,
1436 pub filters: CoverageQueryFilters,
1437 pub decisions: Vec<CoverageDecisionMatch>,
1438}
1439
1440#[derive(Debug, Clone, PartialEq, Serialize)]
1441#[serde(rename_all = "camelCase")]
1442pub struct CoverageDecisionDetailData {
1443 pub run: String,
1444 pub filters: CoverageQueryFilters,
1445 pub pagination_applies_to: String,
1446 pub decisions: Vec<CoverageSelectedDecision>,
1447}
1448
1449#[derive(Debug, Clone, PartialEq, Serialize)]
1450#[serde(untagged)]
1451pub enum CoverageDecisionData {
1452 Matches(CoverageDecisionMatchesData),
1453 Detail(CoverageDecisionDetailData),
1454}
1455
1456#[derive(Debug, Clone, Copy)]
1457pub struct CoverageDecisionQueryOptions<'a> {
1458 pub run: &'a str,
1459 pub view: CoverageViewId,
1460 pub kind: Option<&'a str>,
1461 pub runner: Option<&'a str>,
1462 pub selector: &'a str,
1463 pub offset: usize,
1464 pub limit: usize,
1465}
1466
1467fn selector_location(selector: &str) -> Option<(&str, usize, Option<usize>)> {
1470 let (prefix, last) = selector.rsplit_once(':')?;
1471 let last = last.parse::<usize>().ok()?;
1472 if let Some((file, possible_line)) = prefix.rsplit_once(':')
1473 && let Ok(line) = possible_line.parse::<usize>()
1474 {
1475 return Some((file, line, Some(last)));
1476 }
1477 Some((prefix, last, None))
1478}
1479
1480fn selected_decision(
1481 decision: crate::coverage_report::DecisionResult,
1482 selected: Option<&BTreeSet<String>>,
1483) -> crate::coverage_report::DecisionResult {
1484 let Some(selected) = selected else {
1485 return decision;
1486 };
1487 let vector_observations = decision
1488 .vector_observations
1489 .into_iter()
1490 .filter_map(|mut observation| {
1491 observation
1492 .tests
1493 .retain(|test| selected.contains(test.as_str()));
1494 (!observation.tests.is_empty()).then_some(observation)
1495 })
1496 .collect::<Vec<_>>();
1497 let vectors = vector_observations
1498 .iter()
1499 .map(|observation| observation.vector.clone())
1500 .collect::<Vec<_>>();
1501 let conditions = decision
1502 .meta
1503 .conditions
1504 .iter()
1505 .enumerate()
1506 .map(|(index, source)| {
1507 let mut witness = None;
1508 let mut witness_tests = None;
1509 'pairs: for left in 0..vector_observations.len() {
1510 for right in (left + 1)..vector_observations.len() {
1511 let first = &vector_observations[left];
1512 let second = &vector_observations[right];
1513 if is_independence_pair(&first.vector, &second.vector, index) {
1514 witness = Some([first.vector.clone(), second.vector.clone()]);
1515 witness_tests = Some([first.tests.clone(), second.tests.clone()]);
1516 break 'pairs;
1517 }
1518 }
1519 }
1520 crate::coverage_report::ConditionResult {
1521 index,
1522 source: source.clone(),
1523 covered: witness.is_some(),
1524 assertion_covered: false,
1525 witness,
1526 witness_tests,
1527 }
1528 })
1529 .collect::<Vec<_>>();
1530 crate::coverage_report::DecisionResult {
1531 meta: decision.meta,
1532 executed: !vectors.is_empty(),
1533 covered: conditions.iter().all(|condition| condition.covered),
1534 vectors,
1535 vector_observations,
1536 conditions,
1537 tests: decision
1538 .tests
1539 .into_iter()
1540 .filter(|test| selected.contains(test.as_str()))
1541 .collect(),
1542 confidence: decision.confidence,
1543 }
1544}
1545
1546pub fn coverage_decision_query(
1547 index: &CoverageIndex<'_>,
1548 options: CoverageDecisionQueryOptions<'_>,
1549) -> Result<(CoverageDecisionData, AgentPagination), QueryError> {
1550 if options.limit == 0 {
1551 return Err(QueryError::InvalidPagination);
1552 }
1553 let tests = index.test_summaries(options.view)?;
1554 let selected = selected_test_ids(&tests, options.kind, options.runner)?;
1555 let decisions = index.decision_details(options.view)?;
1556 let mut matches = decisions
1557 .into_iter()
1558 .filter(|decision| decision.meta.id == options.selector)
1559 .collect::<Vec<_>>();
1560 if matches.is_empty()
1561 && let Some((file, line, column)) = selector_location(options.selector)
1562 {
1563 matches = index
1564 .decision_details(options.view)?
1565 .into_iter()
1566 .filter(|decision| {
1567 decision.meta.file == file
1568 && decision.meta.line == line
1569 && column.is_none_or(|column| decision.meta.column == column)
1570 })
1571 .collect();
1572 }
1573 if matches.is_empty() {
1574 return Err(QueryError::DecisionNotFound(options.selector.into()));
1575 }
1576 let filters = query_filters(options.view, options.kind, options.runner);
1577 if matches.len() > 1 {
1578 let total = matches.len();
1579 let page = matches
1580 .into_iter()
1581 .skip(options.offset)
1582 .take(options.limit)
1583 .map(|decision| CoverageDecisionMatch {
1584 id: decision.meta.id,
1585 file: decision.meta.file,
1586 line: decision.meta.line,
1587 column: decision.meta.column,
1588 source: decision.meta.source,
1589 })
1590 .collect::<Vec<_>>();
1591 let returned = page.len();
1592 return Ok((
1593 CoverageDecisionData::Matches(CoverageDecisionMatchesData {
1594 run: options.run.into(),
1595 filters,
1596 decisions: page,
1597 }),
1598 pagination(options.offset, options.limit, returned, total),
1599 ));
1600 }
1601 let filtered = selected_decision(
1602 matches.into_iter().next().expect("one decision match"),
1603 selected.as_ref(),
1604 );
1605 let totals = CoverageDecisionTotals {
1606 conditions: filtered.conditions.len(),
1607 vector_observations: filtered.vector_observations.len(),
1608 tests: filtered.tests.len(),
1609 };
1610 let total = totals
1611 .conditions
1612 .max(totals.vector_observations)
1613 .max(totals.tests);
1614 let vector_observations = filtered
1615 .vector_observations
1616 .iter()
1617 .skip(options.offset)
1618 .take(options.limit)
1619 .cloned()
1620 .collect::<Vec<_>>();
1621 let vectors = vector_observations
1622 .iter()
1623 .map(|observation| observation.vector.clone())
1624 .collect::<Vec<_>>();
1625 let conditions = filtered
1626 .conditions
1627 .iter()
1628 .skip(options.offset)
1629 .take(options.limit)
1630 .map(|condition| CoverageDecisionCondition {
1631 index: condition.index,
1632 source: condition.source.clone(),
1633 covered: condition.covered,
1634 assertion_covered: selected.is_none().then_some(condition.assertion_covered),
1635 witness: condition.witness.clone(),
1636 witness_tests: condition.witness_tests.clone(),
1637 })
1638 .collect::<Vec<_>>();
1639 let tests = filtered
1640 .tests
1641 .iter()
1642 .skip(options.offset)
1643 .take(options.limit)
1644 .cloned()
1645 .collect::<Vec<_>>();
1646 let returned = vector_observations
1647 .len()
1648 .max(conditions.len())
1649 .max(tests.len());
1650 Ok((
1651 CoverageDecisionData::Detail(CoverageDecisionDetailData {
1652 run: options.run.into(),
1653 filters,
1654 pagination_applies_to:
1655 "conditions, vectorObservations, and tests independently within each decision"
1656 .into(),
1657 decisions: vec![CoverageSelectedDecision {
1658 meta: filtered.meta,
1659 executed: filtered.executed,
1660 covered: filtered.covered,
1661 vectors,
1662 vector_observations,
1663 conditions,
1664 tests,
1665 confidence: filtered.confidence,
1666 totals,
1667 }],
1668 }),
1669 pagination(options.offset, options.limit, returned, total),
1670 ))
1671}
1672
1673#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1674pub struct CoverageOtherTest {
1675 pub id: String,
1676 pub name: String,
1677}
1678
1679#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1680#[serde(rename_all = "camelCase")]
1681pub struct CoverageOtherCoverage {
1682 pub covered_elsewhere: bool,
1683 pub kinds: Vec<String>,
1684 pub runners: Vec<String>,
1685 pub tests: Vec<CoverageOtherTest>,
1686}
1687
1688#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1689#[serde(rename_all = "camelCase")]
1690pub struct CoverageLineObligation {
1691 pub kind: String,
1692 pub id: String,
1693 pub line: usize,
1694 pub other_coverage: CoverageOtherCoverage,
1695}
1696
1697#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1698#[serde(rename_all = "camelCase")]
1699pub struct CoveragePointObligation {
1700 pub kind: String,
1701 pub id: String,
1702 pub line: usize,
1703 pub column: usize,
1704 pub source: String,
1705 pub other_coverage: CoverageOtherCoverage,
1706}
1707
1708#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1709#[serde(rename_all = "camelCase")]
1710pub struct CoverageBranchObligation {
1711 pub kind: String,
1712 pub id: String,
1713 pub line: usize,
1714 pub column: usize,
1715 pub source: String,
1716 pub missing: String,
1717 pub other_coverage: CoverageOtherCoverage,
1718}
1719
1720#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1721#[serde(rename_all = "camelCase")]
1722pub struct CoverageMcdcObligation {
1723 pub kind: String,
1724 pub id: String,
1725 pub line: usize,
1726 pub column: usize,
1727 pub decision: String,
1728 pub missing_condition: String,
1729 #[serde(skip)]
1730 pub condition_index: usize,
1731 pub observed_vectors: Vec<String>,
1732 pub needed_vector: Option<String>,
1737 pub needs: String,
1739 pub other_coverage: CoverageOtherCoverage,
1740}
1741
1742#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1743#[serde(untagged)]
1744pub enum CoverageFileObligation {
1745 Line(CoverageLineObligation),
1746 Point(CoveragePointObligation),
1747 Branch(CoverageBranchObligation),
1748 Mcdc(CoverageMcdcObligation),
1749}
1750
1751impl CoverageFileObligation {
1752 fn line(&self) -> usize {
1753 match self {
1754 Self::Line(value) => value.line,
1755 Self::Point(value) => value.line,
1756 Self::Branch(value) => value.line,
1757 Self::Mcdc(value) => value.line,
1758 }
1759 }
1760
1761 fn kind(&self) -> &str {
1762 match self {
1763 Self::Line(value) => &value.kind,
1764 Self::Point(value) => &value.kind,
1765 Self::Branch(value) => &value.kind,
1766 Self::Mcdc(value) => &value.kind,
1767 }
1768 }
1769}
1770
1771#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1772pub struct CoverageFileLimitation {
1773 pub id: String,
1774 pub kind: String,
1775 pub file: String,
1776 pub line: usize,
1777 pub column: usize,
1778 pub source: String,
1779 pub reason: String,
1780 pub blocking: bool,
1781 pub effect: String,
1782}
1783
1784#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1785#[serde(rename_all = "camelCase")]
1786pub struct CoverageFileCounts {
1787 pub total_lines: usize,
1790 pub covered_lines: usize,
1791 pub uncovered_lines: usize,
1792 pub uncovered_statements: usize,
1793 pub uncovered_functions: usize,
1794 pub missing_branches: usize,
1795 pub missing_mcdc_conditions: usize,
1796 pub measurement_limitations: usize,
1797}
1798
1799#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1800#[serde(rename_all = "camelCase")]
1801pub struct CoverageFileGapLine {
1802 pub line: usize,
1803 pub state: String,
1804 #[serde(skip_serializing_if = "Option::is_none")]
1805 pub source: Option<String>,
1806 pub obligations: Vec<CoverageFileObligation>,
1807 pub limitations: Vec<CoverageFileLimitation>,
1808}
1809
1810#[derive(Debug, Clone, PartialEq, Serialize)]
1811#[serde(rename_all = "camelCase")]
1812pub struct CoverageFileDetailData {
1813 pub run: String,
1814 pub filters: CoverageQueryFilters,
1815 pub file: String,
1816 pub metric: MinimizeMetric,
1817 pub counts: CoverageFileCounts,
1818 pub total_tests: usize,
1819 pub total_obligations: usize,
1820 pub total_gap_lines: usize,
1821 pub gap_lines: Vec<CoverageFileGapLine>,
1822 pub total_limitations: usize,
1823 #[serde(skip_serializing_if = "Option::is_none")]
1826 pub assertions: Option<serde_json::Value>,
1827}
1828
1829#[derive(Debug, Clone, Copy)]
1830pub struct CoverageFileDetailOptions<'a> {
1831 pub run: &'a str,
1832 pub view: CoverageViewId,
1833 pub kind: Option<&'a str>,
1834 pub runner: Option<&'a str>,
1835 pub selector: &'a str,
1836 pub metric: MinimizeMetric,
1837 pub offset: usize,
1838 pub limit: usize,
1839}
1840
1841fn other_coverage<T: AsRef<str>>(
1842 test_ids: &[T],
1843 selected: Option<&BTreeSet<String>>,
1844 tests: &HashMap<String, IndexedTestSummary>,
1845) -> CoverageOtherCoverage {
1846 let covered = selected.map_or_else(Vec::new, |selected| {
1847 test_ids
1848 .iter()
1849 .filter(|id| !selected.contains(id.as_ref()))
1850 .filter_map(|id| tests.get(id.as_ref()))
1851 .collect::<Vec<_>>()
1852 });
1853 CoverageOtherCoverage {
1854 covered_elsewhere: !covered.is_empty(),
1855 kinds: covered
1856 .iter()
1857 .map(|test| test.provenance.kind.clone())
1858 .collect::<BTreeSet<_>>()
1859 .into_iter()
1860 .collect(),
1861 runners: covered
1862 .iter()
1863 .map(|test| test.provenance.runner.clone())
1864 .collect::<BTreeSet<_>>()
1865 .into_iter()
1866 .collect(),
1867 tests: covered
1868 .into_iter()
1869 .map(|test| CoverageOtherTest {
1870 id: test.id.clone(),
1871 name: test.name.clone(),
1872 })
1873 .collect(),
1874 }
1875}
1876
1877fn needed(
1881 index: usize,
1882 observed: &[&crate::coverage_analysis::McdcVector],
1883 sources: &[&str],
1884) -> (Option<String>, String) {
1885 let condition = sources.get(index).copied().unwrap_or("the condition");
1886 let Some(seen) = observed
1887 .iter()
1888 .find(|vector| vector.values.get(index).copied().flatten().is_some())
1889 else {
1890 return (None, format!("no test evaluated `{condition}`"));
1891 };
1892 let mut values = String::new();
1893 let mut words = Vec::new();
1894 for (position, value) in seen.values.iter().enumerate() {
1895 let value = match position.cmp(&index) {
1896 std::cmp::Ordering::Less => *value,
1897 std::cmp::Ordering::Equal => value.map(|value| !value),
1898 std::cmp::Ordering::Greater => None,
1899 };
1900 values.push(match value {
1901 None => '-',
1902 Some(false) => 'F',
1903 Some(true) => 'T',
1904 });
1905 if let (Some(value), Some(source)) = (value, sources.get(position)) {
1906 words.push(format!("`{source}` {value}"));
1907 }
1908 }
1909 let outcome = !seen.outcome;
1910 words.push(format!("decision {outcome}"));
1911 (
1912 Some(format!("{values} -> {}", if outcome { 'T' } else { 'F' })),
1913 words.join(", "),
1914 )
1915}
1916
1917fn vector_text(vector: &crate::coverage_analysis::McdcVector) -> String {
1918 let values = vector
1919 .values
1920 .iter()
1921 .map(|value| match value {
1922 None => '-',
1923 Some(false) => 'F',
1924 Some(true) => 'T',
1925 })
1926 .collect::<String>();
1927 format!("{values} -> {}", if vector.outcome { 'T' } else { 'F' })
1928}
1929
1930fn obligation_matches_metric(obligation: &CoverageFileObligation, metric: MinimizeMetric) -> bool {
1931 metric == MinimizeMetric::All
1932 || matches!(
1933 (obligation.kind(), metric),
1934 ("line", MinimizeMetric::Lines)
1935 | ("statement", MinimizeMetric::Statements)
1936 | ("function", MinimizeMetric::Functions)
1937 | ("branch", MinimizeMetric::Branches)
1938 | ("mcdc", MinimizeMetric::Mcdc)
1939 )
1940}
1941
1942fn compact_source(value: &str) -> Option<String> {
1943 let line = value.lines().find(|line| !line.trim().is_empty())?.trim();
1944 let compact = line.split_whitespace().collect::<Vec<_>>().join(" ");
1945 if compact.is_empty() {
1946 None
1947 } else if compact.chars().count() > 120 {
1948 Some(format!(
1949 "{}…",
1950 compact.chars().take(119).collect::<String>()
1951 ))
1952 } else {
1953 Some(compact)
1954 }
1955}
1956
1957fn obligation_source(obligation: &CoverageFileObligation) -> Option<String> {
1958 match obligation {
1959 CoverageFileObligation::Line(_) => None,
1960 CoverageFileObligation::Point(value) => compact_source(&value.source),
1961 CoverageFileObligation::Branch(value) => compact_source(&value.source),
1962 CoverageFileObligation::Mcdc(value) => compact_source(&value.decision),
1963 }
1964}
1965
1966pub struct FileDetailSource {
1974 view: CoverageViewId,
1975 tests: Vec<crate::coverage_index::IndexedTestDetail>,
1976 test_summaries: Vec<IndexedTestSummary>,
1977 tests_by_id: HashMap<String, IndexedTestSummary>,
1978 tests_by_file: HashMap<String, Vec<String>>,
1980 lines: Vec<crate::coverage_index::IndexedLine>,
1981 lines_by_file: BTreeMap<String, Vec<usize>>,
1982 points: Vec<crate::coverage_index::IndexedHitMetadata>,
1983 points_by_file: HashMap<String, Vec<usize>>,
1984 decisions: Vec<crate::coverage_report::DecisionResult>,
1985 decisions_by_file: HashMap<String, Vec<usize>>,
1986 limitations: BTreeMap<String, Vec<crate::coverage_index::IndexedLimitation>>,
1987}
1988
1989impl FileDetailSource {
1990 pub fn read_file(
1994 index: &CoverageIndex<'_>,
1995 view: CoverageViewId,
1996 file: &str,
1997 ) -> Result<Self, QueryError> {
1998 let test_summaries = index.test_summaries(view)?;
1999 let tests_by_id = test_summaries
2000 .iter()
2001 .map(|test| (test.id.clone(), test.clone()))
2002 .collect();
2003 let lines = index.lines_in_file(view, file)?;
2004 let points = index.hit_metadata_in_file(view, file)?;
2005 let decisions = index.decision_details_in_file(view, file)?;
2006 let limitations = index.limitations_in_file(view, file)?;
2007 let mut source = Self {
2008 view,
2009 tests: Vec::new(),
2010 test_summaries,
2011 tests_by_id,
2012 tests_by_file: HashMap::new(),
2013 lines_by_file: BTreeMap::new(),
2014 points_by_file: HashMap::new(),
2015 decisions_by_file: HashMap::new(),
2016 limitations: BTreeMap::new(),
2017 lines,
2018 points,
2019 decisions,
2020 };
2021 if !source.lines.is_empty() {
2022 source
2023 .lines_by_file
2024 .insert(file.to_owned(), (0..source.lines.len()).collect());
2025 let mut tests = source
2028 .lines
2029 .iter()
2030 .flat_map(|line| line.tests.iter().cloned())
2031 .collect::<Vec<_>>();
2032 tests.sort_unstable();
2033 tests.dedup();
2034 source.tests_by_file.insert(file.to_owned(), tests);
2035 }
2036 if !source.points.is_empty() {
2037 source
2038 .points_by_file
2039 .insert(file.to_owned(), (0..source.points.len()).collect());
2040 }
2041 if !source.decisions.is_empty() {
2042 source
2043 .decisions_by_file
2044 .insert(file.to_owned(), (0..source.decisions.len()).collect());
2045 }
2046 if !limitations.is_empty() {
2047 source.limitations.insert(file.to_owned(), limitations);
2048 }
2049 Ok(source)
2050 }
2051
2052 pub fn read(index: &CoverageIndex<'_>, view: CoverageViewId) -> Result<Self, QueryError> {
2053 let tests = index.test_details(view)?;
2054 let mut tests_by_file = HashMap::<String, Vec<String>>::new();
2055 for test in &tests {
2056 let mut files = test
2057 .lines
2058 .iter()
2059 .map(|line| line.file.as_str())
2060 .collect::<Vec<_>>();
2061 files.sort_unstable();
2062 files.dedup();
2063 for file in files {
2064 tests_by_file
2065 .entry(file.to_owned())
2066 .or_default()
2067 .push(test.summary.id.clone());
2068 }
2069 }
2070 let test_summaries = tests
2071 .iter()
2072 .map(|test| test.summary.clone())
2073 .collect::<Vec<_>>();
2074 let tests_by_id = test_summaries
2075 .iter()
2076 .map(|test| (test.id.clone(), test.clone()))
2077 .collect();
2078 let lines = index.lines(view)?;
2079 let mut lines_by_file = BTreeMap::<String, Vec<usize>>::new();
2080 for (at, line) in lines.iter().enumerate() {
2081 lines_by_file.entry(line.file.clone()).or_default().push(at);
2082 }
2083 let points = index.hit_metadata(view)?;
2084 let mut points_by_file = HashMap::<String, Vec<usize>>::new();
2085 for (at, point) in points.iter().enumerate() {
2086 points_by_file
2087 .entry(point.file.clone())
2088 .or_default()
2089 .push(at);
2090 }
2091 let decisions = index.decision_details(view)?;
2092 let mut decisions_by_file = HashMap::<String, Vec<usize>>::new();
2093 for (at, decision) in decisions.iter().enumerate() {
2094 decisions_by_file
2095 .entry(decision.meta.file.clone())
2096 .or_default()
2097 .push(at);
2098 }
2099 let mut limitations = BTreeMap::<String, Vec<_>>::new();
2100 for limitation in index.limitations(view)? {
2101 limitations
2102 .entry(limitation.file.clone())
2103 .or_default()
2104 .push(limitation);
2105 }
2106 Ok(Self {
2107 view,
2108 tests,
2109 test_summaries,
2110 tests_by_id,
2111 tests_by_file,
2112 lines,
2113 lines_by_file,
2114 points,
2115 points_by_file,
2116 decisions,
2117 decisions_by_file,
2118 limitations,
2119 })
2120 }
2121
2122 pub fn tests(&self) -> &[crate::coverage_index::IndexedTestDetail] {
2125 &self.tests
2126 }
2127
2128 pub fn lines(&self) -> &[crate::coverage_index::IndexedLine] {
2129 &self.lines
2130 }
2131
2132 pub fn points(&self) -> &[crate::coverage_index::IndexedHitMetadata] {
2133 &self.points
2134 }
2135
2136 pub fn decisions(&self) -> &[crate::coverage_report::DecisionResult] {
2137 &self.decisions
2138 }
2139}
2140
2141pub fn coverage_file_detail_query(
2142 index: &CoverageIndex<'_>,
2143 options: CoverageFileDetailOptions<'_>,
2144) -> Result<(CoverageFileDetailData, AgentPagination), QueryError> {
2145 if options.limit == 0 {
2146 return Err(QueryError::InvalidPagination);
2147 }
2148 let scoped = FileDetailSource::read_file(index, options.view, options.selector)?;
2151 if !scoped.lines.is_empty() || !scoped.limitations.is_empty() {
2152 return coverage_file_detail_from(&scoped, options);
2153 }
2154 coverage_file_detail_from(&FileDetailSource::read(index, options.view)?, options)
2155}
2156
2157pub fn coverage_file_detail_from(
2160 read: &FileDetailSource,
2161 options: CoverageFileDetailOptions<'_>,
2162) -> Result<(CoverageFileDetailData, AgentPagination), QueryError> {
2163 if options.limit == 0 {
2164 return Err(QueryError::InvalidPagination);
2165 }
2166 debug_assert!(read.view == options.view);
2167 let selected = selected_test_ids(&read.test_summaries, options.kind, options.runner)?;
2168 let tests_by_id = &read.tests_by_id;
2169 let files = read
2170 .lines_by_file
2171 .keys()
2172 .chain(read.limitations.keys())
2173 .map(String::as_str)
2174 .collect::<BTreeSet<_>>();
2175 let file = if files.contains(options.selector) {
2176 options.selector.to_owned()
2177 } else {
2178 let matches = files
2179 .into_iter()
2180 .filter(|file| file.contains(options.selector))
2181 .collect::<Vec<_>>();
2182 if matches.is_empty() {
2183 return Err(QueryError::SourceNotFound(options.selector.into()));
2184 }
2185 if matches.len() != 1 {
2186 return Err(QueryError::AmbiguousSelector {
2187 selector: options.selector.into(),
2188 matches: matches.into_iter().map(str::to_owned).collect(),
2189 });
2190 }
2191 matches[0].to_owned()
2192 };
2193 let lines = read
2194 .lines_by_file
2195 .get(&file)
2196 .into_iter()
2197 .flatten()
2198 .map(|at| &read.lines[*at])
2199 .collect::<Vec<_>>();
2200 let metadata = read
2201 .points_by_file
2202 .get(&file)
2203 .into_iter()
2204 .flatten()
2205 .map(|at| &read.points[*at])
2206 .collect::<Vec<_>>();
2207 let selected_includes = |tests: &[String]| {
2208 selected.as_ref().map_or(!tests.is_empty(), |selected| {
2209 tests.iter().any(|test| selected.contains(test))
2210 })
2211 };
2212 let total_lines = lines.iter().filter(|line| line.measured).count();
2213 let uncovered_lines = lines
2214 .iter()
2215 .filter(|line| line.measured && !selected_includes(&line.tests))
2216 .map(|line| {
2217 CoverageFileObligation::Line(CoverageLineObligation {
2218 kind: "line".into(),
2219 id: format!("line:{}:{}", line.file, line.line),
2220 line: line.line,
2221 other_coverage: other_coverage(&line.tests, selected.as_ref(), tests_by_id),
2222 })
2223 })
2224 .collect::<Vec<_>>();
2225 let statements = metadata
2226 .iter()
2227 .filter(|point| point.obligation == "statement" && !selected_includes(&point.tests))
2228 .map(|point| {
2229 CoverageFileObligation::Point(CoveragePointObligation {
2230 kind: "statement".into(),
2231 id: point.id.clone(),
2232 line: point.line,
2233 column: point.column,
2234 source: point.label.clone().unwrap_or_else(|| point.source.clone()),
2235 other_coverage: other_coverage(&point.tests, selected.as_ref(), tests_by_id),
2236 })
2237 })
2238 .collect::<Vec<_>>();
2239 let functions = metadata
2240 .iter()
2241 .filter(|point| point.obligation == "function" && !selected_includes(&point.tests))
2242 .map(|point| {
2243 CoverageFileObligation::Point(CoveragePointObligation {
2244 kind: "function".into(),
2245 id: point.id.clone(),
2246 line: point.line,
2247 column: point.column,
2248 source: point.label.clone().unwrap_or_else(|| point.source.clone()),
2249 other_coverage: other_coverage(&point.tests, selected.as_ref(), tests_by_id),
2250 })
2251 })
2252 .collect::<Vec<_>>();
2253 let branches = metadata
2254 .iter()
2255 .filter(|branch| branch.obligation == "branch" && !selected_includes(&branch.tests))
2256 .map(|branch| {
2257 CoverageFileObligation::Branch(CoverageBranchObligation {
2258 kind: "branch".into(),
2259 id: branch.id.clone(),
2260 line: branch.line,
2261 column: branch.column,
2262 source: branch.source.clone(),
2263 missing: branch.alternative.clone().unwrap_or_default(),
2264 other_coverage: other_coverage(&branch.tests, selected.as_ref(), tests_by_id),
2265 })
2266 })
2267 .collect::<Vec<_>>();
2268 let observed_lines = metadata
2272 .iter()
2273 .filter(|point| selected_includes(&point.tests))
2274 .map(|point| point.line)
2275 .collect::<BTreeSet<_>>();
2276 let mut mcdc = Vec::new();
2277 for original in read
2278 .decisions_by_file
2279 .get(&file)
2280 .into_iter()
2281 .flatten()
2282 .map(|at| &read.decisions[*at])
2283 {
2284 let filtered = selected_decision(original.clone(), selected.as_ref());
2285 for condition in filtered
2286 .conditions
2287 .iter()
2288 .filter(|condition| !condition.covered)
2289 {
2290 let original_tests = original.conditions[condition.index]
2291 .witness_tests
2292 .clone()
2293 .unwrap_or_default()
2294 .into_iter()
2295 .flatten()
2296 .collect::<Vec<_>>();
2297 let observed = filtered
2298 .vector_observations
2299 .iter()
2300 .map(|observation| &observation.vector)
2301 .collect::<Vec<_>>();
2302 let sources = filtered
2303 .conditions
2304 .iter()
2305 .map(|condition| condition.source.as_str())
2306 .collect::<Vec<_>>();
2307 let (needed_vector, needs) = needed(condition.index, &observed, &sources);
2308 mcdc.push(CoverageFileObligation::Mcdc(CoverageMcdcObligation {
2309 kind: "mcdc".into(),
2310 id: original.meta.id.clone(),
2311 line: original.meta.line,
2312 column: original.meta.column,
2313 decision: original.meta.source.clone(),
2314 missing_condition: condition.source.clone(),
2315 condition_index: condition.index,
2316 observed_vectors: observed.iter().map(|vector| vector_text(vector)).collect(),
2317 needed_vector,
2318 needs,
2319 other_coverage: other_coverage(&original_tests, selected.as_ref(), tests_by_id),
2320 }));
2321 }
2322 }
2323 let mut obligations = uncovered_lines
2324 .iter()
2325 .chain(statements.iter())
2326 .chain(functions.iter())
2327 .chain(branches.iter())
2328 .chain(mcdc.iter())
2329 .filter(|obligation| obligation_matches_metric(obligation, options.metric))
2330 .cloned()
2331 .collect::<Vec<_>>();
2332 obligations.sort_by(|left, right| {
2333 left.line()
2334 .cmp(&right.line())
2335 .then_with(|| left.kind().cmp(right.kind()))
2336 });
2337 let mut limitations = read
2338 .limitations
2339 .get(&file)
2340 .into_iter()
2341 .flatten()
2342 .cloned()
2343 .map(|limitation| CoverageFileLimitation {
2344 id: limitation.id,
2345 kind: limitation.kind,
2346 file: limitation.file,
2347 line: limitation.line,
2348 column: limitation.column,
2349 source: limitation.source,
2350 reason: limitation.reason,
2351 blocking: limitation.blocking,
2352 effect: "outside-measured-denominator".into(),
2353 })
2354 .collect::<Vec<_>>();
2355 limitations.sort_by(|left, right| {
2356 left.line
2357 .cmp(&right.line)
2358 .then_with(|| left.column.cmp(&right.column))
2359 .then_with(|| left.id.cmp(&right.id))
2360 });
2361 let total_tests = read
2362 .tests_by_file
2363 .get(&file)
2364 .into_iter()
2365 .flatten()
2366 .filter(|test| {
2367 selected
2368 .as_ref()
2369 .is_none_or(|selected| selected.contains(*test))
2370 })
2371 .count();
2372 let total_obligations = obligations.len();
2373 let total_limitations = limitations.len();
2374 let mut grouped =
2375 BTreeMap::<usize, (Vec<CoverageFileObligation>, Vec<CoverageFileLimitation>)>::new();
2376 for obligation in obligations {
2377 grouped
2378 .entry(obligation.line())
2379 .or_default()
2380 .0
2381 .push(obligation);
2382 }
2383 for limitation in limitations {
2384 grouped
2385 .entry(limitation.line)
2386 .or_default()
2387 .1
2388 .push(limitation);
2389 }
2390 let gap_lines = grouped
2391 .into_iter()
2392 .map(|(line, (obligations, limitations))| {
2393 let source = obligations.iter().find_map(obligation_source).or_else(|| {
2394 limitations
2395 .iter()
2396 .find_map(|value| compact_source(&value.source))
2397 });
2398 let never_ran = !observed_lines.contains(&line)
2402 && !obligations.is_empty()
2403 && obligations
2404 .iter()
2405 .all(|value| !matches!(value, CoverageFileObligation::Mcdc(_)));
2406 let state = if never_ran
2407 || obligations
2408 .iter()
2409 .any(|value| matches!(value, CoverageFileObligation::Line(_)))
2410 {
2411 "missing"
2412 } else if !obligations.is_empty() || observed_lines.contains(&line) {
2413 "part"
2416 } else {
2417 "limited"
2418 };
2419 CoverageFileGapLine {
2420 line,
2421 state: state.into(),
2422 source,
2423 obligations,
2424 limitations,
2425 }
2426 })
2427 .collect::<Vec<_>>();
2428 let total_gap_lines = gap_lines.len();
2429 let selected_gap_lines = gap_lines
2430 .into_iter()
2431 .skip(options.offset)
2432 .take(options.limit)
2433 .collect::<Vec<_>>();
2434 let returned = selected_gap_lines.len();
2435 Ok((
2436 CoverageFileDetailData {
2437 run: options.run.into(),
2438 filters: query_filters(options.view, options.kind, options.runner),
2439 file,
2440 metric: options.metric,
2441 counts: CoverageFileCounts {
2442 total_lines,
2443 covered_lines: total_lines - uncovered_lines.len(),
2444 uncovered_lines: uncovered_lines.len(),
2445 uncovered_statements: statements.len(),
2446 uncovered_functions: functions.len(),
2447 missing_branches: branches.len(),
2448 missing_mcdc_conditions: mcdc.len(),
2449 measurement_limitations: total_limitations,
2450 },
2451 total_tests,
2452 total_obligations,
2453 total_gap_lines,
2454 gap_lines: selected_gap_lines,
2455 total_limitations,
2456 assertions: None,
2457 },
2458 pagination(options.offset, options.limit, returned, total_gap_lines),
2459 ))
2460}
2461
2462#[derive(Debug, Clone, PartialEq, Serialize)]
2463pub struct CoverageDiffDelta {
2464 #[serde(serialize_with = "crate::coverage_analysis::serialize_javascript_number")]
2465 pub lines: f64,
2466 #[serde(serialize_with = "crate::coverage_analysis::serialize_javascript_number")]
2467 pub branches: f64,
2468 #[serde(serialize_with = "crate::coverage_analysis::serialize_javascript_number")]
2469 pub mcdc: f64,
2470}
2471
2472#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
2473#[serde(rename_all = "camelCase")]
2474pub struct CoverageDiffSide {
2475 pub line_count: usize,
2476 pub branch_count: usize,
2477 pub mcdc_count: usize,
2478 pub lines: Vec<String>,
2479 pub branches: Vec<String>,
2480 pub mcdc: Vec<String>,
2481}
2482
2483#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
2485#[serde(rename_all = "camelCase")]
2486pub struct CoverageDiffFile {
2487 pub file: String,
2488 pub lost: CoverageDiffCounts,
2489 pub gained: CoverageDiffCounts,
2490}
2491
2492#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
2493pub struct CoverageDiffCounts {
2494 pub lines: usize,
2495 pub branches: usize,
2496 pub mcdc: usize,
2497}
2498
2499impl CoverageDiffCounts {
2500 fn total(&self) -> usize {
2501 self.lines + self.branches + self.mcdc
2502 }
2503}
2504
2505const DIFF_FILES: usize = 20;
2507
2508#[derive(Debug, Clone, PartialEq, Serialize)]
2509#[serde(rename_all = "camelCase")]
2510pub struct CoverageDiffData {
2511 pub filters: CoverageQueryFilters,
2512 pub older: String,
2513 pub newer: String,
2514 pub delta: CoverageDiffDelta,
2515 pub gained: CoverageDiffSide,
2516 pub lost: CoverageDiffSide,
2517 pub files: usize,
2521 pub by_file: Vec<CoverageDiffFile>,
2522 #[serde(skip_serializing_if = "Option::is_none")]
2525 pub inputs: Option<CoverageDiffInputs>,
2526 #[serde(skip_serializing_if = "std::ops::Not::not")]
2528 pub gained_only: bool,
2529}
2530
2531#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
2535#[serde(rename_all = "camelCase")]
2536pub struct CoverageDiffInputs {
2537 pub source_changed: bool,
2539 pub tests_changed: bool,
2540}
2541
2542#[derive(Debug, Clone, Copy)]
2543pub struct CoverageDiffQueryOptions<'a> {
2544 pub older_run: &'a str,
2545 pub newer_run: &'a str,
2546 pub view: CoverageViewId,
2547 pub kind: Option<&'a str>,
2548 pub runner: Option<&'a str>,
2549 pub offset: usize,
2550 pub limit: usize,
2551 pub gained_only: bool,
2555}
2556
2557struct DiffSnapshot {
2558 summary: CoverageSummary,
2559 lines: BTreeSet<String>,
2560 branches: HashMap<String, String>,
2561 mcdc: HashMap<String, String>,
2562}
2563
2564fn diff_snapshot(
2565 index: &CoverageIndex<'_>,
2566 view: CoverageViewId,
2567) -> Result<DiffSnapshot, QueryError> {
2568 let summary = index.summary(view)?;
2569 let lines = index
2570 .lines(view)?
2571 .into_iter()
2572 .filter(|line| line.covered)
2573 .map(|line| format!("{}:{}", line.file, line.line))
2574 .collect();
2575 let branches = index
2576 .hit_metadata(view)?
2577 .into_iter()
2578 .filter(|metadata| metadata.obligation == "branch" && !metadata.tests.is_empty())
2579 .map(|metadata| {
2580 let parent = metadata
2581 .parent_id
2582 .ok_or(QueryError::InvalidRecordSelection)?;
2583 Ok((
2584 format!("{parent}:{}", metadata.id),
2585 format!(
2586 "{}:{} {}",
2587 metadata.file,
2588 metadata.line,
2589 metadata.alternative.unwrap_or_default()
2590 ),
2591 ))
2592 })
2593 .collect::<Result<HashMap<_, _>, QueryError>>()?;
2594 let mcdc = index
2595 .decision_details(view)?
2596 .into_iter()
2597 .flat_map(|decision| {
2598 decision
2599 .conditions
2600 .into_iter()
2601 .filter(|condition| condition.covered)
2602 .map(move |condition| {
2603 (
2604 format!("{}:c{}", decision.meta.id, condition.index),
2605 format!(
2606 "{}:{} C{} {}",
2607 decision.meta.file,
2608 decision.meta.line,
2609 condition.index + 1,
2610 condition.source
2611 ),
2612 )
2613 })
2614 })
2615 .collect();
2616 Ok(DiffSnapshot {
2617 summary,
2618 lines,
2619 branches,
2620 mcdc,
2621 })
2622}
2623
2624fn js_string_cmp(left: &str, right: &str) -> std::cmp::Ordering {
2625 left.encode_utf16().cmp(right.encode_utf16())
2626}
2627
2628fn rounded_delta(newer: f64, older: f64) -> f64 {
2629 ((newer - older) * 100.0).round() / 100.0
2630}
2631
2632pub fn coverage_diff_query(
2633 older: &CoverageIndex<'_>,
2634 newer: &CoverageIndex<'_>,
2635 options: CoverageDiffQueryOptions<'_>,
2636) -> Result<(CoverageDiffData, AgentPagination), QueryError> {
2637 if options.limit == 0 {
2638 return Err(QueryError::InvalidPagination);
2639 }
2640 let older = diff_snapshot(older, options.view)?;
2641 let newer = diff_snapshot(newer, options.view)?;
2642 let mut gained_lines = newer
2643 .lines
2644 .difference(&older.lines)
2645 .cloned()
2646 .collect::<Vec<_>>();
2647 let mut lost_lines = older
2648 .lines
2649 .difference(&newer.lines)
2650 .cloned()
2651 .collect::<Vec<_>>();
2652 let mut gained_branches = newer
2653 .branches
2654 .iter()
2655 .filter(|(id, _)| !older.branches.contains_key(*id))
2656 .map(|(_, label)| label.clone())
2657 .collect::<Vec<_>>();
2658 let mut lost_branches = older
2659 .branches
2660 .iter()
2661 .filter(|(id, _)| !newer.branches.contains_key(*id))
2662 .map(|(_, label)| label.clone())
2663 .collect::<Vec<_>>();
2664 let mut gained_mcdc = newer
2665 .mcdc
2666 .iter()
2667 .filter(|(id, _)| !older.mcdc.contains_key(*id))
2668 .map(|(_, label)| label.clone())
2669 .collect::<Vec<_>>();
2670 let mut lost_mcdc = older
2671 .mcdc
2672 .iter()
2673 .filter(|(id, _)| !newer.mcdc.contains_key(*id))
2674 .map(|(_, label)| label.clone())
2675 .collect::<Vec<_>>();
2676 if options.gained_only {
2677 lost_lines.clear();
2678 lost_branches.clear();
2679 lost_mcdc.clear();
2680 }
2681 for values in [
2682 &mut gained_lines,
2683 &mut lost_lines,
2684 &mut gained_branches,
2685 &mut lost_branches,
2686 &mut gained_mcdc,
2687 &mut lost_mcdc,
2688 ] {
2689 values.sort_by(|left, right| js_string_cmp(left, right));
2690 }
2691 let mut by_file = BTreeMap::<&str, (CoverageDiffCounts, CoverageDiffCounts)>::new();
2693 for (values, count) in [
2694 (&lost_lines, 0),
2695 (&lost_branches, 1),
2696 (&lost_mcdc, 2),
2697 (&gained_lines, 3),
2698 (&gained_branches, 4),
2699 (&gained_mcdc, 5),
2700 ] {
2701 for label in values {
2702 let place = label.split(' ').next().unwrap_or(label);
2703 let file = place.rsplit_once(':').map_or(place, |(file, _)| file);
2704 let (lost, gained) = by_file.entry(file).or_default();
2705 let side = if count < 3 { lost } else { gained };
2706 match count % 3 {
2707 0 => side.lines += 1,
2708 1 => side.branches += 1,
2709 _ => side.mcdc += 1,
2710 }
2711 }
2712 }
2713 let files = by_file.len();
2714 let mut by_file = by_file
2715 .into_iter()
2716 .map(|(file, (lost, gained))| CoverageDiffFile {
2717 file: file.to_owned(),
2718 lost,
2719 gained,
2720 })
2721 .collect::<Vec<_>>();
2722 by_file.sort_by(|left, right| {
2723 (right.lost.total(), right.gained.total())
2724 .cmp(&(left.lost.total(), left.gained.total()))
2725 .then_with(|| js_string_cmp(&left.file, &right.file))
2726 });
2727 by_file.truncate(DIFF_FILES);
2728 let total = [
2729 gained_lines.len(),
2730 gained_branches.len(),
2731 gained_mcdc.len(),
2732 lost_lines.len(),
2733 lost_branches.len(),
2734 lost_mcdc.len(),
2735 ]
2736 .into_iter()
2737 .max()
2738 .unwrap_or(0);
2739 let page = |values: &[String]| {
2740 values
2741 .iter()
2742 .skip(options.offset)
2743 .take(options.limit)
2744 .cloned()
2745 .collect::<Vec<_>>()
2746 };
2747 let gained = CoverageDiffSide {
2748 line_count: gained_lines.len(),
2749 branch_count: gained_branches.len(),
2750 mcdc_count: gained_mcdc.len(),
2751 lines: page(&gained_lines),
2752 branches: page(&gained_branches),
2753 mcdc: page(&gained_mcdc),
2754 };
2755 let lost = CoverageDiffSide {
2756 line_count: lost_lines.len(),
2757 branch_count: lost_branches.len(),
2758 mcdc_count: lost_mcdc.len(),
2759 lines: page(&lost_lines),
2760 branches: page(&lost_branches),
2761 mcdc: page(&lost_mcdc),
2762 };
2763 let returned = [
2764 gained.lines.len(),
2765 gained.branches.len(),
2766 gained.mcdc.len(),
2767 lost.lines.len(),
2768 lost.branches.len(),
2769 lost.mcdc.len(),
2770 ]
2771 .into_iter()
2772 .max()
2773 .unwrap_or(0);
2774 Ok((
2775 CoverageDiffData {
2776 filters: query_filters(options.view, options.kind, options.runner),
2777 older: options.older_run.into(),
2778 newer: options.newer_run.into(),
2779 delta: CoverageDiffDelta {
2780 lines: rounded_delta(
2781 newer.summary.lines.percentage,
2782 older.summary.lines.percentage,
2783 ),
2784 branches: rounded_delta(
2785 newer.summary.branches.percentage,
2786 older.summary.branches.percentage,
2787 ),
2788 mcdc: rounded_delta(
2789 newer.summary.condition_coverage_pct,
2790 older.summary.condition_coverage_pct,
2791 ),
2792 },
2793 gained,
2794 lost,
2795 files,
2796 by_file,
2797 inputs: None,
2798 gained_only: options.gained_only,
2799 },
2800 pagination(options.offset, options.limit, returned, total),
2801 ))
2802}
2803
2804pub fn coverage_scope_query(
2805 index: &CoverageIndex<'_>,
2806 options: CoverageScopeQueryOptions<'_>,
2807) -> Result<(CoverageScopeData, AgentPagination), QueryError> {
2808 if options.limit == 0 {
2809 return Err(QueryError::InvalidPagination);
2810 }
2811 let projection = index.projection(options.view, options.kind, options.runner)?;
2812 let scope = projection
2813 .source_scope
2814 .ok_or(QueryError::ScopeUnavailable)?;
2815 let mut entries = index.scope_entries(options.view)?;
2816 entries.sort_by(|left, right| {
2817 scope_status_rank(&left.status)
2818 .cmp(&scope_status_rank(&right.status))
2819 .then_with(|| left.file.cmp(&right.file))
2820 });
2821 let groups = scope_groups(&entries);
2822 let unclassified = unclassified_source(&scope.roots, &entries);
2823 let total = entries.len();
2824 let selected = entries
2825 .into_iter()
2826 .skip(options.offset)
2827 .take(options.limit)
2828 .collect::<Vec<_>>();
2829 let returned = selected.len();
2830 Ok((
2831 CoverageScopeData {
2832 run: options.run.into(),
2833 filters: CoverageQueryFilters {
2834 outcome: match options.view {
2835 CoverageViewId::All => "all",
2836 CoverageViewId::Passed => "passed",
2837 CoverageViewId::Failed => "failed",
2838 }
2839 .into(),
2840 kind: options.kind.map(str::to_owned),
2841 runner: options.runner.map(str::to_owned),
2842 },
2843 kind: scope.kind,
2844 language: scope.language,
2845 model: scope.model,
2846 mode: scope.mode,
2847 roots: scope.roots,
2848 unit: scope.unit,
2849 measurement_complete: scope.measurement_complete,
2850 counts: ScopeCounts {
2851 included: scope.included,
2852 excluded: scope.excluded,
2853 ambiguous: scope.ambiguous,
2854 },
2855 measurement: projection.measurement,
2856 groups,
2857 unclassified_source: unclassified,
2858 entries: selected,
2859 },
2860 pagination(options.offset, options.limit, returned, total),
2861 ))
2862}
2863
2864pub fn coverage_summary_query(
2865 index: &CoverageIndex<'_>,
2866 options: CoverageSummaryQueryOptions<'_>,
2867) -> Result<CoverageSummaryData, QueryError> {
2868 let projection = index.projection(options.view, options.kind, options.runner)?;
2869 let mut test_kind_sources = BTreeMap::new();
2870 for test in index.test_summaries(options.view)?.iter().filter(|t| {
2871 t.role == "test"
2872 && options.kind.is_none_or(|k| t.provenance.kind == k)
2873 && options.runner.is_none_or(|r| t.provenance.runner == r)
2874 }) {
2875 *test_kind_sources
2876 .entry(test.provenance.source.clone())
2877 .or_insert(0) += 1;
2878 }
2879 let mut diagnostics = Vec::new();
2880 let mut transport_blockers = 0usize;
2881 if projection.empty_evidence_tests > 0 {
2882 diagnostics.push(CoverageDiagnostic {
2883 code: "TEST_EVIDENCE_MISSING".into(),
2884 severity: "warning".into(),
2885 message: format!(
2886 "{} test(s) recorded assertion phases but attributed zero coverage evidence; possible causes include checks of uninstrumented data, shared setup, lost async context or missing probe transport. First: {}",
2887 projection.empty_evidence_tests,
2888 projection.first_empty_evidence_test.as_deref().unwrap_or("unknown")
2889 ),
2890 });
2891 }
2892 if let Some(transport) = &projection.transport {
2893 if transport.corrupt_records > 0 {
2894 transport_blockers += 1;
2895 diagnostics.push(CoverageDiagnostic {
2896 code: "CORRUPT_EVIDENCE_RECORDS".into(),
2897 severity: "error".into(),
2898 message: format!(
2899 "{} malformed evidence record(s) in {} file(s) were excluded; coverage is incomplete.",
2900 transport.corrupt_records, transport.corrupt_files
2901 ),
2902 });
2903 }
2904 if transport.remote_launches > 0
2905 && transport.scoped_server_records == 0
2906 && transport.background_server_records == 0
2907 && projection.attribution.server_explicit == 0
2908 && projection.attribution.server_fallback == 0
2909 {
2910 transport_blockers += 1;
2911 diagnostics.push(CoverageDiagnostic {
2912 code: "REMOTE_SERVER_EVIDENCE_MISSING".into(),
2913 severity: "error".into(),
2914 message: "Remote launches were supervised, but neither scoped nor background server evidence returned. Supercov refuses to describe this measurement as complete.".into(),
2915 });
2916 }
2917 }
2918 let coverage_by_kind = index.dimensions(options.view, CoverageDimension::Kind)?;
2919 let coverage_by_runner = index.dimensions(options.view, CoverageDimension::Runner)?;
2920 let coverage_by_tests = if options.kind.is_none() && options.runner.is_none() {
2921 index
2922 .dimensions(options.view, CoverageDimension::Tests)?
2923 .into_iter()
2924 .next()
2925 .filter(|tests| tests.tests + tests.setups > 0)
2926 .map(|tests| tests.summary)
2927 } else {
2928 None
2929 };
2930 let other_e2e_kinds = coverage_by_kind
2931 .iter()
2932 .filter(|dimension| dimension.tests > 0)
2933 .filter_map(|dimension| dimension.kind.clone())
2934 .filter(|kind| kind != "e2e")
2935 .collect::<Vec<_>>();
2936 let e2e_observed = coverage_by_kind
2937 .iter()
2938 .any(|dimension| dimension.tests > 0 && dimension.kind.as_deref() == Some("e2e"));
2939 let e2e_gap_context = if options.kind.is_none()
2940 && options.runner.is_none()
2941 && e2e_observed
2942 && !other_e2e_kinds.is_empty()
2943 {
2944 let mut covered_elsewhere = IndexedGapDimensions {
2945 lines: 0,
2946 statements: 0,
2947 functions: 0,
2948 branches: 0,
2949 mcdc_conditions: 0,
2950 };
2951 let mut uncovered_everywhere = covered_elsewhere.clone();
2952 for gap in index.file_gaps(options.view, Some("e2e"), None)? {
2953 covered_elsewhere.lines += gap.covered_by_other_tests.lines;
2954 covered_elsewhere.statements += gap.covered_by_other_tests.statements;
2955 covered_elsewhere.functions += gap.covered_by_other_tests.functions;
2956 covered_elsewhere.branches += gap.covered_by_other_tests.branches;
2957 covered_elsewhere.mcdc_conditions += gap.covered_by_other_tests.mcdc_conditions;
2958 uncovered_everywhere.lines += gap.uncovered_everywhere.lines;
2959 uncovered_everywhere.statements += gap.uncovered_everywhere.statements;
2960 uncovered_everywhere.functions += gap.uncovered_everywhere.functions;
2961 uncovered_everywhere.branches += gap.uncovered_everywhere.branches;
2962 uncovered_everywhere.mcdc_conditions += gap.uncovered_everywhere.mcdc_conditions;
2963 }
2964 Some(CoverageKindGapContext {
2965 kind: "e2e".into(),
2966 other_kinds: other_e2e_kinds,
2967 covered_elsewhere,
2968 uncovered_everywhere,
2969 })
2970 } else {
2971 None
2972 };
2973 let filters = CoverageQueryFilters {
2974 outcome: match options.view {
2975 CoverageViewId::All => "all",
2976 CoverageViewId::Passed => "passed",
2977 CoverageViewId::Failed => "failed",
2978 }
2979 .into(),
2980 kind: options.kind.map(str::to_owned),
2981 runner: options.runner.map(str::to_owned),
2982 };
2983 let mut measurement = projection.measurement.clone();
2984 if transport_blockers > 0 {
2985 measurement.complete = false;
2986 measurement.limitations = measurement.limitations.saturating_add(transport_blockers);
2987 measurement.blocking = measurement.blocking.saturating_add(transport_blockers);
2988 }
2989 let structurally_complete = projection.summary.coverage_complete && measurement.complete;
2990 let complete = options.view == CoverageViewId::Passed
2991 && options.valid
2992 && !options.stale
2993 && structurally_complete;
2994 Ok(CoverageSummaryData {
2995 test_kind_sources,
2996 assertion_coverage: None,
2997 run: options.run.into(),
2998 command: Vec::new(),
2999 merged_across: Vec::new(),
3000 companions: Vec::new(),
3001 hints: Vec::new(),
3002 workspace: None,
3003 filters,
3004 model: index.model()?,
3005 generated_at: projection.generated_at,
3006 valid: options.valid,
3007 test_exit_code: options.test_exit_code,
3008 stale: options.stale,
3009 stale_reasons: options.stale_reasons,
3010 structurally_complete,
3011 complete,
3012 coverage: projection.summary,
3013 measurement,
3014 coverage_by_kind,
3015 coverage_by_tests,
3016 e2e_gap_context,
3017 coverage_by_runner,
3018 attribution: projection.attribution,
3019 test_attribution: {
3020 let mut counts = TestAttributionCounts::default();
3021 for test in index
3022 .test_summaries(options.view)?
3023 .iter()
3024 .filter(|test| test.role == "test")
3025 {
3026 match test.attribution.as_str() {
3027 crate::coverage_report::ATTRIBUTION_RUN_WIDE => counts.run_wide += 1,
3028 crate::coverage_report::ATTRIBUTION_PARTIAL => counts.partial += 1,
3029 crate::coverage_report::ATTRIBUTION_BY_TIME => counts.by_time += 1,
3030 _ => counts.exact += 1,
3031 }
3032 }
3033 counts
3034 },
3035 transport: projection.transport,
3036 diagnostics,
3037 confidence: (options.kind.is_none() && options.runner.is_none())
3038 .then_some(projection.confidence),
3039 files_with_gaps: projection.files_with_gaps,
3040 files_with_coverage_gaps: projection.files_with_coverage_gaps,
3041 files_with_measurement_limitations: projection.measurement.files,
3042 tests: projection.tests,
3043 setups: projection.setups,
3044 test_outcomes: projection.test_outcomes,
3045 unclassified_source: match &projection.source_scope {
3046 Some(scope) if scope.ambiguous > 0 => {
3047 unclassified_source(&scope.roots, &index.scope_entries(options.view)?)
3048 }
3049 _ => None,
3050 },
3051 source_scope: projection.source_scope,
3052 })
3053}
3054
3055#[derive(Debug, Clone, PartialEq)]
3056pub enum CoverageDimensionQueryData {
3057 Kinds(CoverageKindsData),
3058 Runners(CoverageRunnersData),
3059}
3060
3061#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
3064#[serde(rename_all = "camelCase")]
3065pub struct CoverageArea {
3066 pub directory: String,
3067 pub files: usize,
3068 pub totals: IndexedGapDimensions,
3069 pub covered: IndexedGapDimensions,
3070 pub by_kind: Vec<CoverageAreaKind>,
3071 #[serde(skip_serializing_if = "Option::is_none")]
3075 pub covered_by_tests: Option<IndexedGapDimensions>,
3076}
3077
3078#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
3079#[serde(rename_all = "camelCase")]
3080pub struct CoverageAreaKind {
3081 pub kind: String,
3082 pub covered: IndexedGapDimensions,
3083}
3084
3085#[derive(Debug, Clone, PartialEq, Serialize)]
3086#[serde(rename_all = "camelCase")]
3087pub struct CoverageAreasData {
3088 pub run: String,
3089 pub filters: CoverageQueryFilters,
3090 pub metric: MinimizeMetric,
3091 pub depth: usize,
3092 pub kinds: Vec<String>,
3095 pub areas: Vec<CoverageArea>,
3096}
3097
3098#[derive(Debug, Clone, Copy)]
3099pub struct CoverageAreasQueryOptions<'a> {
3100 pub run: &'a str,
3101 pub view: CoverageViewId,
3102 pub metric: MinimizeMetric,
3103 pub kind: Option<&'a str>,
3104 pub runner: Option<&'a str>,
3105 pub depth: usize,
3106 pub offset: usize,
3107 pub limit: usize,
3108}
3109
3110fn area_directory(file: &str, depth: usize) -> String {
3113 let mut folders = file.split('/').collect::<Vec<_>>();
3114 folders.pop();
3115 if folders.is_empty() {
3116 ".".into()
3117 } else {
3118 folders[..folders.len().min(depth)].join("/")
3119 }
3120}
3121
3122fn add_dimensions(sum: &mut IndexedGapDimensions, value: &IndexedGapDimensions) {
3123 sum.lines += value.lines;
3124 sum.statements += value.statements;
3125 sum.functions += value.functions;
3126 sum.branches += value.branches;
3127 sum.mcdc_conditions += value.mcdc_conditions;
3128}
3129
3130const NO_DIMENSIONS: IndexedGapDimensions = IndexedGapDimensions {
3131 lines: 0,
3132 statements: 0,
3133 functions: 0,
3134 branches: 0,
3135 mcdc_conditions: 0,
3136};
3137
3138pub fn dimension_for_metric(dimensions: &IndexedGapDimensions, metric: MinimizeMetric) -> usize {
3140 match metric {
3141 MinimizeMetric::All | MinimizeMetric::Lines => dimensions.lines,
3142 MinimizeMetric::Statements => dimensions.statements,
3143 MinimizeMetric::Functions => dimensions.functions,
3144 MinimizeMetric::Branches => dimensions.branches,
3145 MinimizeMetric::Mcdc => dimensions.mcdc_conditions,
3146 }
3147}
3148
3149pub fn coverage_areas_query(
3153 index: &CoverageIndex<'_>,
3154 options: CoverageAreasQueryOptions<'_>,
3155) -> Result<(CoverageAreasData, AgentPagination), QueryError> {
3156 if options.limit == 0 || options.depth == 0 {
3157 return Err(QueryError::InvalidPagination);
3158 }
3159 let files = index.file_gaps(options.view, options.kind, options.runner)?;
3160 if (options.kind.is_some() || options.runner.is_some()) && files.is_empty() {
3161 return Err(QueryError::TestFilterEmpty {
3162 kind: options.kind.map(str::to_owned),
3163 runner: options.runner.map(str::to_owned),
3164 });
3165 }
3166 let kinds = if options.kind.is_none() && options.runner.is_none() {
3167 index
3168 .dimensions(options.view, CoverageDimension::Kind)?
3169 .into_iter()
3170 .filter(|kind| kind.tests > 0 && kind.attributed > 0)
3171 .filter_map(|kind| kind.kind)
3172 .collect::<Vec<_>>()
3173 } else {
3174 Vec::new()
3175 };
3176 let mut areas = BTreeMap::<String, CoverageArea>::new();
3177 for file in &files {
3178 let directory = area_directory(&file.file, options.depth);
3179 let area = areas
3180 .entry(directory.clone())
3181 .or_insert_with(|| CoverageArea {
3182 directory,
3183 files: 0,
3184 totals: NO_DIMENSIONS,
3185 covered: NO_DIMENSIONS,
3186 by_kind: kinds
3187 .iter()
3188 .map(|kind| CoverageAreaKind {
3189 kind: kind.clone(),
3190 covered: NO_DIMENSIONS,
3191 })
3192 .collect(),
3193 covered_by_tests: None,
3194 });
3195 area.files += 1;
3196 add_dimensions(&mut area.totals, &file.totals);
3197 add_dimensions(&mut area.covered, &file.covered);
3198 }
3199 for (position, kind) in kinds.iter().enumerate() {
3200 for file in index.file_gaps(options.view, Some(kind), None)? {
3201 if let Some(area) = areas.get_mut(&area_directory(&file.file, options.depth)) {
3202 add_dimensions(&mut area.by_kind[position].covered, &file.covered);
3203 }
3204 }
3205 }
3206 if !kinds.is_empty() {
3207 let tested = index.file_gaps(options.view, Some(crate::coverage_index::ALL_TESTS), None)?;
3208 if !tested.is_empty() {
3209 for area in areas.values_mut() {
3210 area.covered_by_tests = Some(NO_DIMENSIONS);
3211 }
3212 for file in tested {
3213 if let Some(sum) = areas
3214 .get_mut(&area_directory(&file.file, options.depth))
3215 .and_then(|area| area.covered_by_tests.as_mut())
3216 {
3217 add_dimensions(sum, &file.covered);
3218 }
3219 }
3220 }
3221 }
3222 let mut areas = areas.into_values().collect::<Vec<_>>();
3223 areas.sort_by(|left, right| {
3224 dimension_for_metric(&right.totals, options.metric)
3225 .cmp(&dimension_for_metric(&left.totals, options.metric))
3226 .then_with(|| left.directory.cmp(&right.directory))
3227 });
3228 let total = areas.len();
3229 let page = areas
3230 .into_iter()
3231 .skip(options.offset)
3232 .take(options.limit)
3233 .collect::<Vec<_>>();
3234 let returned = page.len();
3235 Ok((
3236 CoverageAreasData {
3237 run: options.run.into(),
3238 filters: CoverageQueryFilters {
3239 outcome: match options.view {
3240 CoverageViewId::All => "all",
3241 CoverageViewId::Passed => "passed",
3242 CoverageViewId::Failed => "failed",
3243 }
3244 .into(),
3245 kind: options.kind.map(str::to_owned),
3246 runner: options.runner.map(str::to_owned),
3247 },
3248 metric: options.metric,
3249 depth: options.depth,
3250 kinds,
3251 areas: page,
3252 },
3253 pagination(options.offset, options.limit, returned, total),
3254 ))
3255}
3256
3257#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
3259#[serde(rename_all = "camelCase")]
3260pub struct TestWithoutEvidence {
3261 pub id: String,
3262 pub name: String,
3263 #[serde(skip_serializing_if = "Option::is_none")]
3264 pub file: Option<String>,
3265 pub kind: String,
3266 pub runner: String,
3267 pub outcome: String,
3268 pub assertions: usize,
3269 #[serde(skip_serializing_if = "Option::is_none")]
3273 pub imports_measured: Option<bool>,
3274}
3275
3276#[derive(Debug, Clone, PartialEq, Serialize)]
3277#[serde(rename_all = "camelCase")]
3278pub struct TestsWithoutEvidenceData {
3279 pub run: String,
3280 pub filters: CoverageQueryFilters,
3281 pub tests: Vec<TestWithoutEvidence>,
3282}
3283
3284#[derive(Debug, Clone, Copy)]
3285pub struct TestsWithoutEvidenceOptions<'a> {
3286 pub run: &'a str,
3287 pub view: CoverageViewId,
3288 pub kind: Option<&'a str>,
3289 pub runner: Option<&'a str>,
3290 pub offset: usize,
3291 pub limit: usize,
3292}
3293
3294pub fn tests_without_evidence_query(
3298 index: &CoverageIndex<'_>,
3299 options: TestsWithoutEvidenceOptions<'_>,
3300) -> Result<(TestsWithoutEvidenceData, AgentPagination), QueryError> {
3301 if options.limit == 0 {
3302 return Err(QueryError::InvalidPagination);
3303 }
3304 let mut assertions = BTreeMap::<String, usize>::new();
3305 for phase in index.phase_summaries(options.view)? {
3306 *assertions.entry(phase.test).or_insert(0) += 1;
3307 }
3308 let mut tests = index
3309 .test_details(options.view)?
3310 .into_iter()
3311 .filter(|test| {
3312 test.summary.role == "test"
3313 && test.lines.is_empty()
3314 && test.hits.is_empty()
3315 && test.decisions.is_empty()
3316 && options
3317 .kind
3318 .is_none_or(|kind| test.summary.provenance.kind == kind)
3319 && options
3320 .runner
3321 .is_none_or(|runner| test.summary.provenance.runner == runner)
3322 })
3323 .filter_map(|test| {
3324 let assertions = *assertions.get(&test.summary.id)?;
3325 Some(TestWithoutEvidence {
3326 id: test.summary.id,
3327 name: test.summary.name,
3328 file: test.summary.file,
3329 kind: test.summary.provenance.kind,
3330 runner: test.summary.provenance.runner,
3331 outcome: test.summary.outcome,
3332 assertions,
3333 imports_measured: None,
3334 })
3335 })
3336 .collect::<Vec<_>>();
3337 tests.sort_by(|left, right| {
3338 (&left.file, &left.name, &left.id).cmp(&(&right.file, &right.name, &right.id))
3339 });
3340 let total = tests.len();
3341 let page = tests
3342 .into_iter()
3343 .skip(options.offset)
3344 .take(options.limit)
3345 .collect::<Vec<_>>();
3346 let returned = page.len();
3347 Ok((
3348 TestsWithoutEvidenceData {
3349 run: options.run.into(),
3350 filters: CoverageQueryFilters {
3351 outcome: match options.view {
3352 CoverageViewId::All => "all",
3353 CoverageViewId::Passed => "passed",
3354 CoverageViewId::Failed => "failed",
3355 }
3356 .into(),
3357 kind: options.kind.map(str::to_owned),
3358 runner: options.runner.map(str::to_owned),
3359 },
3360 tests: page,
3361 },
3362 pagination(options.offset, options.limit, returned, total),
3363 ))
3364}
3365
3366#[derive(Debug, Clone, PartialEq)]
3367pub enum CoverageFileQueryData {
3368 Files(CoverageFilesData),
3369 Gaps(CoverageGapsData),
3370}
3371
3372#[derive(Debug, Clone, PartialEq)]
3373pub struct CoverageFileQueryResult {
3374 pub data: CoverageFileQueryData,
3375 pub pagination: AgentPagination,
3376}
3377
3378#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
3379#[serde(rename_all = "lowercase")]
3380pub enum DecisionSort {
3381 Location,
3382 Missing,
3383}
3384
3385#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
3386#[serde(rename_all = "camelCase")]
3387pub struct DecisionGapTotals {
3388 pub decisions: usize,
3389 pub decisions_with_missing_conditions: usize,
3390 pub conditions: usize,
3391 pub missing_conditions: usize,
3392}
3393
3394#[derive(Debug, Clone, PartialEq, Serialize)]
3395#[serde(rename_all = "camelCase")]
3396pub struct CoverageFileDecisionsData {
3397 pub run: String,
3398 pub filters: CoverageQueryFilters,
3399 pub file: String,
3400 pub group: String,
3401 pub sort: DecisionSort,
3402 pub totals: DecisionGapTotals,
3403 pub decisions: Vec<IndexedDecisionGap>,
3404}
3405
3406#[derive(Debug, Clone, Copy)]
3407pub struct CoverageFileDecisionsOptions<'a> {
3408 pub run: &'a str,
3409 pub view: CoverageViewId,
3410 pub kind: Option<&'a str>,
3411 pub runner: Option<&'a str>,
3412 pub file: &'a str,
3413 pub sort: DecisionSort,
3414 pub offset: usize,
3415 pub limit: usize,
3416}
3417
3418#[derive(Debug, Clone)]
3419pub struct CoverageDimensionQueryOptions<'a> {
3420 pub run: &'a str,
3421 pub view: CoverageViewId,
3422 pub dimension: CoverageDimension,
3423 pub filters: CoverageQueryFilters,
3424 pub offset: usize,
3425 pub limit: usize,
3426}
3427
3428pub fn coverage_dimension_query(
3429 index: &CoverageIndex<'_>,
3430 options: CoverageDimensionQueryOptions<'_>,
3431) -> Result<(CoverageDimensionQueryData, AgentPagination), QueryError> {
3432 let CoverageDimensionQueryOptions {
3433 run,
3434 view,
3435 dimension,
3436 filters,
3437 offset,
3438 limit,
3439 } = options;
3440 if limit == 0 {
3441 return Err(QueryError::InvalidPagination);
3442 }
3443 let values = index.dimensions(view, dimension)?;
3444 let total = values.len();
3445 let selected = values
3446 .into_iter()
3447 .skip(offset)
3448 .take(limit)
3449 .collect::<Vec<_>>();
3450 let returned = selected.len();
3451 let data = match dimension {
3452 CoverageDimension::Kind | CoverageDimension::Tests => {
3454 CoverageDimensionQueryData::Kinds(CoverageKindsData {
3455 run: run.into(),
3456 filters,
3457 kinds: selected,
3458 })
3459 }
3460 CoverageDimension::Runner => CoverageDimensionQueryData::Runners(CoverageRunnersData {
3461 run: run.into(),
3462 filters,
3463 runners: selected,
3464 }),
3465 };
3466 Ok((data, pagination(offset, limit, returned, total)))
3467}
3468
3469#[derive(Debug, Clone, Copy)]
3470pub struct CoverageFileQueryOptions<'a> {
3471 pub run: &'a str,
3472 pub view: CoverageViewId,
3473 pub metric: MinimizeMetric,
3474 pub gaps_only: bool,
3475 pub kind: Option<&'a str>,
3476 pub runner: Option<&'a str>,
3477 pub offset: usize,
3478 pub limit: usize,
3479}
3480
3481fn gap_metric_value(gap: &IndexedFileGap, metric: MinimizeMetric) -> usize {
3482 match metric {
3483 MinimizeMetric::All => gap.score,
3484 MinimizeMetric::Lines => gap.uncovered_lines,
3485 MinimizeMetric::Statements => gap.uncovered_statements,
3486 MinimizeMetric::Functions => gap.uncovered_functions,
3487 MinimizeMetric::Branches => gap.missing_branches,
3488 MinimizeMetric::Mcdc => gap.missing_mcdc_conditions,
3489 }
3490}
3491
3492fn has_gap_for_metric(gap: &IndexedFileGap, metric: MinimizeMetric) -> bool {
3493 match metric {
3494 MinimizeMetric::All => {
3495 gap.uncovered_lines > 0
3496 || gap.uncovered_statements > 0
3497 || gap.uncovered_functions > 0
3498 || gap.missing_branches > 0
3499 || gap.missing_mcdc_conditions > 0
3500 }
3501 _ => gap_metric_value(gap, metric) > 0,
3502 }
3503}
3504
3505pub fn coverage_file_decisions_query(
3506 index: &CoverageIndex<'_>,
3507 options: CoverageFileDecisionsOptions<'_>,
3508) -> Result<(CoverageFileDecisionsData, AgentPagination), QueryError> {
3509 if options.limit == 0 {
3510 return Err(QueryError::InvalidPagination);
3511 }
3512 let all = index.decision_gaps(options.view, options.kind, options.runner, options.file)?;
3513 if (options.kind.is_some() || options.runner.is_some()) && all.is_empty() {
3514 if index
3517 .file_gaps(options.view, options.kind, options.runner)?
3518 .is_empty()
3519 {
3520 return Err(QueryError::TestFilterEmpty {
3521 kind: options.kind.map(str::to_owned),
3522 runner: options.runner.map(str::to_owned),
3523 });
3524 }
3525 }
3526 let totals = DecisionGapTotals {
3527 decisions: all.len(),
3528 decisions_with_missing_conditions: all
3529 .iter()
3530 .filter(|decision| decision.missing_conditions > 0)
3531 .count(),
3532 conditions: all.iter().map(|decision| decision.conditions).sum(),
3533 missing_conditions: all.iter().map(|decision| decision.missing_conditions).sum(),
3534 };
3535 let mut missing = all
3536 .into_iter()
3537 .filter(|decision| decision.missing_conditions > 0)
3538 .collect::<Vec<_>>();
3539 missing.sort_by(|left, right| match options.sort {
3540 DecisionSort::Missing => right
3541 .missing_conditions
3542 .cmp(&left.missing_conditions)
3543 .then_with(|| left.line.cmp(&right.line))
3544 .then_with(|| left.column.cmp(&right.column)),
3545 DecisionSort::Location => left
3546 .line
3547 .cmp(&right.line)
3548 .then_with(|| left.column.cmp(&right.column))
3549 .then_with(|| left.id.cmp(&right.id)),
3550 });
3551 let total = missing.len();
3552 let rows = missing
3553 .into_iter()
3554 .skip(options.offset)
3555 .take(options.limit)
3556 .collect::<Vec<_>>();
3557 let returned = rows.len();
3558 let filters = CoverageQueryFilters {
3559 outcome: match options.view {
3560 CoverageViewId::All => "all",
3561 CoverageViewId::Passed => "passed",
3562 CoverageViewId::Failed => "failed",
3563 }
3564 .into(),
3565 kind: options.kind.map(str::to_owned),
3566 runner: options.runner.map(str::to_owned),
3567 };
3568 Ok((
3569 CoverageFileDecisionsData {
3570 run: options.run.into(),
3571 filters,
3572 file: options.file.into(),
3573 group: "decision".into(),
3574 sort: options.sort,
3575 totals,
3576 decisions: rows,
3577 },
3578 pagination(options.offset, options.limit, returned, total),
3579 ))
3580}
3581
3582pub fn coverage_file_query(
3583 index: &CoverageIndex<'_>,
3584 options: CoverageFileQueryOptions<'_>,
3585) -> Result<CoverageFileQueryResult, QueryError> {
3586 let CoverageFileQueryOptions {
3587 run,
3588 view,
3589 metric,
3590 gaps_only,
3591 kind,
3592 runner,
3593 offset,
3594 limit,
3595 } = options;
3596 if limit == 0 {
3597 return Err(QueryError::InvalidPagination);
3598 }
3599 let mut files = index.file_gaps(view, kind, runner)?;
3600 if (kind.is_some() || runner.is_some()) && files.is_empty() {
3601 return Err(QueryError::TestFilterEmpty {
3602 kind: kind.map(str::to_owned),
3603 runner: runner.map(str::to_owned),
3604 });
3605 }
3606 if gaps_only {
3607 files.retain(|gap| has_gap_for_metric(gap, metric) || gap.measurement_limitations > 0);
3608 }
3609 files.sort_by(|left, right| {
3610 gap_metric_value(right, metric)
3611 .cmp(&gap_metric_value(left, metric))
3612 .then_with(|| {
3613 right
3614 .measurement_limitations
3615 .cmp(&left.measurement_limitations)
3616 })
3617 .then_with(|| left.file.cmp(&right.file))
3618 });
3619 let total = files.len();
3620 let page = files
3621 .into_iter()
3622 .skip(offset)
3623 .take(limit)
3624 .collect::<Vec<_>>();
3625 let page_info = pagination(offset, limit, page.len(), total);
3626 let filters = CoverageQueryFilters {
3627 outcome: match view {
3628 CoverageViewId::All => "all",
3629 CoverageViewId::Passed => "passed",
3630 CoverageViewId::Failed => "failed",
3631 }
3632 .into(),
3633 kind: kind.map(str::to_owned),
3634 runner: runner.map(str::to_owned),
3635 };
3636 let data = if gaps_only {
3637 CoverageFileQueryData::Gaps(CoverageGapsData {
3638 run: run.into(),
3639 filters,
3640 metric,
3641 gaps: page,
3642 })
3643 } else {
3644 CoverageFileQueryData::Files(CoverageFilesData {
3645 run: run.into(),
3646 filters,
3647 metric,
3648 files: page,
3649 })
3650 };
3651 Ok(CoverageFileQueryResult {
3652 data,
3653 pagination: page_info,
3654 })
3655}
3656
3657#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
3658enum ObligationMetric {
3659 Lines,
3660 Statements,
3661 Functions,
3662 Branches,
3663 Mcdc,
3664}
3665
3666impl ObligationMetric {
3667 fn selected(self, metric: MinimizeMetric) -> bool {
3668 metric == MinimizeMetric::All
3669 || matches!(
3670 (self, metric),
3671 (Self::Lines, MinimizeMetric::Lines)
3672 | (Self::Statements, MinimizeMetric::Statements)
3673 | (Self::Functions, MinimizeMetric::Functions)
3674 | (Self::Branches, MinimizeMetric::Branches)
3675 | (Self::Mcdc, MinimizeMetric::Mcdc)
3676 )
3677 }
3678
3679 fn public(self) -> MinimizeMetric {
3680 match self {
3681 Self::Lines => MinimizeMetric::Lines,
3682 Self::Statements => MinimizeMetric::Statements,
3683 Self::Functions => MinimizeMetric::Functions,
3684 Self::Branches => MinimizeMetric::Branches,
3685 Self::Mcdc => MinimizeMetric::Mcdc,
3686 }
3687 }
3688}
3689
3690#[derive(Clone)]
3691struct Obligation {
3692 id: String,
3693 metric: ObligationMetric,
3694 options: Vec<Vec<String>>,
3696}
3697
3698struct ObligationModel {
3699 obligations: Vec<Obligation>,
3700 setup_by_file: BTreeMap<String, Vec<String>>,
3701 tests_by_file: BTreeMap<String, Vec<String>>,
3702 background: Vec<String>,
3703}
3704
3705impl ObligationModel {
3706 fn expand(&self, selected: &BTreeSet<String>) -> BTreeSet<String> {
3707 let mut expanded = selected.clone();
3708 expanded.extend(self.background.iter().cloned());
3709 for (file, setup_ids) in &self.setup_by_file {
3710 if self
3711 .tests_by_file
3712 .get(file)
3713 .is_some_and(|tests| tests.iter().any(|id| selected.contains(id)))
3714 {
3715 expanded.extend(setup_ids.iter().cloned());
3716 }
3717 }
3718 expanded
3719 }
3720}
3721
3722fn deduplicate_options(options: impl IntoIterator<Item = Vec<String>>) -> Vec<Vec<String>> {
3723 let mut unique = BTreeMap::<String, Vec<String>>::new();
3724 for mut option in options {
3725 option.sort();
3726 option.dedup();
3727 let key = option.join("\0");
3728 unique.entry(key).or_insert(option);
3729 }
3730 unique.into_values().collect()
3731}
3732
3733fn evidence_choices(
3734 ids: &[Id],
3735 tests: &HashMap<&str, &crate::coverage_report::TestCoverageResult>,
3736 candidates: &BTreeSet<String>,
3737 tests_by_file: &BTreeMap<String, Vec<String>>,
3738) -> Vec<Vec<String>> {
3739 let mut choices = Vec::new();
3740 for id in ids {
3741 let Some(test) = tests.get(id.as_str()) else {
3742 continue;
3743 };
3744 if test.role == "background" {
3745 choices.push(Vec::new());
3746 } else if test.role == "setup" {
3747 if let Some(file) = &test.file {
3748 choices.extend(
3749 tests_by_file
3750 .get(file)
3751 .into_iter()
3752 .flatten()
3753 .map(|candidate| vec![candidate.clone()]),
3754 );
3755 }
3756 } else if candidates.contains(id.as_str()) {
3757 choices.push(vec![id.to_string()]);
3758 }
3759 }
3760 deduplicate_options(choices)
3761}
3762
3763fn build_obligations(view: &CoverageView, candidates: &BTreeSet<String>) -> ObligationModel {
3764 let tests = view
3765 .tests
3766 .iter()
3767 .map(|test| (test.id.as_str(), test))
3768 .collect::<HashMap<_, _>>();
3769 let mut tests_by_file = BTreeMap::<String, Vec<String>>::new();
3770 let mut setup_by_file = BTreeMap::<String, Vec<String>>::new();
3771 let mut background = Vec::new();
3772 for test in &view.tests {
3773 match test.role.as_str() {
3774 "test" if candidates.contains(&test.id) => {
3775 if let Some(file) = &test.file {
3776 tests_by_file
3777 .entry(file.clone())
3778 .or_default()
3779 .push(test.id.clone());
3780 }
3781 }
3782 "setup" => {
3783 if let Some(file) = &test.file {
3784 setup_by_file
3785 .entry(file.clone())
3786 .or_default()
3787 .push(test.id.clone());
3788 }
3789 }
3790 "background" => background.push(test.id.clone()),
3791 _ => {}
3792 }
3793 }
3794 let choices = |ids: &[Id]| evidence_choices(ids, &tests, candidates, &tests_by_file);
3795 let mut obligations = Vec::new();
3796 let mut unique_lines = BTreeMap::new();
3797 for line in &view.lines {
3798 unique_lines.insert((line.file.as_str(), line.line), line);
3799 }
3800 for ((file, line), result) in unique_lines {
3801 obligations.push(Obligation {
3802 id: format!("line:{file}:{line}"),
3803 metric: ObligationMetric::Lines,
3804 options: choices(&result.tests),
3805 });
3806 }
3807 for point in &view.points {
3808 let (kind, metric) = match point.meta.kind {
3809 crate::coverage_analysis::PointKind::Statement => {
3810 ("statement", ObligationMetric::Statements)
3811 }
3812 crate::coverage_analysis::PointKind::Function => {
3813 ("function", ObligationMetric::Functions)
3814 }
3815 };
3816 obligations.push(Obligation {
3817 id: format!("{kind}:{}", point.meta.id),
3818 metric,
3819 options: choices(&point.tests),
3820 });
3821 }
3822 for branch in &view.branches {
3823 for alternative in &branch.alternatives {
3824 obligations.push(Obligation {
3825 id: format!("branch:{}:{}", branch.meta.id, alternative.id),
3826 metric: ObligationMetric::Branches,
3827 options: choices(&alternative.tests),
3828 });
3829 }
3830 }
3831 for decision in &view.decisions {
3832 for condition in 0..decision.meta.conditions.len() {
3833 let mut options = Vec::new();
3834 for left in 0..decision.vector_observations.len() {
3835 for right in (left + 1)..decision.vector_observations.len() {
3836 let first = &decision.vector_observations[left];
3837 let second = &decision.vector_observations[right];
3838 if !is_independence_pair(&first.vector, &second.vector, condition) {
3839 continue;
3840 }
3841 for first_choice in choices(&first.tests) {
3842 for second_choice in choices(&second.tests) {
3843 let mut combined = first_choice.clone();
3844 combined.extend(second_choice);
3845 options.push(combined);
3846 }
3847 }
3848 }
3849 }
3850 obligations.push(Obligation {
3851 id: format!("mcdc:{}:{condition}", decision.meta.id),
3852 metric: ObligationMetric::Mcdc,
3853 options: deduplicate_options(options),
3854 });
3855 }
3856 }
3857 ObligationModel {
3858 obligations,
3859 setup_by_file,
3860 tests_by_file,
3861 background,
3862 }
3863}
3864
3865fn percentage(metric: ObligationMetric, summary: &CoverageSummary) -> f64 {
3866 match metric {
3867 ObligationMetric::Lines => summary.lines.percentage,
3868 ObligationMetric::Statements => summary.statements.percentage,
3869 ObligationMetric::Functions => summary.functions.percentage,
3870 ObligationMetric::Branches => summary.branches.percentage,
3871 ObligationMetric::Mcdc => summary.condition_coverage_pct,
3872 }
3873}
3874
3875fn obligation_satisfied(obligation: &Obligation, selected: &BTreeSet<String>) -> bool {
3876 obligation
3877 .options
3878 .iter()
3879 .any(|option| option.iter().all(|test| selected.contains(test)))
3880}
3881
3882struct Search<'a> {
3883 obligations: &'a [Obligation],
3884 skip_limits: BTreeMap<ObligationMetric, usize>,
3885 best: BTreeSet<String>,
3886 explored_states: usize,
3887 max_states: usize,
3888 seen: BTreeSet<String>,
3889 candidate_tests: usize,
3890 target: f64,
3891 metric: MinimizeMetric,
3892}
3893
3894impl Search<'_> {
3895 fn visit(
3896 &mut self,
3897 selected: BTreeSet<String>,
3898 skipped: BTreeSet<String>,
3899 skipped_by_metric: BTreeMap<ObligationMetric, usize>,
3900 ) -> Result<(), QueryError> {
3901 self.explored_states += 1;
3902 if self.explored_states > self.max_states {
3903 return Err(QueryError::ComplexityLimit {
3904 candidate_tests: self.candidate_tests,
3905 obligations: self.obligations.len(),
3906 explored_states: self.explored_states,
3907 max_states: self.max_states,
3908 target: self.target,
3909 metric: self.metric,
3910 });
3911 }
3912 if selected.len() >= self.best.len() {
3913 return Ok(());
3914 }
3915 let state_key = format!(
3916 "{}|{}",
3917 selected.iter().cloned().collect::<Vec<_>>().join(","),
3918 skipped.iter().cloned().collect::<Vec<_>>().join(",")
3919 );
3920 if !self.seen.insert(state_key) {
3921 return Ok(());
3922 }
3923 let mut unmet = self
3924 .obligations
3925 .iter()
3926 .filter(|obligation| {
3927 !skipped.contains(&obligation.id) && !obligation_satisfied(obligation, &selected)
3928 })
3929 .collect::<Vec<_>>();
3930 if unmet.is_empty() {
3931 self.best = selected;
3932 return Ok(());
3933 }
3934 unmet.sort_by(|left, right| {
3935 let feasible = |obligation: &Obligation| {
3936 obligation
3937 .options
3938 .iter()
3939 .filter(|option| option.iter().any(|test| !selected.contains(test)))
3940 .count()
3941 };
3942 feasible(left)
3943 .cmp(&feasible(right))
3944 .then_with(|| left.id.cmp(&right.id))
3945 });
3946 let obligation = unmet[0];
3947 let mut additions = deduplicate_options(obligation.options.iter().filter_map(|option| {
3948 let addition = option
3949 .iter()
3950 .filter(|test| !selected.contains(*test))
3951 .cloned()
3952 .collect::<Vec<_>>();
3953 (!addition.is_empty()).then_some(addition)
3954 }));
3955 additions.sort_by(|left, right| {
3956 left.len()
3957 .cmp(&right.len())
3958 .then_with(|| left.join("\0").cmp(&right.join("\0")))
3959 });
3960 for addition in additions {
3961 if selected.len() + addition.len() >= self.best.len() {
3962 continue;
3963 }
3964 let mut next = selected.clone();
3965 next.extend(addition);
3966 self.visit(next, skipped.clone(), skipped_by_metric.clone())?;
3967 }
3968 let skipped_count = skipped_by_metric
3969 .get(&obligation.metric)
3970 .copied()
3971 .unwrap_or(0);
3972 if skipped_count
3973 < self
3974 .skip_limits
3975 .get(&obligation.metric)
3976 .copied()
3977 .unwrap_or(0)
3978 {
3979 let mut next_skipped = skipped;
3980 next_skipped.insert(obligation.id.clone());
3981 let mut next_counts = skipped_by_metric;
3982 next_counts.insert(obligation.metric, skipped_count + 1);
3983 self.visit(selected, next_skipped, next_counts)?;
3984 }
3985 Ok(())
3986 }
3987}
3988
3989pub fn minimum_test_set(
3990 view: &CoverageView,
3991 target: f64,
3992 metric: MinimizeMetric,
3993 max_states: usize,
3994) -> Result<MinimumTestSetResult, QueryError> {
3995 use crate::coverage_report::coverage_is_complete;
3996 if !target.is_finite() || !(0.0..=100.0).contains(&target) {
3997 return Err(QueryError::InvalidTarget(target));
3998 }
3999 if view.tests.iter().any(|test| {
4000 test.role == "background" && (!test.hits.is_empty() || !test.decisions.is_empty())
4001 }) {
4002 return Err(QueryError::UnattributedEvidence);
4003 }
4004 if view
4011 .tests
4012 .iter()
4013 .any(|test| test.role == "test" && !coverage_is_complete(&test.attribution))
4014 {
4015 return Err(QueryError::UnattributedEvidence);
4016 }
4017 let candidate_tests = view
4018 .tests
4019 .iter()
4020 .filter(|test| test.role == "test")
4021 .map(|test| test.id.clone())
4022 .collect::<BTreeSet<_>>();
4023 let model = build_obligations(view, &candidate_tests);
4024 let obligations = model
4025 .obligations
4026 .iter()
4027 .filter(|obligation| obligation.metric.selected(metric))
4028 .cloned()
4029 .collect::<Vec<_>>();
4030 let mut totals = BTreeMap::<ObligationMetric, usize>::new();
4031 for obligation in &obligations {
4032 *totals.entry(obligation.metric).or_default() += 1;
4033 }
4034 let metrics = [
4035 ObligationMetric::Lines,
4036 ObligationMetric::Statements,
4037 ObligationMetric::Functions,
4038 ObligationMetric::Branches,
4039 ObligationMetric::Mcdc,
4040 ]
4041 .into_iter()
4042 .filter(|candidate| candidate.selected(metric))
4043 .collect::<Vec<_>>();
4044 let skip_limits = metrics
4045 .iter()
4046 .map(|selected_metric| {
4047 let total = totals.get(selected_metric).copied().unwrap_or(0);
4048 let required = ((total as f64 * target) / 100.0).ceil() as usize;
4049 (*selected_metric, total.saturating_sub(required))
4050 })
4051 .collect::<BTreeMap<_, _>>();
4052 let full_expanded = model.expand(&candidate_tests);
4053 let full_summary = coverage_summary_for_tests(view, &full_expanded)?;
4054 for selected_metric in &metrics {
4055 let reachable = percentage(*selected_metric, &full_summary);
4056 if reachable + 1e-9 < target {
4057 return Err(QueryError::TargetUnreachable {
4058 metric: selected_metric.public(),
4059 target,
4060 reachable,
4061 });
4062 }
4063 }
4064 let mut search = Search {
4065 obligations: &obligations,
4066 skip_limits,
4067 best: candidate_tests.clone(),
4068 explored_states: 0,
4069 max_states,
4070 seen: BTreeSet::new(),
4071 candidate_tests: candidate_tests.len(),
4072 target,
4073 metric,
4074 };
4075 search.visit(BTreeSet::new(), BTreeSet::new(), BTreeMap::new())?;
4076 let expanded = model.expand(&search.best);
4077 let summary = coverage_summary_for_tests(view, &expanded)?;
4078 Ok(MinimumTestSetResult {
4079 optimal: true,
4080 target,
4081 metric,
4082 selected: search.best.into_iter().collect(),
4083 expanded: expanded.into_iter().collect(),
4084 summary,
4085 explored_states: search.explored_states,
4086 })
4087}
4088
4089#[cfg(test)]
4090mod tests {
4091 use crate::{
4092 coverage_analysis::McdcVector,
4093 coverage_report::{
4094 CoverageManifest, CoverageReport, CoverageReportRequest, DecisionMeta, ExitCodeInput,
4095 RawTestResult, RuntimeSnapshot, TestProvenance, analyze_coverage_results,
4096 },
4097 };
4098
4099 use super::*;
4100
4101 fn scope_entry(
4102 file: &str,
4103 status: &str,
4104 reason: &str,
4105 package: Option<&str>,
4106 ) -> IndexedScopeEntry {
4107 IndexedScopeEntry {
4108 file: file.into(),
4109 status: status.into(),
4110 reason: reason.into(),
4111 package_root: package.map(str::to_owned),
4112 measurement_limitations: 0,
4113 limitation_kinds: Vec::new(),
4114 }
4115 }
4116
4117 #[test]
4118 fn a_scope_is_grouped_by_place_and_names_the_roots_that_settle_it() {
4119 let unclassified = "unclassified first-party source";
4120 let entries = [
4121 scope_entry("components/a.tsx", "ambiguous", unclassified, None),
4122 scope_entry("components/ui/b.tsx", "ambiguous", unclassified, None),
4123 scope_entry("proxy.ts", "ambiguous", unclassified, None),
4124 scope_entry(
4125 "app/page.tsx",
4126 "included",
4127 "discovered package source root",
4128 None,
4129 ),
4130 scope_entry(
4131 "tests/a.test.ts",
4132 "excluded",
4133 "test or fixture source",
4134 None,
4135 ),
4136 scope_entry(
4137 "next.config.ts",
4138 "excluded",
4139 "build/test/tool configuration",
4140 None,
4141 ),
4142 scope_entry(
4143 "packages/ui/extra/c.ts",
4144 "ambiguous",
4145 unclassified,
4146 Some("packages/ui"),
4147 ),
4148 scope_entry(
4149 "packages/ui/src/d.ts",
4150 "included",
4151 "discovered package source root",
4152 Some("packages/ui"),
4153 ),
4154 ];
4155 let groups = scope_groups(&entries)
4156 .into_iter()
4157 .map(|group| (group.files, group.status, group.directory))
4158 .collect::<Vec<_>>();
4159 assert_eq!(
4160 groups,
4161 [
4162 (2, "ambiguous".to_owned(), "components".to_owned()),
4163 (1, "ambiguous".into(), ".".into()),
4164 (1, "ambiguous".into(), "packages/ui/extra".into()),
4165 (1, "included".into(), "app".into()),
4166 (1, "included".into(), "packages/ui/src".into()),
4167 (1, "excluded".into(), ".".into()),
4168 (1, "excluded".into(), "tests".into()),
4169 ]
4170 );
4171 let hint =
4174 unclassified_source(&["app".into(), "packages/ui/src".into()], &entries).unwrap();
4175 assert_eq!(
4176 hint.roots,
4177 [
4178 "app",
4179 "packages/ui/src",
4180 "components",
4181 "packages/ui/extra",
4182 "proxy.ts"
4183 ]
4184 );
4185 assert_eq!(hint.directories[0].directory, "components");
4186 assert_eq!(hint.directories[0].files, 2);
4187 assert_eq!(unclassified_source(&[], &entries[3..6]), None);
4188 }
4189
4190 #[test]
4191 fn a_file_is_counted_under_its_first_folders() {
4192 assert_eq!(area_directory("app/api/cron/route.ts", 1), "app");
4193 assert_eq!(area_directory("app/api/cron/route.ts", 2), "app/api");
4194 assert_eq!(area_directory("app/page.tsx", 2), "app");
4195 assert_eq!(area_directory("proxy.ts", 2), ".");
4196 }
4197
4198 #[test]
4199 fn all_metric_keeps_statement_only_files_in_the_gap_set() {
4200 let gap = IndexedFileGap {
4201 view: CoverageViewId::All,
4202 file: "src/statement.js".into(),
4203 uncovered_lines: 0,
4204 uncovered_statements: 1,
4205 uncovered_functions: 0,
4206 missing_branches: 0,
4207 missing_mcdc_conditions: 0,
4208 measurement_limitations: 0,
4209 limitation_kinds: Vec::new(),
4210 covered_by_other_tests: crate::coverage_index::IndexedGapDimensions {
4211 lines: 0,
4212 statements: 0,
4213 functions: 0,
4214 branches: 0,
4215 mcdc_conditions: 0,
4216 },
4217 uncovered_everywhere: crate::coverage_index::IndexedGapDimensions {
4218 lines: 0,
4219 statements: 1,
4220 functions: 0,
4221 branches: 0,
4222 mcdc_conditions: 0,
4223 },
4224 totals: crate::coverage_index::IndexedGapDimensions {
4225 lines: 1,
4226 statements: 2,
4227 functions: 0,
4228 branches: 0,
4229 mcdc_conditions: 0,
4230 },
4231 covered: crate::coverage_index::IndexedGapDimensions {
4232 lines: 1,
4233 statements: 1,
4234 functions: 0,
4235 branches: 0,
4236 mcdc_conditions: 0,
4237 },
4238 score: 0,
4239 };
4240 assert!(has_gap_for_metric(&gap, MinimizeMetric::All));
4241 assert!(has_gap_for_metric(&gap, MinimizeMetric::Statements));
4242 assert!(!has_gap_for_metric(&gap, MinimizeMetric::Lines));
4243 }
4244
4245 fn result(id: &str, vector: McdcVector) -> RawTestResult {
4246 RawTestResult {
4247 test_id: Some(id.into()),
4248 scope: None,
4249 test: id.into(),
4250 test_file: Some("tests/permission.test.js".into()),
4251 title: None,
4252 retry: Some(0),
4253 status: Some("passed".into()),
4254 expected_status: None,
4255 flaky: false,
4256 provenance: TestProvenance {
4257 runner: "node:test".into(),
4258 kind: "unit".into(),
4259 project: None,
4260 source: "runner-default".into(),
4261 },
4262 role: "test".into(),
4263 attribution: crate::coverage_report::ATTRIBUTION_EXACT.into(),
4264 phases: Vec::new(),
4265 runtime: vec![RuntimeSnapshot {
4266 decisions: vec![crate::coverage_report::DecisionSnapshot {
4267 meta: decision(),
4268 vectors: vec![vector],
4269 }],
4270 hits: Vec::new(),
4271 events: Vec::new(),
4272 logicals: Vec::new(),
4273 phase_id: None,
4274 }],
4275 browser: Vec::new(),
4276 server: Vec::new(),
4277 }
4278 }
4279
4280 fn decision() -> DecisionMeta {
4281 DecisionMeta {
4282 id: "decision".into(),
4283 file: "src/permission.js".into(),
4284 line: 1,
4285 column: 1,
4286 source: "admin || owner".into(),
4287 conditions: vec!["admin".into(), "owner".into()],
4288 kind: "if".into(),
4289 }
4290 }
4291
4292 fn report(mut results: Vec<RawTestResult>) -> CoverageReport {
4293 analyze_coverage_results(&CoverageReportRequest {
4294 run_id: "run".into(),
4295 manifest: CoverageManifest {
4296 unmeasured: Vec::new(),
4297 decisions: vec![decision()],
4298 points: Vec::new(),
4299 branches: Vec::new(),
4300 limitations: Vec::new(),
4301 scope: None,
4302 assertion_sites: Vec::new(),
4303 },
4304 raw_results: std::mem::take(&mut results),
4305 generated_at: "time".into(),
4306 coverage_model: None,
4307 integrity: None,
4308 test_exit_code: ExitCodeInput::Present(Some(0)),
4309 })
4310 .unwrap()
4311 }
4312
4313 #[test]
4314 fn recomputes_mcdc_witnesses_and_removes_a_redundant_vector() {
4315 let report = report(vec![
4316 result(
4317 "admin",
4318 McdcVector {
4319 values: vec![Some(true), None],
4320 outcome: true,
4321 },
4322 ),
4323 result(
4324 "owner",
4325 McdcVector {
4326 values: vec![Some(false), Some(true)],
4327 outcome: true,
4328 },
4329 ),
4330 result(
4331 "both",
4332 McdcVector {
4333 values: vec![Some(true), None],
4334 outcome: true,
4335 },
4336 ),
4337 result(
4338 "neither",
4339 McdcVector {
4340 values: vec![Some(false), Some(false)],
4341 outcome: false,
4342 },
4343 ),
4344 ]);
4345 let minimized = minimum_test_set(&report.view, 100.0, MinimizeMetric::Mcdc, 5_000).unwrap();
4346 assert_eq!(minimized.selected.len(), 3);
4347 assert!(minimized.selected.contains(&"owner".into()));
4348 assert!(minimized.selected.contains(&"neither".into()));
4349 assert_eq!(minimized.summary.condition_coverage_pct, 100.0);
4350 }
4351
4352 #[test]
4353 fn a_test_that_belongs_to_no_test_is_not_minimized_away() {
4354 let mut parallel = result(
4361 "parallel",
4362 McdcVector {
4363 values: vec![Some(false), Some(false)],
4364 outcome: false,
4365 },
4366 );
4367 parallel.attribution = crate::coverage_report::ATTRIBUTION_RUN_WIDE.into();
4368 assert!(matches!(
4369 minimum_test_set(
4370 &report(vec![parallel]).view,
4371 100.0,
4372 MinimizeMetric::Mcdc,
4373 5_000,
4374 ),
4375 Err(QueryError::UnattributedEvidence)
4376 ));
4377 }
4378
4379 #[test]
4380 fn the_summary_counts_each_test_under_the_attribution_it_carries() {
4381 let vector = || McdcVector {
4385 values: vec![Some(false), Some(false)],
4386 outcome: false,
4387 };
4388 let mut serial = result("serial", vector());
4389 serial.attribution = crate::coverage_report::ATTRIBUTION_EXACT.into();
4390 let mut parallel = result("parallel", vector());
4391 parallel.attribution = crate::coverage_report::ATTRIBUTION_RUN_WIDE.into();
4392 let mut ruby = result("ruby", vector());
4393 ruby.attribution = crate::coverage_report::ATTRIBUTION_PARTIAL.into();
4394 let mut also_parallel = result("also-parallel", vector());
4395 also_parallel.attribution = crate::coverage_report::ATTRIBUTION_RUN_WIDE.into();
4396 let report = report(vec![serial, parallel, ruby, also_parallel]);
4397
4398 let nonce = std::time::SystemTime::now()
4399 .duration_since(std::time::UNIX_EPOCH)
4400 .unwrap()
4401 .as_nanos();
4402 let root = std::env::temp_dir().join(format!(
4403 "supercov-attribution-counts-{}-{nonce}",
4404 std::process::id()
4405 ));
4406 std::fs::create_dir(&root).unwrap();
4407 let path = root.join("query-index.v1.bin");
4408 let identity = crate::query_index::QueryIndexIdentity {
4409 evidence_sha256: [1; 32],
4410 evidence_bytes: 100,
4411 analysis_sha256: [2; 32],
4412 producer_sha256: [3; 32],
4413 archive_schema_version: 2,
4414 };
4415 crate::query_index::write_query_index(
4416 &crate::coverage_index::coverage_index_sections(&report).unwrap(),
4417 &identity,
4418 &path,
4419 )
4420 .unwrap();
4421 let container = crate::query_index::QueryIndex::open(&path, &identity).unwrap();
4422 let index = crate::coverage_index::CoverageIndex::new(&container).unwrap();
4423
4424 let summary = coverage_summary_query(
4425 &index,
4426 CoverageSummaryQueryOptions {
4427 run: "run",
4428 view: CoverageViewId::All,
4429 kind: None,
4430 runner: None,
4431 valid: true,
4432 test_exit_code: Some(0),
4433 stale: false,
4434 stale_reasons: Vec::new(),
4435 },
4436 )
4437 .unwrap();
4438 assert_eq!(summary.test_attribution.exact, 1);
4439 assert_eq!(summary.test_attribution.run_wide, 2);
4440 assert_eq!(summary.test_attribution.partial, 1);
4441 assert_eq!(summary.test_attribution.total(), 4);
4442 std::fs::remove_dir_all(&root).ok();
4443 }
4444
4445 #[test]
4446 fn refuses_background_evidence() {
4447 let mut aggregate = result(
4448 "aggregate",
4449 McdcVector {
4450 values: vec![Some(false), Some(false)],
4451 outcome: false,
4452 },
4453 );
4454 aggregate.role = "background".into();
4455 assert!(matches!(
4456 minimum_test_set(
4457 &report(vec![aggregate]).view,
4458 100.0,
4459 MinimizeMetric::Mcdc,
4460 5_000,
4461 ),
4462 Err(QueryError::UnattributedEvidence)
4463 ));
4464 }
4465
4466 #[test]
4467 fn bounds_the_exact_search() {
4468 let report = report(vec![
4469 result(
4470 "admin",
4471 McdcVector {
4472 values: vec![Some(true), None],
4473 outcome: true,
4474 },
4475 ),
4476 result(
4477 "owner",
4478 McdcVector {
4479 values: vec![Some(false), Some(true)],
4480 outcome: true,
4481 },
4482 ),
4483 result(
4484 "neither",
4485 McdcVector {
4486 values: vec![Some(false), Some(false)],
4487 outcome: false,
4488 },
4489 ),
4490 ]);
4491 assert!(matches!(
4492 minimum_test_set(&report.view, 100.0, MinimizeMetric::Mcdc, 1),
4493 Err(QueryError::ComplexityLimit { .. })
4494 ));
4495 }
4496}