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