Skip to main content

supercov_engine/
coverage_query.rs

1//! Language-neutral coverage query operators.
2//!
3//! Querying is deliberately separated from the CLI and storage container.
4//! This module accepts the frozen analyzed view and owns structural query
5//! semantics shared by every language frontend.
6
7use 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    // Covered lines are absent from the gap projection, but their anchored
771    // statement/branch/decision metadata still carries the source snippet.
772    // Prefer the exact gap-line text when present and otherwise retain that
773    // anchored source so a successful line query never contradicts itself by
774    // claiming the source is unavailable while printing it below.
775    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
1372/// Reconstruct the exact decision view used by provenance-filtered queries.
1373/// Project filters are applied against the immutable query index.
1374pub 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    /// Measured lines in the file, and how many the selected tests covered:
1627    /// the file's own line coverage, comparable with other tools'.
1628    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        // A file with no decisions is valid, so consult the file projection to
2641        // distinguish it from a nonexistent test provenance projection.
2642        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    /// Any one complete option satisfies this obligation.
2821    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}