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