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