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