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