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