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