Skip to main content

supercov_engine/
coverage_query.rs

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