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