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