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