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