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