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