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::interned::Id;
12use crate::{
13    agent_json::pagination,
14    coverage_analysis::{CoverageSummary, is_independence_pair},
15    coverage_index::{
16        CoverageDimension, CoverageIndex, CoverageIndexError, CoverageViewId, IndexedCoverageModel,
17        IndexedDecisionGap, IndexedDimensionCoverage, IndexedFileGap, IndexedGapDimensions,
18        IndexedHitMetadata, IndexedMeasurement, IndexedOutcomeCounts, IndexedScopeEntry,
19        IndexedSourceScope, IndexedSummaryConfidence, IndexedTestSummary,
20    },
21    coverage_report::{
22        CoverageConfidence, CoverageView, DecisionMeta, ReportError, SourceLine, TestAttempt,
23        TestProvenance, TransportStats, 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, Eq, Serialize)]
53pub struct CoverageMinimizedTest {
54    pub id: String,
55    pub name: String,
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub file: Option<String>,
58    pub runner: String,
59    pub kind: String,
60}
61
62#[derive(Debug, Clone, PartialEq, Serialize)]
63#[serde(rename_all = "camelCase")]
64pub struct CoverageMinimizeData {
65    pub run: String,
66    pub filters: CoverageQueryFilters,
67    pub optimal: bool,
68    #[serde(serialize_with = "crate::coverage_analysis::serialize_javascript_number")]
69    pub target: f64,
70    pub metric: MinimizeMetric,
71    pub selected: Vec<String>,
72    pub expanded: Vec<String>,
73    pub summary: CoverageSummary,
74    pub explored_states: usize,
75    pub selected_count: usize,
76    pub total_candidate_tests: usize,
77    pub tests: Vec<CoverageMinimizedTest>,
78}
79
80#[derive(Debug, Clone, Copy)]
81pub struct CoverageMinimizeQueryOptions<'a> {
82    pub run: &'a str,
83    pub view_id: CoverageViewId,
84    pub kind: Option<&'a str>,
85    pub runner: Option<&'a str>,
86    pub target: f64,
87    pub metric: MinimizeMetric,
88    pub max_states: usize,
89    pub offset: usize,
90    pub limit: usize,
91}
92
93pub fn coverage_minimize_query(
94    view: &CoverageView,
95    options: CoverageMinimizeQueryOptions<'_>,
96) -> Result<(CoverageMinimizeData, AgentPagination), QueryError> {
97    if options.limit == 0 {
98        return Err(QueryError::InvalidPagination);
99    }
100    let selected_ids = if options.kind.is_none() && options.runner.is_none() {
101        None
102    } else {
103        let ids = view
104            .tests
105            .iter()
106            .filter(|test| {
107                options.kind.is_none_or(|kind| test.provenance.kind == kind)
108                    && options
109                        .runner
110                        .is_none_or(|runner| test.provenance.runner == runner)
111            })
112            .map(|test| test.id.clone())
113            .collect::<BTreeSet<_>>();
114        if ids.is_empty() {
115            return Err(QueryError::TestFilterEmpty {
116                kind: options.kind.map(str::to_owned),
117                runner: options.runner.map(str::to_owned),
118            });
119        }
120        Some(ids)
121    };
122    let mut solver_view = view.clone();
123    if let Some(selected) = &selected_ids {
124        solver_view.tests.retain(|test| selected.contains(&test.id));
125    }
126    let minimized = minimum_test_set(
127        &solver_view,
128        options.target,
129        options.metric,
130        options.max_states,
131    )?;
132    let selected_details = minimized
133        .selected
134        .iter()
135        .map(|id| {
136            let test = view
137                .tests
138                .iter()
139                .find(|test| test.id == *id)
140                .ok_or(QueryError::InvalidRecordSelection)?;
141            Ok(CoverageMinimizedTest {
142                id: id.clone(),
143                name: test.name.clone(),
144                file: test.file.clone(),
145                runner: test.provenance.runner.clone(),
146                kind: test.provenance.kind.clone(),
147            })
148        })
149        .collect::<Result<Vec<_>, QueryError>>()?;
150    let total = selected_details.len();
151    let tests = selected_details
152        .iter()
153        .skip(options.offset)
154        .take(options.limit)
155        .cloned()
156        .collect::<Vec<_>>();
157    let returned = tests.len();
158    let total_candidate_tests = solver_view
159        .tests
160        .iter()
161        .filter(|test| test.role == "test")
162        .count();
163    Ok((
164        CoverageMinimizeData {
165            run: options.run.into(),
166            filters: query_filters(options.view_id, options.kind, options.runner),
167            optimal: minimized.optimal,
168            target: minimized.target,
169            metric: minimized.metric,
170            selected: minimized.selected,
171            expanded: minimized.expanded,
172            summary: minimized.summary,
173            explored_states: minimized.explored_states,
174            selected_count: total,
175            total_candidate_tests,
176            tests,
177        },
178        pagination(options.offset, options.limit, returned, total),
179    ))
180}
181
182#[derive(Debug)]
183pub enum QueryError {
184    InvalidTarget(f64),
185    UnattributedEvidence,
186    TargetUnreachable {
187        metric: MinimizeMetric,
188        target: f64,
189        reachable: f64,
190    },
191    ComplexityLimit {
192        candidate_tests: usize,
193        obligations: usize,
194        explored_states: usize,
195        max_states: usize,
196        target: f64,
197        metric: MinimizeMetric,
198    },
199    Analysis(ReportError),
200    Index(CoverageIndexError),
201    InvalidPagination,
202    TestFilterEmpty {
203        kind: Option<String>,
204        runner: Option<String>,
205    },
206    TestNotFound(String),
207    DecisionNotFound(String),
208    SourceNotFound(String),
209    AmbiguousSelector {
210        selector: String,
211        matches: Vec<String>,
212    },
213    InvalidRecordSelection,
214    ScopeUnavailable,
215}
216
217impl From<ReportError> for QueryError {
218    fn from(value: ReportError) -> Self {
219        Self::Analysis(value)
220    }
221}
222
223impl From<CoverageIndexError> for QueryError {
224    fn from(value: CoverageIndexError) -> Self {
225        Self::Index(value)
226    }
227}
228
229#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
230#[serde(rename_all = "camelCase")]
231pub struct CoverageQueryFilters {
232    pub outcome: String,
233    pub kind: Option<String>,
234    pub runner: Option<String>,
235}
236
237#[derive(Debug, Clone, PartialEq, Serialize)]
238#[serde(rename_all = "camelCase")]
239pub struct CoverageFilesData {
240    pub run: String,
241    pub filters: CoverageQueryFilters,
242    pub metric: MinimizeMetric,
243    pub files: Vec<IndexedFileGap>,
244}
245
246#[derive(Debug, Clone, PartialEq, Serialize)]
247#[serde(rename_all = "camelCase")]
248pub struct CoverageGapsData {
249    pub run: String,
250    pub filters: CoverageQueryFilters,
251    pub metric: MinimizeMetric,
252    pub gaps: Vec<IndexedFileGap>,
253}
254
255#[derive(Debug, Clone, PartialEq, Serialize)]
256#[serde(rename_all = "camelCase")]
257pub struct CoverageKindsData {
258    pub run: String,
259    pub filters: CoverageQueryFilters,
260    pub kinds: Vec<IndexedDimensionCoverage>,
261}
262
263#[derive(Debug, Clone, PartialEq, Serialize)]
264#[serde(rename_all = "camelCase")]
265pub struct CoverageRunnersData {
266    pub run: String,
267    pub filters: CoverageQueryFilters,
268    pub runners: Vec<IndexedDimensionCoverage>,
269}
270
271/// Tests by how completely their coverage is their own.
272#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
273#[serde(rename_all = "camelCase")]
274pub struct TestAttributionCounts {
275    /// Credited with exactly what they reached.
276    pub exact: usize,
277    /// Credited with exactly what they reached, and with server work no
278    /// request named that ran while each was the only test running.
279    pub by_time: usize,
280    /// Credited with some of what they reached, and no way to say how much.
281    pub partial: usize,
282    /// Credited with none of it; what they reached is in the run's totals.
283    pub run_wide: usize,
284}
285
286impl TestAttributionCounts {
287    pub fn total(&self) -> usize {
288        self.exact + self.by_time + self.partial + self.run_wide
289    }
290}
291
292#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
293#[serde(rename_all = "camelCase")]
294pub struct CoverageDiagnostic {
295    pub code: String,
296    pub severity: String,
297    pub message: String,
298}
299
300#[derive(Debug, Clone, PartialEq, Serialize)]
301#[serde(rename_all = "camelCase")]
302pub struct CoverageSummaryData {
303    pub test_kind_sources: BTreeMap<String, usize>,
304    #[serde(skip_serializing_if = "Option::is_none")]
305    pub assertion_coverage: Option<serde_json::Value>,
306    pub run: String,
307    #[serde(default)]
308    pub command: Vec<String>,
309    /// For a merge made with `--allow-changed`: what differed between the
310    /// runs it was merged from.
311    #[serde(default, skip_serializing_if = "Vec::is_empty")]
312    pub merged_across: Vec<String>,
313    /// The runs the same command published beside this one: its servers'
314    /// runs, or the browser run a server's run took its tests from.
315    #[serde(default, skip_serializing_if = "Vec::is_empty")]
316    pub companions: Vec<crate::run_store::CompanionRun>,
317    #[serde(default, skip_serializing_if = "Vec::is_empty")]
318    pub hints: Vec<String>,
319    #[serde(skip_serializing_if = "Option::is_none")]
320    pub workspace: Option<String>,
321    pub filters: CoverageQueryFilters,
322    pub model: IndexedCoverageModel,
323    pub generated_at: String,
324    pub valid: bool,
325    #[serde(skip_serializing_if = "Option::is_none")]
326    pub test_exit_code: Option<i32>,
327    pub stale: bool,
328    pub stale_reasons: Vec<String>,
329    pub structurally_complete: bool,
330    pub complete: bool,
331    pub coverage: CoverageSummary,
332    pub measurement: IndexedMeasurement,
333    pub coverage_by_kind: Vec<IndexedDimensionCoverage>,
334    /// What the tests cover between them. `coverage` less this ran while no
335    /// test was running, which is why a run with one kind of test can show a
336    /// total above that kind's own.
337    #[serde(skip_serializing_if = "Option::is_none")]
338    pub coverage_by_tests: Option<CoverageSummary>,
339    #[serde(skip_serializing_if = "Option::is_none")]
340    pub e2e_gap_context: Option<CoverageKindGapContext>,
341    pub coverage_by_runner: Vec<IndexedDimensionCoverage>,
342    pub attribution: crate::coverage_index::IndexedAttribution,
343    /// How completely each test's coverage could be credited to it.
344    ///
345    /// Counted here rather than stored, because the per-test answer is already
346    /// in the run and a total is a read of it. A run written before the field
347    /// existed reports every test as exact, which is what its records say.
348    pub test_attribution: TestAttributionCounts,
349    #[serde(skip_serializing_if = "Option::is_none")]
350    pub transport: Option<TransportStats>,
351    pub diagnostics: Vec<CoverageDiagnostic>,
352    #[serde(skip_serializing_if = "Option::is_none")]
353    pub confidence: Option<IndexedSummaryConfidence>,
354    pub files_with_gaps: usize,
355    pub files_with_coverage_gaps: usize,
356    pub files_with_measurement_limitations: usize,
357    pub tests: usize,
358    pub setups: usize,
359    pub test_outcomes: IndexedOutcomeCounts,
360    #[serde(skip_serializing_if = "Option::is_none")]
361    pub source_scope: Option<IndexedSourceScope>,
362    /// Where the files discovery could not classify are, and the roots that
363    /// would settle them.
364    #[serde(skip_serializing_if = "Option::is_none")]
365    pub unclassified_source: Option<UnclassifiedSource>,
366}
367
368/// One row of the scope overview: the files of one place that share a status
369/// and the reason for it.
370#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
371#[serde(rename_all = "camelCase")]
372pub struct ScopeGroup {
373    pub status: String,
374    pub directory: String,
375    pub reason: String,
376    pub files: usize,
377}
378
379#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
380#[serde(rename_all = "camelCase")]
381pub struct ScopeDirectory {
382    pub directory: String,
383    pub files: usize,
384}
385
386/// The unclassified files of a run by where they are, and the source roots
387/// to name if they are the project's own code: the roots discovery found
388/// plus each of those places.
389#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
390#[serde(rename_all = "camelCase")]
391pub struct UnclassifiedSource {
392    pub directories: Vec<ScopeDirectory>,
393    pub roots: Vec<String>,
394}
395
396/// Where a reader would look for a file: the directory at the top of the
397/// project, or of its package in a workspace. A file that sits at that top
398/// is in `.`.
399fn scope_directory(entry: &IndexedScopeEntry) -> String {
400    let package = entry.package_root.as_deref().filter(|root| *root != ".");
401    let local = package
402        .and_then(|root| entry.file.strip_prefix(root))
403        .and_then(|rest| rest.strip_prefix('/'))
404        .unwrap_or(&entry.file);
405    match (package, local.split_once('/')) {
406        (Some(package), Some((first, _))) => format!("{package}/{first}"),
407        (Some(package), None) => package.to_owned(),
408        (None, Some((first, _))) => first.to_owned(),
409        (None, None) => ".".to_owned(),
410    }
411}
412
413fn scope_status_rank(status: &str) -> u8 {
414    match status {
415        "ambiguous" => 0,
416        "included" => 1,
417        "excluded" => 2,
418        _ => 3,
419    }
420}
421
422/// Every scope entry, grouped. 845 entries took five pages to read and an
423/// `awk` to see that 79 of 89 unclassified files were one directory.
424pub fn scope_groups(entries: &[IndexedScopeEntry]) -> Vec<ScopeGroup> {
425    let mut groups = BTreeMap::<(u8, String, String, String), usize>::new();
426    for entry in entries {
427        *groups
428            .entry((
429                scope_status_rank(&entry.status),
430                entry.status.clone(),
431                scope_directory(entry),
432                entry.reason.clone(),
433            ))
434            .or_default() += 1;
435    }
436    let mut groups = groups
437        .into_iter()
438        .map(|((rank, status, directory, reason), files)| {
439            (
440                rank,
441                ScopeGroup {
442                    status,
443                    directory,
444                    reason,
445                    files,
446                },
447            )
448        })
449        .collect::<Vec<_>>();
450    groups.sort_by(|(left_rank, left), (right_rank, right)| {
451        left_rank
452            .cmp(right_rank)
453            .then_with(|| right.files.cmp(&left.files))
454            .then_with(|| left.directory.cmp(&right.directory))
455            .then_with(|| left.reason.cmp(&right.reason))
456    });
457    groups.into_iter().map(|(_, group)| group).collect()
458}
459
460pub fn unclassified_source(
461    roots: &[String],
462    entries: &[IndexedScopeEntry],
463) -> Option<UnclassifiedSource> {
464    let mut places = BTreeMap::<String, usize>::new();
465    let mut named = Vec::<(String, usize)>::new();
466    for entry in entries.iter().filter(|entry| entry.status == "ambiguous") {
467        let directory = scope_directory(entry);
468        *places.entry(directory.clone()).or_default() += 1;
469        // A file at the top is named itself; naming `.` would take the tests
470        // and the configuration with it.
471        let root = if entry.file.contains('/') && directory != "." {
472            directory
473        } else {
474            entry.file.clone()
475        };
476        match named.iter_mut().find(|(name, _)| *name == root) {
477            Some((_, files)) => *files += 1,
478            None => named.push((root, 1)),
479        }
480    }
481    if places.is_empty() {
482        return None;
483    }
484    let mut directories = places
485        .into_iter()
486        .map(|(directory, files)| ScopeDirectory { directory, files })
487        .collect::<Vec<_>>();
488    directories.sort_by(|left, right| {
489        right
490            .files
491            .cmp(&left.files)
492            .then_with(|| left.directory.cmp(&right.directory))
493    });
494    named.sort_by(|left, right| right.1.cmp(&left.1).then_with(|| left.0.cmp(&right.0)));
495    let mut all = roots.to_vec();
496    for (root, _) in named {
497        if !all.contains(&root) {
498            all.push(root);
499        }
500    }
501    Some(UnclassifiedSource {
502        directories,
503        roots: all,
504    })
505}
506
507#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
508#[serde(rename_all = "camelCase")]
509pub struct CoverageKindGapContext {
510    pub kind: String,
511    pub other_kinds: Vec<String>,
512    pub covered_elsewhere: IndexedGapDimensions,
513    pub uncovered_everywhere: IndexedGapDimensions,
514}
515
516#[derive(Debug, Clone)]
517pub struct CoverageSummaryQueryOptions<'a> {
518    pub run: &'a str,
519    pub view: CoverageViewId,
520    pub kind: Option<&'a str>,
521    pub runner: Option<&'a str>,
522    pub valid: bool,
523    pub test_exit_code: Option<i32>,
524    pub stale: bool,
525    pub stale_reasons: Vec<String>,
526}
527
528#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
529pub struct ScopeCounts {
530    pub included: usize,
531    pub excluded: usize,
532    pub ambiguous: usize,
533}
534
535#[derive(Debug, Clone, PartialEq, Serialize)]
536#[serde(rename_all = "camelCase")]
537pub struct CoverageScopeData {
538    pub run: String,
539    pub filters: CoverageQueryFilters,
540    pub kind: String,
541    pub language: String,
542    pub model: String,
543    #[serde(skip_serializing_if = "Option::is_none")]
544    pub mode: Option<String>,
545    pub roots: Vec<String>,
546    #[serde(skip_serializing_if = "Option::is_none")]
547    pub unit: Option<String>,
548    #[serde(skip_serializing_if = "Option::is_none")]
549    pub measurement_complete: Option<bool>,
550    pub counts: ScopeCounts,
551    pub measurement: IndexedMeasurement,
552    /// Every entry of the scope, grouped; `entries` is one page of them.
553    pub groups: Vec<ScopeGroup>,
554    #[serde(skip_serializing_if = "Option::is_none")]
555    pub unclassified_source: Option<UnclassifiedSource>,
556    pub entries: Vec<IndexedScopeEntry>,
557}
558
559#[derive(Debug, Clone, Copy)]
560pub struct CoverageScopeQueryOptions<'a> {
561    pub run: &'a str,
562    pub view: CoverageViewId,
563    pub kind: Option<&'a str>,
564    pub runner: Option<&'a str>,
565    pub offset: usize,
566    pub limit: usize,
567}
568
569#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
570pub struct CoverageLocation {
571    pub file: String,
572    pub line: usize,
573}
574
575#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
576pub struct CoverageCoveringTest {
577    pub id: String,
578    pub name: String,
579    pub provenance: TestProvenance,
580}
581
582#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
583#[serde(rename_all = "camelCase")]
584pub struct CoverageCoveringPhase {
585    pub id: String,
586    pub kind: String,
587    pub operation: String,
588    #[serde(skip_serializing_if = "Option::is_none")]
589    pub source: Option<String>,
590    pub test: String,
591    #[serde(skip_serializing_if = "Option::is_none")]
592    pub status: Option<String>,
593    #[serde(skip_serializing_if = "Option::is_none")]
594    pub caused_by_phase_id: Option<String>,
595}
596
597#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
598#[serde(rename_all = "camelCase")]
599pub struct CoverageAnchor {
600    pub kind: String,
601    pub id: String,
602    pub column: usize,
603    pub covered: bool,
604    #[serde(skip_serializing_if = "Option::is_none")]
605    pub source: Option<String>,
606    #[serde(skip_serializing_if = "Option::is_none")]
607    pub missing: Option<String>,
608    pub covering_tests: usize,
609    #[serde(skip_serializing_if = "Option::is_none")]
610    pub covered_conditions: Option<usize>,
611    #[serde(skip_serializing_if = "Option::is_none")]
612    pub conditions: Option<usize>,
613}
614
615#[derive(Debug, Clone, PartialEq, Serialize)]
616#[serde(rename_all = "camelCase")]
617pub struct CoverageCoversLineData {
618    pub run: String,
619    pub filters: CoverageQueryFilters,
620    pub location: CoverageLocation,
621    #[serde(skip_serializing_if = "Option::is_none")]
622    pub source: Option<String>,
623    #[serde(skip_serializing_if = "Option::is_none")]
624    pub source_origin: Option<String>,
625    pub covered: bool,
626    pub confidence: CoverageConfidence,
627    pub total_tests: usize,
628    pub total_phases: usize,
629    pub total_anchored: usize,
630    pub covered_anchored: usize,
631    pub total_limitations: usize,
632    pub total_remaining: usize,
633    pub tests: Vec<CoverageCoveringTest>,
634    pub phases: Vec<CoverageCoveringPhase>,
635    pub anchored: Vec<CoverageAnchor>,
636    pub limitations: Vec<CoverageFileLimitation>,
637    pub remaining: Vec<CoverageFileObligation>,
638    /// The run's saved assertion assessment for this place, attached by the
639    /// CLI when the run has one.
640    #[serde(skip_serializing_if = "Option::is_none")]
641    pub assertions: Option<serde_json::Value>,
642}
643
644#[derive(Debug, Clone, PartialEq, Serialize)]
645#[serde(rename_all = "camelCase")]
646pub struct CoverageCoversAnchorsData {
647    pub run: String,
648    pub filters: CoverageQueryFilters,
649    pub location: CoverageLocation,
650    #[serde(skip_serializing_if = "Option::is_none")]
651    pub source: Option<String>,
652    #[serde(skip_serializing_if = "Option::is_none")]
653    pub source_origin: Option<String>,
654    pub line_obligation: bool,
655    pub anchored: Vec<CoverageAnchor>,
656    pub total_anchored: usize,
657    pub covered_anchored: usize,
658    pub total_limitations: usize,
659    pub limitations: Vec<CoverageFileLimitation>,
660    pub total_remaining: usize,
661    pub remaining: Vec<CoverageFileObligation>,
662    pub total_tests: usize,
663    pub tests: Vec<CoverageCoveringTest>,
664    /// The run's saved assertion assessment for this place, attached by the
665    /// CLI when the run has one.
666    #[serde(skip_serializing_if = "Option::is_none")]
667    pub assertions: Option<serde_json::Value>,
668}
669
670#[derive(Debug, Clone, PartialEq, Serialize)]
671#[serde(untagged)]
672pub enum CoverageCoversData {
673    Line(CoverageCoversLineData),
674    Anchors(CoverageCoversAnchorsData),
675}
676
677#[derive(Debug, Clone, Copy)]
678pub struct CoverageCoversQueryOptions<'a> {
679    pub run: &'a str,
680    pub view: CoverageViewId,
681    pub kind: Option<&'a str>,
682    pub runner: Option<&'a str>,
683    pub file: &'a str,
684    pub line: usize,
685    pub offset: usize,
686    pub limit: usize,
687}
688
689fn query_filters(
690    view: CoverageViewId,
691    kind: Option<&str>,
692    runner: Option<&str>,
693) -> CoverageQueryFilters {
694    CoverageQueryFilters {
695        outcome: match view {
696            CoverageViewId::All => "all",
697            CoverageViewId::Passed => "passed",
698            CoverageViewId::Failed => "failed",
699        }
700        .into(),
701        kind: kind.map(str::to_owned),
702        runner: runner.map(str::to_owned),
703    }
704}
705
706fn selected_test_ids(
707    tests: &[IndexedTestSummary],
708    kind: Option<&str>,
709    runner: Option<&str>,
710) -> Result<Option<BTreeSet<String>>, QueryError> {
711    if kind.is_none() && runner.is_none() {
712        return Ok(None);
713    }
714    let selected = tests
715        .iter()
716        .filter(|test| {
717            kind.is_none_or(|kind| test.provenance.kind == kind)
718                && runner.is_none_or(|runner| test.provenance.runner == runner)
719        })
720        .map(|test| test.id.clone())
721        .collect::<BTreeSet<_>>();
722    if selected.is_empty() {
723        return Err(QueryError::TestFilterEmpty {
724            kind: kind.map(str::to_owned),
725            runner: runner.map(str::to_owned),
726        });
727    }
728    Ok(Some(selected))
729}
730
731pub fn coverage_covers_query(
732    index: &CoverageIndex<'_>,
733    options: CoverageCoversQueryOptions<'_>,
734) -> Result<(CoverageCoversData, AgentPagination), QueryError> {
735    if options.limit == 0 {
736        return Err(QueryError::InvalidPagination);
737    }
738    let tests = index.test_summaries(options.view)?;
739    let selected = selected_test_ids(&tests, options.kind, options.runner)?;
740    let selected_includes = |id: &str| selected.as_ref().is_none_or(|ids| ids.contains(id));
741    let filters = query_filters(options.view, options.kind, options.runner);
742    let location = CoverageLocation {
743        file: options.file.into(),
744        line: options.line,
745    };
746    let metadata = index
747        .hit_metadata(options.view)?
748        .into_iter()
749        .map(|value| (value.id.clone(), value))
750        .collect::<HashMap<_, _>>();
751    let decisions = index
752        .decision_details(options.view)?
753        .into_iter()
754        .map(|value| (value.meta.id.clone(), value))
755        .collect::<HashMap<_, _>>();
756    let anchors = index.anchors(options.view, options.file, options.line)?;
757    let tests_by_id = tests
758        .iter()
759        .map(|test| (test.id.clone(), test.clone()))
760        .collect::<HashMap<_, _>>();
761    let mut anchor_test_ids = anchors
762        .iter()
763        .flat_map(|anchor| anchor.tests.iter())
764        .filter(|id| selected_includes(id))
765        .cloned()
766        .collect::<BTreeSet<_>>();
767    for anchor in anchors.iter().filter(|anchor| anchor.kind == "branch") {
768        anchor_test_ids.extend(
769            metadata
770                .values()
771                .filter(|detail| detail.parent_id.as_deref() == Some(anchor.id.as_str()))
772                .flat_map(|detail| detail.tests.iter())
773                .filter(|id| selected_includes(id))
774                .cloned(),
775        );
776    }
777    let all_anchor_tests = anchor_test_ids
778        .iter()
779        .map(|id| {
780            let test = tests_by_id.get(id);
781            CoverageCoveringTest {
782                id: id.clone(),
783                name: test.map_or_else(|| id.clone(), |test| test.name.clone()),
784                provenance: test
785                    .map_or_else(TestProvenance::default, |test| test.provenance.clone()),
786            }
787        })
788        .collect::<Vec<_>>();
789    let total_anchor_tests = all_anchor_tests.len();
790    let anchor_tests_page = all_anchor_tests
791        .iter()
792        .skip(options.offset)
793        .take(options.limit)
794        .cloned()
795        .collect::<Vec<_>>();
796    let render_anchor = |anchor: crate::coverage_index::IndexedAnchor| {
797        let branch_alternatives = if anchor.kind == "branch" {
798            metadata
799                .values()
800                .filter(|detail| {
801                    detail.obligation == "branch"
802                        && detail.parent_id.as_deref() == Some(anchor.id.as_str())
803                })
804                .collect::<Vec<_>>()
805        } else {
806            Vec::new()
807        };
808        let branch_tests = branch_alternatives
809            .iter()
810            .flat_map(|detail| detail.tests.iter())
811            .filter(|test| selected_includes(test))
812            .cloned()
813            .collect::<BTreeSet<_>>();
814        let covering_tests = if anchor.kind == "branch" {
815            branch_tests.len()
816        } else {
817            anchor
818                .tests
819                .iter()
820                .filter(|test| selected_includes(test))
821                .count()
822        };
823        let detail = metadata.get(&anchor.id);
824        let decision = decisions
825            .get(&anchor.id)
826            .cloned()
827            .map(|decision| selected_decision(decision, selected.as_ref()));
828        let conditions = decision.as_ref().map_or(anchor.conditions, |decision| {
829            Some(decision.conditions.len())
830        });
831        let covered_conditions = decision
832            .as_ref()
833            .map_or(anchor.covered_conditions, |decision| {
834                Some(
835                    decision
836                        .conditions
837                        .iter()
838                        .filter(|condition| condition.covered)
839                        .count(),
840                )
841            });
842        let covered = match anchor.kind.as_str() {
843            "decision" => conditions == covered_conditions,
844            "branch" if !branch_alternatives.is_empty() => branch_alternatives
845                .iter()
846                .all(|detail| detail.tests.iter().any(|test| selected_includes(test))),
847            "branch" => anchor.covered,
848            _ => covering_tests > 0,
849        };
850        let branch_source = branch_alternatives
851            .first()
852            .map(|detail| detail.source.clone());
853        let missing_branch_alternatives = branch_alternatives
854            .iter()
855            .filter(|detail| !detail.tests.iter().any(|test| selected_includes(test)))
856            .filter_map(|detail| detail.alternative.clone())
857            .collect::<Vec<_>>();
858        CoverageAnchor {
859            kind: anchor.kind,
860            id: anchor.id,
861            column: anchor.column,
862            covered,
863            source: decision
864                .as_ref()
865                .map(|decision| decision.meta.source.clone())
866                .or(branch_source)
867                .or_else(|| {
868                    detail.map(|detail| {
869                        detail
870                            .label
871                            .clone()
872                            .unwrap_or_else(|| detail.source.clone())
873                    })
874                })
875                .and_then(|source| compact_source(&source)),
876            missing: if missing_branch_alternatives.is_empty() {
877                detail.and_then(|detail| detail.alternative.clone())
878            } else {
879                Some(missing_branch_alternatives.join("; "))
880            },
881            covering_tests,
882            covered_conditions,
883            conditions,
884        }
885    };
886    let rendered_anchors = anchors.into_iter().map(render_anchor).collect::<Vec<_>>();
887    let total_anchored = rendered_anchors.len();
888    let covered_anchored = rendered_anchors
889        .iter()
890        .filter(|anchor| anchor.covered)
891        .count();
892    let all_limitations = index
893        .limitations(options.view)?
894        .into_iter()
895        .filter(|limitation| limitation.file == options.file && limitation.line == options.line)
896        .map(|limitation| CoverageFileLimitation {
897            id: limitation.id,
898            kind: limitation.kind,
899            file: limitation.file,
900            line: limitation.line,
901            column: limitation.column,
902            source: limitation.source,
903            reason: limitation.reason,
904            blocking: limitation.blocking,
905            effect: "outside-measured-denominator".into(),
906        })
907        .collect::<Vec<_>>();
908    let total_limitations = all_limitations.len();
909    let limitations_page = all_limitations
910        .iter()
911        .skip(options.offset)
912        .take(options.limit)
913        .cloned()
914        .collect::<Vec<_>>();
915    let (file_detail, _) = coverage_file_detail_query(
916        index,
917        CoverageFileDetailOptions {
918            run: options.run,
919            view: options.view,
920            kind: options.kind,
921            runner: options.runner,
922            selector: options.file,
923            metric: MinimizeMetric::All,
924            offset: 0,
925            limit: usize::MAX,
926        },
927    )?;
928    let gap_line = file_detail
929        .gap_lines
930        .into_iter()
931        .find(|gap| gap.line == options.line);
932    // Covered lines are absent from the gap projection, but their anchored
933    // statement/branch/decision metadata still carries the source snippet.
934    // Prefer the exact gap-line text when present and otherwise retain that
935    // anchored source so a successful line query never contradicts itself by
936    // claiming the source is unavailable while printing it below.
937    let source = gap_line
938        .as_ref()
939        .and_then(|gap| gap.source.clone())
940        .or_else(|| {
941            rendered_anchors
942                .iter()
943                .filter_map(|anchor| anchor.source.clone())
944                .min_by_key(|source| source.len())
945        });
946    let all_remaining = gap_line.map_or_else(Vec::new, |gap| gap.obligations);
947    let total_remaining = all_remaining.len();
948    let remaining_page = all_remaining
949        .iter()
950        .skip(options.offset)
951        .take(options.limit)
952        .cloned()
953        .collect::<Vec<_>>();
954    let Some(line) = index.line(options.view, options.file, options.line)? else {
955        let anchored = rendered_anchors
956            .iter()
957            .skip(options.offset)
958            .take(options.limit)
959            .cloned()
960            .collect::<Vec<_>>();
961        let total = total_anchored.max(total_limitations).max(total_remaining);
962        let total = total.max(total_anchor_tests);
963        let returned = anchored
964            .len()
965            .max(limitations_page.len())
966            .max(remaining_page.len())
967            .max(anchor_tests_page.len());
968        return Ok((
969            CoverageCoversData::Anchors(CoverageCoversAnchorsData {
970                run: options.run.into(),
971                filters,
972                location,
973                source,
974                source_origin: None,
975                line_obligation: false,
976                anchored,
977                total_anchored,
978                covered_anchored,
979                total_limitations,
980                limitations: limitations_page,
981                total_remaining,
982                remaining: remaining_page,
983                total_tests: total_anchor_tests,
984                tests: anchor_tests_page,
985                assertions: None,
986            }),
987            pagination(options.offset, options.limit, returned, total),
988        ));
989    };
990    let all_tests = line
991        .tests
992        .iter()
993        .filter(|id| selected_includes(id))
994        .map(|id| {
995            let test = tests_by_id.get(id);
996            CoverageCoveringTest {
997                id: id.clone(),
998                name: test.map_or_else(|| id.clone(), |test| test.name.clone()),
999                provenance: test
1000                    .map_or_else(TestProvenance::default, |test| test.provenance.clone()),
1001            }
1002        })
1003        .collect::<Vec<_>>();
1004    let phases_by_id = index
1005        .phase_summaries(options.view)?
1006        .into_iter()
1007        .map(|phase| (phase.id.clone(), phase))
1008        .collect::<HashMap<_, _>>();
1009    let all_phases = line
1010        .phases
1011        .iter()
1012        .filter_map(|id| phases_by_id.get(id))
1013        .filter(|phase| selected_includes(&phase.test))
1014        .map(|phase| CoverageCoveringPhase {
1015            id: phase.id.clone(),
1016            kind: phase.kind.clone(),
1017            operation: phase.operation.clone(),
1018            source: phase.source.clone(),
1019            test: phase.test.clone(),
1020            status: phase.status.clone(),
1021            caused_by_phase_id: phase.caused_by_phase_id.clone(),
1022        })
1023        .collect::<Vec<_>>();
1024    let tests_page = all_tests
1025        .iter()
1026        .skip(options.offset)
1027        .take(options.limit)
1028        .cloned()
1029        .collect::<Vec<_>>();
1030    let phases_page = all_phases
1031        .iter()
1032        .skip(options.offset)
1033        .take(options.limit)
1034        .cloned()
1035        .collect::<Vec<_>>();
1036    let anchored_page = rendered_anchors
1037        .into_iter()
1038        .skip(options.offset)
1039        .take(options.limit)
1040        .collect::<Vec<_>>();
1041    let total = all_tests
1042        .len()
1043        .max(all_phases.len())
1044        .max(total_anchored)
1045        .max(total_limitations)
1046        .max(total_remaining);
1047    let returned = tests_page
1048        .len()
1049        .max(phases_page.len())
1050        .max(anchored_page.len())
1051        .max(limitations_page.len())
1052        .max(remaining_page.len());
1053    Ok((
1054        CoverageCoversData::Line(CoverageCoversLineData {
1055            run: options.run.into(),
1056            filters,
1057            location,
1058            source,
1059            source_origin: None,
1060            covered: line.tests.iter().any(|test| selected_includes(test)),
1061            confidence: line.confidence,
1062            total_tests: all_tests.len(),
1063            total_phases: all_phases.len(),
1064            total_anchored,
1065            covered_anchored,
1066            total_limitations,
1067            total_remaining,
1068            tests: tests_page,
1069            phases: phases_page,
1070            anchored: anchored_page,
1071            limitations: limitations_page,
1072            remaining: remaining_page,
1073            assertions: None,
1074        }),
1075        pagination(options.offset, options.limit, returned, total),
1076    ))
1077}
1078
1079#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1080pub struct CoverageTestMatch {
1081    pub id: String,
1082    pub name: String,
1083    pub outcome: String,
1084    pub provenance: TestProvenance,
1085}
1086
1087#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1088#[serde(rename_all = "camelCase")]
1089pub struct CoverageHitDetail {
1090    pub id: String,
1091    pub obligation: String,
1092    #[serde(skip_serializing_if = "Option::is_none")]
1093    pub branch_kind: Option<String>,
1094    #[serde(skip_serializing_if = "Option::is_none")]
1095    pub file: Option<String>,
1096    #[serde(skip_serializing_if = "Option::is_none")]
1097    pub line: Option<usize>,
1098    #[serde(skip_serializing_if = "Option::is_none")]
1099    pub column: Option<usize>,
1100    #[serde(skip_serializing_if = "Option::is_none")]
1101    pub label: Option<String>,
1102    #[serde(skip_serializing_if = "Option::is_none")]
1103    pub alternative: Option<String>,
1104}
1105
1106#[derive(Debug, Clone, PartialEq, Serialize)]
1107pub struct CoverageTestDecision {
1108    pub id: String,
1109    pub vectors: Vec<crate::coverage_analysis::McdcVector>,
1110    pub meta: DecisionMeta,
1111}
1112
1113#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1114#[serde(rename_all = "camelCase")]
1115pub struct CoverageTestPhase {
1116    pub id: String,
1117    pub kind: String,
1118    pub operation: String,
1119    #[serde(skip_serializing_if = "Option::is_none")]
1120    pub source: Option<String>,
1121    #[serde(skip_serializing_if = "Option::is_none")]
1122    pub status: Option<String>,
1123    #[serde(skip_serializing_if = "Option::is_none")]
1124    pub caused_by_phase_id: Option<String>,
1125    pub lines: usize,
1126    pub decisions: usize,
1127}
1128
1129#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1130pub struct CoverageTestTotals {
1131    pub lines: usize,
1132    pub hits: usize,
1133    pub decisions: usize,
1134    pub phases: usize,
1135}
1136
1137#[derive(Debug, Clone, PartialEq, Serialize)]
1138#[serde(rename_all = "camelCase")]
1139pub struct CoverageSelectedTest {
1140    pub id: String,
1141    pub name: String,
1142    #[serde(skip_serializing_if = "Option::is_none")]
1143    pub file: Option<String>,
1144    #[serde(skip_serializing_if = "Option::is_none")]
1145    pub title: Option<String>,
1146    pub retries: Vec<usize>,
1147    pub attempts: Vec<TestAttempt>,
1148    pub outcome: String,
1149    pub provenance: TestProvenance,
1150    pub role: String,
1151    /// `exact`, or `run-wide` when this test ran and nothing can say what it
1152    /// reached. Under `run-wide` the totals below are zero because the test
1153    /// has no coverage of its own, not because it reached nothing.
1154    pub attribution: String,
1155    pub hits: Vec<String>,
1156    pub decisions: Vec<CoverageTestDecision>,
1157    pub lines: Vec<SourceLine>,
1158    pub hit_details: Vec<CoverageHitDetail>,
1159    pub phases: Vec<CoverageTestPhase>,
1160    pub totals: CoverageTestTotals,
1161}
1162
1163#[derive(Debug, Clone, PartialEq, Serialize)]
1164pub struct CoverageTestMatchesData {
1165    pub run: String,
1166    pub filters: CoverageQueryFilters,
1167    pub tests: Vec<CoverageTestMatch>,
1168}
1169
1170#[derive(Debug, Clone, PartialEq, Serialize)]
1171#[serde(rename_all = "camelCase")]
1172pub struct CoverageTestDetailData {
1173    pub run: String,
1174    pub filters: CoverageQueryFilters,
1175    pub pagination_applies_to: String,
1176    pub tests: Vec<CoverageSelectedTest>,
1177}
1178
1179#[derive(Debug, Clone, PartialEq, Serialize)]
1180#[serde(untagged)]
1181pub enum CoverageTestData {
1182    Matches(CoverageTestMatchesData),
1183    Detail(CoverageTestDetailData),
1184}
1185
1186#[derive(Debug, Clone, Copy)]
1187pub struct CoverageTestQueryOptions<'a> {
1188    pub run: &'a str,
1189    pub view: CoverageViewId,
1190    pub kind: Option<&'a str>,
1191    pub runner: Option<&'a str>,
1192    pub selector: &'a str,
1193    pub offset: usize,
1194    pub limit: usize,
1195}
1196
1197fn hit_detail(id: &str, metadata: Option<&IndexedHitMetadata>) -> CoverageHitDetail {
1198    match metadata {
1199        Some(metadata) => CoverageHitDetail {
1200            id: id.into(),
1201            obligation: metadata.obligation.clone(),
1202            branch_kind: metadata.branch_kind.clone(),
1203            file: Some(metadata.file.clone()),
1204            line: Some(metadata.line),
1205            column: Some(metadata.column),
1206            label: metadata.label.clone(),
1207            alternative: metadata.alternative.clone(),
1208        },
1209        None => CoverageHitDetail {
1210            id: id.into(),
1211            obligation: "unknown".into(),
1212            branch_kind: None,
1213            file: None,
1214            line: None,
1215            column: None,
1216            label: None,
1217            alternative: None,
1218        },
1219    }
1220}
1221
1222pub fn coverage_test_query(
1223    index: &CoverageIndex<'_>,
1224    options: CoverageTestQueryOptions<'_>,
1225) -> Result<(CoverageTestData, AgentPagination), QueryError> {
1226    if options.limit == 0 {
1227        return Err(QueryError::InvalidPagination);
1228    }
1229    let tests = index.test_details(options.view)?;
1230    let summaries = tests
1231        .iter()
1232        .map(|test| test.summary.clone())
1233        .collect::<Vec<_>>();
1234    let selected = selected_test_ids(&summaries, options.kind, options.runner)?;
1235    let selector = options.selector.to_lowercase();
1236    let matches = tests
1237        .into_iter()
1238        .filter(|test| {
1239            selected
1240                .as_ref()
1241                .is_none_or(|ids| ids.contains(&test.summary.id))
1242        })
1243        .filter(|test| {
1244            test.summary.id == selector || test.summary.name.to_lowercase().contains(&selector)
1245        })
1246        .collect::<Vec<_>>();
1247    if matches.is_empty() {
1248        return Err(QueryError::TestNotFound(options.selector.into()));
1249    }
1250    let filters = query_filters(options.view, options.kind, options.runner);
1251    if matches.len() > 1 {
1252        let total = matches.len();
1253        let page = matches
1254            .into_iter()
1255            .skip(options.offset)
1256            .take(options.limit)
1257            .map(|test| CoverageTestMatch {
1258                id: test.summary.id,
1259                name: test.summary.name,
1260                outcome: test.summary.outcome,
1261                provenance: test.summary.provenance,
1262            })
1263            .collect::<Vec<_>>();
1264        let returned = page.len();
1265        return Ok((
1266            CoverageTestData::Matches(CoverageTestMatchesData {
1267                run: options.run.into(),
1268                filters,
1269                tests: page,
1270            }),
1271            pagination(options.offset, options.limit, returned, total),
1272        ));
1273    }
1274    let test = matches.into_iter().next().expect("one test match");
1275    let metadata = index
1276        .hit_metadata(options.view)?
1277        .into_iter()
1278        .map(|metadata| (metadata.id.clone(), metadata))
1279        .collect::<HashMap<_, _>>();
1280    let decisions = index
1281        .decision_metadata(options.view)?
1282        .into_iter()
1283        .map(|decision| (decision.id.clone(), decision))
1284        .collect::<HashMap<_, _>>();
1285    let all_phases = index
1286        .phase_summaries(options.view)?
1287        .into_iter()
1288        .filter(|phase| phase.test == test.summary.id)
1289        .map(|phase| CoverageTestPhase {
1290            id: phase.id,
1291            kind: phase.kind,
1292            operation: phase.operation,
1293            source: phase.source,
1294            status: phase.status,
1295            caused_by_phase_id: phase.caused_by_phase_id,
1296            lines: phase.lines,
1297            decisions: phase.decisions,
1298        })
1299        .collect::<Vec<_>>();
1300    let totals = CoverageTestTotals {
1301        lines: test.lines.len(),
1302        hits: test.hits.len(),
1303        decisions: test.decisions.len(),
1304        phases: all_phases.len(),
1305    };
1306    let total = totals
1307        .lines
1308        .max(totals.hits)
1309        .max(totals.decisions)
1310        .max(totals.phases);
1311    let lines = test
1312        .lines
1313        .iter()
1314        .skip(options.offset)
1315        .take(options.limit)
1316        .cloned()
1317        .collect::<Vec<_>>();
1318    let hits = test
1319        .hits
1320        .iter()
1321        .skip(options.offset)
1322        .take(options.limit)
1323        .cloned()
1324        .collect::<Vec<_>>();
1325    let hit_details = test
1326        .hits
1327        .iter()
1328        .skip(options.offset)
1329        .take(options.limit)
1330        .map(|id| hit_detail(id, metadata.get(id)))
1331        .collect::<Vec<_>>();
1332    let test_decisions = test
1333        .decisions
1334        .iter()
1335        .skip(options.offset)
1336        .take(options.limit)
1337        .map(|decision| {
1338            Ok(CoverageTestDecision {
1339                id: decision.id.clone(),
1340                vectors: decision.vectors.clone(),
1341                meta: decisions
1342                    .get(&decision.id)
1343                    .cloned()
1344                    .ok_or(QueryError::InvalidRecordSelection)?,
1345            })
1346        })
1347        .collect::<Result<Vec<_>, QueryError>>()?;
1348    let phases = all_phases
1349        .into_iter()
1350        .skip(options.offset)
1351        .take(options.limit)
1352        .collect::<Vec<_>>();
1353    let returned = lines
1354        .len()
1355        .max(hits.len())
1356        .max(test_decisions.len())
1357        .max(phases.len());
1358    Ok((
1359        CoverageTestData::Detail(CoverageTestDetailData {
1360            run: options.run.into(),
1361            filters,
1362            pagination_applies_to:
1363                "lines, hits/hitDetails, decisions, and phases independently within the test".into(),
1364            tests: vec![CoverageSelectedTest {
1365                id: test.summary.id,
1366                name: test.summary.name,
1367                file: test.summary.file,
1368                title: test.summary.title,
1369                retries: test.retries,
1370                attempts: test.attempts,
1371                outcome: test.summary.outcome,
1372                provenance: test.summary.provenance,
1373                role: test.summary.role,
1374                attribution: test.summary.attribution,
1375                hits,
1376                decisions: test_decisions,
1377                lines,
1378                hit_details,
1379                phases,
1380                totals,
1381            }],
1382        }),
1383        pagination(options.offset, options.limit, returned, total),
1384    ))
1385}
1386
1387#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1388pub struct CoverageDecisionMatch {
1389    pub id: String,
1390    pub file: String,
1391    pub line: usize,
1392    pub column: usize,
1393    pub source: String,
1394}
1395
1396#[derive(Debug, Clone, PartialEq, Serialize)]
1397#[serde(rename_all = "camelCase")]
1398pub struct CoverageDecisionCondition {
1399    pub index: usize,
1400    pub source: String,
1401    pub covered: bool,
1402    #[serde(skip_serializing_if = "Option::is_none")]
1403    pub assertion_covered: Option<bool>,
1404    #[serde(skip_serializing_if = "Option::is_none")]
1405    pub witness: Option<[crate::coverage_analysis::McdcVector; 2]>,
1406    #[serde(skip_serializing_if = "Option::is_none")]
1407    pub witness_tests: Option<[Vec<Id>; 2]>,
1408}
1409
1410#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1411#[serde(rename_all = "camelCase")]
1412pub struct CoverageDecisionTotals {
1413    pub conditions: usize,
1414    pub vector_observations: usize,
1415    pub tests: usize,
1416}
1417
1418#[derive(Debug, Clone, PartialEq, Serialize)]
1419#[serde(rename_all = "camelCase")]
1420pub struct CoverageSelectedDecision {
1421    pub meta: DecisionMeta,
1422    pub executed: bool,
1423    pub covered: bool,
1424    pub vectors: Vec<crate::coverage_analysis::McdcVector>,
1425    pub vector_observations: Vec<crate::coverage_report::VectorObservation>,
1426    pub conditions: Vec<CoverageDecisionCondition>,
1427    pub tests: Vec<Id>,
1428    pub confidence: CoverageConfidence,
1429    pub totals: CoverageDecisionTotals,
1430}
1431
1432#[derive(Debug, Clone, PartialEq, Serialize)]
1433pub struct CoverageDecisionMatchesData {
1434    pub run: String,
1435    pub filters: CoverageQueryFilters,
1436    pub decisions: Vec<CoverageDecisionMatch>,
1437}
1438
1439#[derive(Debug, Clone, PartialEq, Serialize)]
1440#[serde(rename_all = "camelCase")]
1441pub struct CoverageDecisionDetailData {
1442    pub run: String,
1443    pub filters: CoverageQueryFilters,
1444    pub pagination_applies_to: String,
1445    pub decisions: Vec<CoverageSelectedDecision>,
1446}
1447
1448#[derive(Debug, Clone, PartialEq, Serialize)]
1449#[serde(untagged)]
1450pub enum CoverageDecisionData {
1451    Matches(CoverageDecisionMatchesData),
1452    Detail(CoverageDecisionDetailData),
1453}
1454
1455#[derive(Debug, Clone, Copy)]
1456pub struct CoverageDecisionQueryOptions<'a> {
1457    pub run: &'a str,
1458    pub view: CoverageViewId,
1459    pub kind: Option<&'a str>,
1460    pub runner: Option<&'a str>,
1461    pub selector: &'a str,
1462    pub offset: usize,
1463    pub limit: usize,
1464}
1465
1466fn selector_location(selector: &str) -> Option<(&str, usize)> {
1467    let (prefix, last) = selector.rsplit_once(':')?;
1468    let last = last.parse::<usize>().ok()?;
1469    if let Some((file, possible_line)) = prefix.rsplit_once(':')
1470        && let Ok(line) = possible_line.parse::<usize>()
1471    {
1472        return Some((file, line));
1473    }
1474    Some((prefix, last))
1475}
1476
1477fn selected_decision(
1478    decision: crate::coverage_report::DecisionResult,
1479    selected: Option<&BTreeSet<String>>,
1480) -> crate::coverage_report::DecisionResult {
1481    let Some(selected) = selected else {
1482        return decision;
1483    };
1484    let vector_observations = decision
1485        .vector_observations
1486        .into_iter()
1487        .filter_map(|mut observation| {
1488            observation
1489                .tests
1490                .retain(|test| selected.contains(test.as_str()));
1491            (!observation.tests.is_empty()).then_some(observation)
1492        })
1493        .collect::<Vec<_>>();
1494    let vectors = vector_observations
1495        .iter()
1496        .map(|observation| observation.vector.clone())
1497        .collect::<Vec<_>>();
1498    let conditions = decision
1499        .meta
1500        .conditions
1501        .iter()
1502        .enumerate()
1503        .map(|(index, source)| {
1504            let mut witness = None;
1505            let mut witness_tests = None;
1506            'pairs: for left in 0..vector_observations.len() {
1507                for right in (left + 1)..vector_observations.len() {
1508                    let first = &vector_observations[left];
1509                    let second = &vector_observations[right];
1510                    if is_independence_pair(&first.vector, &second.vector, index) {
1511                        witness = Some([first.vector.clone(), second.vector.clone()]);
1512                        witness_tests = Some([first.tests.clone(), second.tests.clone()]);
1513                        break 'pairs;
1514                    }
1515                }
1516            }
1517            crate::coverage_report::ConditionResult {
1518                index,
1519                source: source.clone(),
1520                covered: witness.is_some(),
1521                assertion_covered: false,
1522                witness,
1523                witness_tests,
1524            }
1525        })
1526        .collect::<Vec<_>>();
1527    crate::coverage_report::DecisionResult {
1528        meta: decision.meta,
1529        executed: !vectors.is_empty(),
1530        covered: conditions.iter().all(|condition| condition.covered),
1531        vectors,
1532        vector_observations,
1533        conditions,
1534        tests: decision
1535            .tests
1536            .into_iter()
1537            .filter(|test| selected.contains(test.as_str()))
1538            .collect(),
1539        confidence: decision.confidence,
1540    }
1541}
1542
1543pub fn coverage_decision_query(
1544    index: &CoverageIndex<'_>,
1545    options: CoverageDecisionQueryOptions<'_>,
1546) -> Result<(CoverageDecisionData, AgentPagination), QueryError> {
1547    if options.limit == 0 {
1548        return Err(QueryError::InvalidPagination);
1549    }
1550    let tests = index.test_summaries(options.view)?;
1551    let selected = selected_test_ids(&tests, options.kind, options.runner)?;
1552    let decisions = index.decision_details(options.view)?;
1553    let mut matches = decisions
1554        .into_iter()
1555        .filter(|decision| decision.meta.id == options.selector)
1556        .collect::<Vec<_>>();
1557    if matches.is_empty()
1558        && let Some((file, line)) = selector_location(options.selector)
1559    {
1560        matches = index
1561            .decision_details(options.view)?
1562            .into_iter()
1563            .filter(|decision| decision.meta.file == file && decision.meta.line == line)
1564            .collect();
1565    }
1566    if matches.is_empty() {
1567        return Err(QueryError::DecisionNotFound(options.selector.into()));
1568    }
1569    let filters = query_filters(options.view, options.kind, options.runner);
1570    if matches.len() > 1 {
1571        let total = matches.len();
1572        let page = matches
1573            .into_iter()
1574            .skip(options.offset)
1575            .take(options.limit)
1576            .map(|decision| CoverageDecisionMatch {
1577                id: decision.meta.id,
1578                file: decision.meta.file,
1579                line: decision.meta.line,
1580                column: decision.meta.column,
1581                source: decision.meta.source,
1582            })
1583            .collect::<Vec<_>>();
1584        let returned = page.len();
1585        return Ok((
1586            CoverageDecisionData::Matches(CoverageDecisionMatchesData {
1587                run: options.run.into(),
1588                filters,
1589                decisions: page,
1590            }),
1591            pagination(options.offset, options.limit, returned, total),
1592        ));
1593    }
1594    let filtered = selected_decision(
1595        matches.into_iter().next().expect("one decision match"),
1596        selected.as_ref(),
1597    );
1598    let totals = CoverageDecisionTotals {
1599        conditions: filtered.conditions.len(),
1600        vector_observations: filtered.vector_observations.len(),
1601        tests: filtered.tests.len(),
1602    };
1603    let total = totals
1604        .conditions
1605        .max(totals.vector_observations)
1606        .max(totals.tests);
1607    let vector_observations = filtered
1608        .vector_observations
1609        .iter()
1610        .skip(options.offset)
1611        .take(options.limit)
1612        .cloned()
1613        .collect::<Vec<_>>();
1614    let vectors = vector_observations
1615        .iter()
1616        .map(|observation| observation.vector.clone())
1617        .collect::<Vec<_>>();
1618    let conditions = filtered
1619        .conditions
1620        .iter()
1621        .skip(options.offset)
1622        .take(options.limit)
1623        .map(|condition| CoverageDecisionCondition {
1624            index: condition.index,
1625            source: condition.source.clone(),
1626            covered: condition.covered,
1627            assertion_covered: selected.is_none().then_some(condition.assertion_covered),
1628            witness: condition.witness.clone(),
1629            witness_tests: condition.witness_tests.clone(),
1630        })
1631        .collect::<Vec<_>>();
1632    let tests = filtered
1633        .tests
1634        .iter()
1635        .skip(options.offset)
1636        .take(options.limit)
1637        .cloned()
1638        .collect::<Vec<_>>();
1639    let returned = vector_observations
1640        .len()
1641        .max(conditions.len())
1642        .max(tests.len());
1643    Ok((
1644        CoverageDecisionData::Detail(CoverageDecisionDetailData {
1645            run: options.run.into(),
1646            filters,
1647            pagination_applies_to:
1648                "conditions, vectorObservations, and tests independently within each decision"
1649                    .into(),
1650            decisions: vec![CoverageSelectedDecision {
1651                meta: filtered.meta,
1652                executed: filtered.executed,
1653                covered: filtered.covered,
1654                vectors,
1655                vector_observations,
1656                conditions,
1657                tests,
1658                confidence: filtered.confidence,
1659                totals,
1660            }],
1661        }),
1662        pagination(options.offset, options.limit, returned, total),
1663    ))
1664}
1665
1666#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1667pub struct CoverageOtherTest {
1668    pub id: String,
1669    pub name: String,
1670}
1671
1672#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1673#[serde(rename_all = "camelCase")]
1674pub struct CoverageOtherCoverage {
1675    pub covered_elsewhere: bool,
1676    pub kinds: Vec<String>,
1677    pub runners: Vec<String>,
1678    pub tests: Vec<CoverageOtherTest>,
1679}
1680
1681#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1682#[serde(rename_all = "camelCase")]
1683pub struct CoverageLineObligation {
1684    pub kind: String,
1685    pub id: String,
1686    pub line: usize,
1687    pub other_coverage: CoverageOtherCoverage,
1688}
1689
1690#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1691#[serde(rename_all = "camelCase")]
1692pub struct CoveragePointObligation {
1693    pub kind: String,
1694    pub id: String,
1695    pub line: usize,
1696    pub column: usize,
1697    pub source: String,
1698    pub other_coverage: CoverageOtherCoverage,
1699}
1700
1701#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1702#[serde(rename_all = "camelCase")]
1703pub struct CoverageBranchObligation {
1704    pub kind: String,
1705    pub id: String,
1706    pub line: usize,
1707    pub column: usize,
1708    pub source: String,
1709    pub missing: String,
1710    pub other_coverage: CoverageOtherCoverage,
1711}
1712
1713#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1714#[serde(rename_all = "camelCase")]
1715pub struct CoverageMcdcObligation {
1716    pub kind: String,
1717    pub id: String,
1718    pub line: usize,
1719    pub column: usize,
1720    pub decision: String,
1721    pub missing_condition: String,
1722    #[serde(skip)]
1723    pub condition_index: usize,
1724    pub observed_vectors: Vec<String>,
1725    pub other_coverage: CoverageOtherCoverage,
1726}
1727
1728#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1729#[serde(untagged)]
1730pub enum CoverageFileObligation {
1731    Line(CoverageLineObligation),
1732    Point(CoveragePointObligation),
1733    Branch(CoverageBranchObligation),
1734    Mcdc(CoverageMcdcObligation),
1735}
1736
1737impl CoverageFileObligation {
1738    fn line(&self) -> usize {
1739        match self {
1740            Self::Line(value) => value.line,
1741            Self::Point(value) => value.line,
1742            Self::Branch(value) => value.line,
1743            Self::Mcdc(value) => value.line,
1744        }
1745    }
1746
1747    fn kind(&self) -> &str {
1748        match self {
1749            Self::Line(value) => &value.kind,
1750            Self::Point(value) => &value.kind,
1751            Self::Branch(value) => &value.kind,
1752            Self::Mcdc(value) => &value.kind,
1753        }
1754    }
1755}
1756
1757#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1758pub struct CoverageFileLimitation {
1759    pub id: String,
1760    pub kind: String,
1761    pub file: String,
1762    pub line: usize,
1763    pub column: usize,
1764    pub source: String,
1765    pub reason: String,
1766    pub blocking: bool,
1767    pub effect: String,
1768}
1769
1770#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1771#[serde(rename_all = "camelCase")]
1772pub struct CoverageFileCounts {
1773    /// Measured lines in the file, and how many the selected tests covered:
1774    /// the file's own line coverage, comparable with other tools'.
1775    pub total_lines: usize,
1776    pub covered_lines: usize,
1777    pub uncovered_lines: usize,
1778    pub uncovered_statements: usize,
1779    pub uncovered_functions: usize,
1780    pub missing_branches: usize,
1781    pub missing_mcdc_conditions: usize,
1782    pub measurement_limitations: usize,
1783}
1784
1785#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1786#[serde(rename_all = "camelCase")]
1787pub struct CoverageFileGapLine {
1788    pub line: usize,
1789    pub state: String,
1790    #[serde(skip_serializing_if = "Option::is_none")]
1791    pub source: Option<String>,
1792    pub obligations: Vec<CoverageFileObligation>,
1793    pub limitations: Vec<CoverageFileLimitation>,
1794}
1795
1796#[derive(Debug, Clone, PartialEq, Serialize)]
1797#[serde(rename_all = "camelCase")]
1798pub struct CoverageFileDetailData {
1799    pub run: String,
1800    pub filters: CoverageQueryFilters,
1801    pub file: String,
1802    pub metric: MinimizeMetric,
1803    pub counts: CoverageFileCounts,
1804    pub total_tests: usize,
1805    pub total_obligations: usize,
1806    pub total_gap_lines: usize,
1807    pub gap_lines: Vec<CoverageFileGapLine>,
1808    pub total_limitations: usize,
1809    /// The run's saved assertion assessment for this place, attached by the
1810    /// CLI when the run has one.
1811    #[serde(skip_serializing_if = "Option::is_none")]
1812    pub assertions: Option<serde_json::Value>,
1813}
1814
1815#[derive(Debug, Clone, Copy)]
1816pub struct CoverageFileDetailOptions<'a> {
1817    pub run: &'a str,
1818    pub view: CoverageViewId,
1819    pub kind: Option<&'a str>,
1820    pub runner: Option<&'a str>,
1821    pub selector: &'a str,
1822    pub metric: MinimizeMetric,
1823    pub offset: usize,
1824    pub limit: usize,
1825}
1826
1827fn other_coverage<T: AsRef<str>>(
1828    test_ids: &[T],
1829    selected: Option<&BTreeSet<String>>,
1830    tests: &HashMap<String, IndexedTestSummary>,
1831) -> CoverageOtherCoverage {
1832    let covered = selected.map_or_else(Vec::new, |selected| {
1833        test_ids
1834            .iter()
1835            .filter(|id| !selected.contains(id.as_ref()))
1836            .filter_map(|id| tests.get(id.as_ref()))
1837            .collect::<Vec<_>>()
1838    });
1839    CoverageOtherCoverage {
1840        covered_elsewhere: !covered.is_empty(),
1841        kinds: covered
1842            .iter()
1843            .map(|test| test.provenance.kind.clone())
1844            .collect::<BTreeSet<_>>()
1845            .into_iter()
1846            .collect(),
1847        runners: covered
1848            .iter()
1849            .map(|test| test.provenance.runner.clone())
1850            .collect::<BTreeSet<_>>()
1851            .into_iter()
1852            .collect(),
1853        tests: covered
1854            .into_iter()
1855            .map(|test| CoverageOtherTest {
1856                id: test.id.clone(),
1857                name: test.name.clone(),
1858            })
1859            .collect(),
1860    }
1861}
1862
1863fn vector_text(vector: &crate::coverage_analysis::McdcVector) -> String {
1864    let values = vector
1865        .values
1866        .iter()
1867        .map(|value| match value {
1868            None => '-',
1869            Some(false) => 'F',
1870            Some(true) => 'T',
1871        })
1872        .collect::<String>();
1873    format!("{values} -> {}", if vector.outcome { 'T' } else { 'F' })
1874}
1875
1876fn obligation_matches_metric(obligation: &CoverageFileObligation, metric: MinimizeMetric) -> bool {
1877    metric == MinimizeMetric::All
1878        || matches!(
1879            (obligation.kind(), metric),
1880            ("line", MinimizeMetric::Lines)
1881                | ("statement", MinimizeMetric::Statements)
1882                | ("function", MinimizeMetric::Functions)
1883                | ("branch", MinimizeMetric::Branches)
1884                | ("mcdc", MinimizeMetric::Mcdc)
1885        )
1886}
1887
1888fn compact_source(value: &str) -> Option<String> {
1889    let line = value.lines().find(|line| !line.trim().is_empty())?.trim();
1890    let compact = line.split_whitespace().collect::<Vec<_>>().join(" ");
1891    if compact.is_empty() {
1892        None
1893    } else if compact.chars().count() > 120 {
1894        Some(format!(
1895            "{}…",
1896            compact.chars().take(119).collect::<String>()
1897        ))
1898    } else {
1899        Some(compact)
1900    }
1901}
1902
1903fn obligation_source(obligation: &CoverageFileObligation) -> Option<String> {
1904    match obligation {
1905        CoverageFileObligation::Line(_) => None,
1906        CoverageFileObligation::Point(value) => compact_source(&value.source),
1907        CoverageFileObligation::Branch(value) => compact_source(&value.source),
1908        CoverageFileObligation::Mcdc(value) => compact_source(&value.decision),
1909    }
1910}
1911
1912/// What a file's detail is read from: the run's lines, points, decisions,
1913/// limitations and tests, read once and kept by file.
1914///
1915/// Reading them is most of what a file's detail costs, and none of it
1916/// depends on the file. The HTML report asks for every file with a gap, and
1917/// read the whole run again for each: six minutes for a merged run of 2,094
1918/// tests that is exported in five seconds.
1919pub struct FileDetailSource {
1920    view: CoverageViewId,
1921    tests: Vec<crate::coverage_index::IndexedTestDetail>,
1922    test_summaries: Vec<IndexedTestSummary>,
1923    tests_by_id: HashMap<String, IndexedTestSummary>,
1924    /// The tests with a line in each file.
1925    tests_by_file: HashMap<String, Vec<String>>,
1926    lines: Vec<crate::coverage_index::IndexedLine>,
1927    lines_by_file: BTreeMap<String, Vec<usize>>,
1928    points: Vec<crate::coverage_index::IndexedHitMetadata>,
1929    points_by_file: HashMap<String, Vec<usize>>,
1930    decisions: Vec<crate::coverage_report::DecisionResult>,
1931    decisions_by_file: HashMap<String, Vec<usize>>,
1932    limitations: BTreeMap<String, Vec<crate::coverage_index::IndexedLimitation>>,
1933}
1934
1935impl FileDetailSource {
1936    pub fn read(index: &CoverageIndex<'_>, view: CoverageViewId) -> Result<Self, QueryError> {
1937        let tests = index.test_details(view)?;
1938        let mut tests_by_file = HashMap::<String, Vec<String>>::new();
1939        for test in &tests {
1940            let mut files = test
1941                .lines
1942                .iter()
1943                .map(|line| line.file.as_str())
1944                .collect::<Vec<_>>();
1945            files.sort_unstable();
1946            files.dedup();
1947            for file in files {
1948                tests_by_file
1949                    .entry(file.to_owned())
1950                    .or_default()
1951                    .push(test.summary.id.clone());
1952            }
1953        }
1954        let test_summaries = tests
1955            .iter()
1956            .map(|test| test.summary.clone())
1957            .collect::<Vec<_>>();
1958        let tests_by_id = test_summaries
1959            .iter()
1960            .map(|test| (test.id.clone(), test.clone()))
1961            .collect();
1962        let lines = index.lines(view)?;
1963        let mut lines_by_file = BTreeMap::<String, Vec<usize>>::new();
1964        for (at, line) in lines.iter().enumerate() {
1965            lines_by_file.entry(line.file.clone()).or_default().push(at);
1966        }
1967        let points = index.hit_metadata(view)?;
1968        let mut points_by_file = HashMap::<String, Vec<usize>>::new();
1969        for (at, point) in points.iter().enumerate() {
1970            points_by_file
1971                .entry(point.file.clone())
1972                .or_default()
1973                .push(at);
1974        }
1975        let decisions = index.decision_details(view)?;
1976        let mut decisions_by_file = HashMap::<String, Vec<usize>>::new();
1977        for (at, decision) in decisions.iter().enumerate() {
1978            decisions_by_file
1979                .entry(decision.meta.file.clone())
1980                .or_default()
1981                .push(at);
1982        }
1983        let mut limitations = BTreeMap::<String, Vec<_>>::new();
1984        for limitation in index.limitations(view)? {
1985            limitations
1986                .entry(limitation.file.clone())
1987                .or_default()
1988                .push(limitation);
1989        }
1990        Ok(Self {
1991            view,
1992            tests,
1993            test_summaries,
1994            tests_by_id,
1995            tests_by_file,
1996            lines,
1997            lines_by_file,
1998            points,
1999            points_by_file,
2000            decisions,
2001            decisions_by_file,
2002            limitations,
2003        })
2004    }
2005
2006    /// Every test, line, point and decision of the run, in the index's
2007    /// order, for a reader that wants them all as well as a file's detail.
2008    pub fn tests(&self) -> &[crate::coverage_index::IndexedTestDetail] {
2009        &self.tests
2010    }
2011
2012    pub fn lines(&self) -> &[crate::coverage_index::IndexedLine] {
2013        &self.lines
2014    }
2015
2016    pub fn points(&self) -> &[crate::coverage_index::IndexedHitMetadata] {
2017        &self.points
2018    }
2019
2020    pub fn decisions(&self) -> &[crate::coverage_report::DecisionResult] {
2021        &self.decisions
2022    }
2023}
2024
2025pub fn coverage_file_detail_query(
2026    index: &CoverageIndex<'_>,
2027    options: CoverageFileDetailOptions<'_>,
2028) -> Result<(CoverageFileDetailData, AgentPagination), QueryError> {
2029    if options.limit == 0 {
2030        return Err(QueryError::InvalidPagination);
2031    }
2032    coverage_file_detail_from(&FileDetailSource::read(index, options.view)?, options)
2033}
2034
2035/// One file's detail from a run already read. `options.view` is the view the
2036/// run was read in.
2037pub fn coverage_file_detail_from(
2038    read: &FileDetailSource,
2039    options: CoverageFileDetailOptions<'_>,
2040) -> Result<(CoverageFileDetailData, AgentPagination), QueryError> {
2041    if options.limit == 0 {
2042        return Err(QueryError::InvalidPagination);
2043    }
2044    debug_assert!(read.view == options.view);
2045    let selected = selected_test_ids(&read.test_summaries, options.kind, options.runner)?;
2046    let tests_by_id = &read.tests_by_id;
2047    let files = read
2048        .lines_by_file
2049        .keys()
2050        .chain(read.limitations.keys())
2051        .map(String::as_str)
2052        .collect::<BTreeSet<_>>();
2053    let file = if files.contains(options.selector) {
2054        options.selector.to_owned()
2055    } else {
2056        let matches = files
2057            .into_iter()
2058            .filter(|file| file.contains(options.selector))
2059            .collect::<Vec<_>>();
2060        if matches.is_empty() {
2061            return Err(QueryError::SourceNotFound(options.selector.into()));
2062        }
2063        if matches.len() != 1 {
2064            return Err(QueryError::AmbiguousSelector {
2065                selector: options.selector.into(),
2066                matches: matches.into_iter().map(str::to_owned).collect(),
2067            });
2068        }
2069        matches[0].to_owned()
2070    };
2071    let lines = read
2072        .lines_by_file
2073        .get(&file)
2074        .into_iter()
2075        .flatten()
2076        .map(|at| &read.lines[*at])
2077        .collect::<Vec<_>>();
2078    let metadata = read
2079        .points_by_file
2080        .get(&file)
2081        .into_iter()
2082        .flatten()
2083        .map(|at| &read.points[*at])
2084        .collect::<Vec<_>>();
2085    let selected_includes = |tests: &[String]| {
2086        selected.as_ref().map_or(!tests.is_empty(), |selected| {
2087            tests.iter().any(|test| selected.contains(test))
2088        })
2089    };
2090    let total_lines = lines.iter().filter(|line| line.measured).count();
2091    let uncovered_lines = lines
2092        .iter()
2093        .filter(|line| line.measured && !selected_includes(&line.tests))
2094        .map(|line| {
2095            CoverageFileObligation::Line(CoverageLineObligation {
2096                kind: "line".into(),
2097                id: format!("line:{}:{}", line.file, line.line),
2098                line: line.line,
2099                other_coverage: other_coverage(&line.tests, selected.as_ref(), tests_by_id),
2100            })
2101        })
2102        .collect::<Vec<_>>();
2103    let statements = metadata
2104        .iter()
2105        .filter(|point| point.obligation == "statement" && !selected_includes(&point.tests))
2106        .map(|point| {
2107            CoverageFileObligation::Point(CoveragePointObligation {
2108                kind: "statement".into(),
2109                id: point.id.clone(),
2110                line: point.line,
2111                column: point.column,
2112                source: point.label.clone().unwrap_or_else(|| point.source.clone()),
2113                other_coverage: other_coverage(&point.tests, selected.as_ref(), tests_by_id),
2114            })
2115        })
2116        .collect::<Vec<_>>();
2117    let functions = metadata
2118        .iter()
2119        .filter(|point| point.obligation == "function" && !selected_includes(&point.tests))
2120        .map(|point| {
2121            CoverageFileObligation::Point(CoveragePointObligation {
2122                kind: "function".into(),
2123                id: point.id.clone(),
2124                line: point.line,
2125                column: point.column,
2126                source: point.label.clone().unwrap_or_else(|| point.source.clone()),
2127                other_coverage: other_coverage(&point.tests, selected.as_ref(), tests_by_id),
2128            })
2129        })
2130        .collect::<Vec<_>>();
2131    let branches = metadata
2132        .iter()
2133        .filter(|branch| branch.obligation == "branch" && !selected_includes(&branch.tests))
2134        .map(|branch| {
2135            CoverageFileObligation::Branch(CoverageBranchObligation {
2136                kind: "branch".into(),
2137                id: branch.id.clone(),
2138                line: branch.line,
2139                column: branch.column,
2140                source: branch.source.clone(),
2141                missing: branch.alternative.clone().unwrap_or_default(),
2142                other_coverage: other_coverage(&branch.tests, selected.as_ref(), tests_by_id),
2143            })
2144        })
2145        .collect::<Vec<_>>();
2146    // A line ran when anything anchored on it was observed. A line inside a
2147    // multi-line expression has no line obligation of its own, so this is
2148    // what tells one that never ran from one that ran part of the way.
2149    let observed_lines = metadata
2150        .iter()
2151        .filter(|point| selected_includes(&point.tests))
2152        .map(|point| point.line)
2153        .collect::<BTreeSet<_>>();
2154    let mut mcdc = Vec::new();
2155    for original in read
2156        .decisions_by_file
2157        .get(&file)
2158        .into_iter()
2159        .flatten()
2160        .map(|at| &read.decisions[*at])
2161    {
2162        let filtered = selected_decision(original.clone(), selected.as_ref());
2163        for condition in filtered
2164            .conditions
2165            .iter()
2166            .filter(|condition| !condition.covered)
2167        {
2168            let original_tests = original.conditions[condition.index]
2169                .witness_tests
2170                .clone()
2171                .unwrap_or_default()
2172                .into_iter()
2173                .flatten()
2174                .collect::<Vec<_>>();
2175            mcdc.push(CoverageFileObligation::Mcdc(CoverageMcdcObligation {
2176                kind: "mcdc".into(),
2177                id: original.meta.id.clone(),
2178                line: original.meta.line,
2179                column: original.meta.column,
2180                decision: original.meta.source.clone(),
2181                missing_condition: condition.source.clone(),
2182                condition_index: condition.index,
2183                observed_vectors: filtered
2184                    .vector_observations
2185                    .iter()
2186                    .map(|observation| vector_text(&observation.vector))
2187                    .collect(),
2188                other_coverage: other_coverage(&original_tests, selected.as_ref(), tests_by_id),
2189            }));
2190        }
2191    }
2192    let mut obligations = uncovered_lines
2193        .iter()
2194        .chain(statements.iter())
2195        .chain(functions.iter())
2196        .chain(branches.iter())
2197        .chain(mcdc.iter())
2198        .filter(|obligation| obligation_matches_metric(obligation, options.metric))
2199        .cloned()
2200        .collect::<Vec<_>>();
2201    obligations.sort_by(|left, right| {
2202        left.line()
2203            .cmp(&right.line())
2204            .then_with(|| left.kind().cmp(right.kind()))
2205    });
2206    let mut limitations = read
2207        .limitations
2208        .get(&file)
2209        .into_iter()
2210        .flatten()
2211        .cloned()
2212        .map(|limitation| CoverageFileLimitation {
2213            id: limitation.id,
2214            kind: limitation.kind,
2215            file: limitation.file,
2216            line: limitation.line,
2217            column: limitation.column,
2218            source: limitation.source,
2219            reason: limitation.reason,
2220            blocking: limitation.blocking,
2221            effect: "outside-measured-denominator".into(),
2222        })
2223        .collect::<Vec<_>>();
2224    limitations.sort_by(|left, right| {
2225        left.line
2226            .cmp(&right.line)
2227            .then_with(|| left.column.cmp(&right.column))
2228            .then_with(|| left.id.cmp(&right.id))
2229    });
2230    let total_tests = read
2231        .tests_by_file
2232        .get(&file)
2233        .into_iter()
2234        .flatten()
2235        .filter(|test| {
2236            selected
2237                .as_ref()
2238                .is_none_or(|selected| selected.contains(*test))
2239        })
2240        .count();
2241    let total_obligations = obligations.len();
2242    let total_limitations = limitations.len();
2243    let mut grouped =
2244        BTreeMap::<usize, (Vec<CoverageFileObligation>, Vec<CoverageFileLimitation>)>::new();
2245    for obligation in obligations {
2246        grouped
2247            .entry(obligation.line())
2248            .or_default()
2249            .0
2250            .push(obligation);
2251    }
2252    for limitation in limitations {
2253        grouped
2254            .entry(limitation.line)
2255            .or_default()
2256            .1
2257            .push(limitation);
2258    }
2259    let gap_lines = grouped
2260        .into_iter()
2261        .map(|(line, (obligations, limitations))| {
2262            let source = obligations.iter().find_map(obligation_source).or_else(|| {
2263                limitations
2264                    .iter()
2265                    .find_map(|value| compact_source(&value.source))
2266            });
2267            // An MC/DC condition can be evaluated without anything on its own
2268            // line being recorded, so only branch and point gaps can say a
2269            // line never ran.
2270            let never_ran = !observed_lines.contains(&line)
2271                && !obligations.is_empty()
2272                && obligations
2273                    .iter()
2274                    .all(|value| !matches!(value, CoverageFileObligation::Mcdc(_)));
2275            let state = if never_ran
2276                || obligations
2277                    .iter()
2278                    .any(|value| matches!(value, CoverageFileObligation::Line(_)))
2279            {
2280                "missing"
2281            } else if !obligations.is_empty() || observed_lines.contains(&line) {
2282                // A line that ran with part of it outside measurement ran part
2283                // of the way, as its line view says.
2284                "part"
2285            } else {
2286                "limited"
2287            };
2288            CoverageFileGapLine {
2289                line,
2290                state: state.into(),
2291                source,
2292                obligations,
2293                limitations,
2294            }
2295        })
2296        .collect::<Vec<_>>();
2297    let total_gap_lines = gap_lines.len();
2298    let selected_gap_lines = gap_lines
2299        .into_iter()
2300        .skip(options.offset)
2301        .take(options.limit)
2302        .collect::<Vec<_>>();
2303    let returned = selected_gap_lines.len();
2304    Ok((
2305        CoverageFileDetailData {
2306            run: options.run.into(),
2307            filters: query_filters(options.view, options.kind, options.runner),
2308            file,
2309            metric: options.metric,
2310            counts: CoverageFileCounts {
2311                total_lines,
2312                covered_lines: total_lines - uncovered_lines.len(),
2313                uncovered_lines: uncovered_lines.len(),
2314                uncovered_statements: statements.len(),
2315                uncovered_functions: functions.len(),
2316                missing_branches: branches.len(),
2317                missing_mcdc_conditions: mcdc.len(),
2318                measurement_limitations: total_limitations,
2319            },
2320            total_tests,
2321            total_obligations,
2322            total_gap_lines,
2323            gap_lines: selected_gap_lines,
2324            total_limitations,
2325            assertions: None,
2326        },
2327        pagination(options.offset, options.limit, returned, total_gap_lines),
2328    ))
2329}
2330
2331#[derive(Debug, Clone, PartialEq, Serialize)]
2332pub struct CoverageDiffDelta {
2333    #[serde(serialize_with = "crate::coverage_analysis::serialize_javascript_number")]
2334    pub lines: f64,
2335    #[serde(serialize_with = "crate::coverage_analysis::serialize_javascript_number")]
2336    pub branches: f64,
2337    #[serde(serialize_with = "crate::coverage_analysis::serialize_javascript_number")]
2338    pub mcdc: f64,
2339}
2340
2341#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
2342#[serde(rename_all = "camelCase")]
2343pub struct CoverageDiffSide {
2344    pub line_count: usize,
2345    pub branch_count: usize,
2346    pub mcdc_count: usize,
2347    pub lines: Vec<String>,
2348    pub branches: Vec<String>,
2349    pub mcdc: Vec<String>,
2350}
2351
2352/// How much of a comparison falls in one file.
2353#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
2354#[serde(rename_all = "camelCase")]
2355pub struct CoverageDiffFile {
2356    pub file: String,
2357    pub lost: CoverageDiffCounts,
2358    pub gained: CoverageDiffCounts,
2359}
2360
2361#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
2362pub struct CoverageDiffCounts {
2363    pub lines: usize,
2364    pub branches: usize,
2365    pub mcdc: usize,
2366}
2367
2368impl CoverageDiffCounts {
2369    fn total(&self) -> usize {
2370        self.lines + self.branches + self.mcdc
2371    }
2372}
2373
2374/// The files of a comparison that are listed whole; the rest are counted.
2375const DIFF_FILES: usize = 20;
2376
2377#[derive(Debug, Clone, PartialEq, Serialize)]
2378#[serde(rename_all = "camelCase")]
2379pub struct CoverageDiffData {
2380    pub filters: CoverageQueryFilters,
2381    pub older: String,
2382    pub newer: String,
2383    pub delta: CoverageDiffDelta,
2384    pub gained: CoverageDiffSide,
2385    pub lost: CoverageDiffSide,
2386    /// How many files the difference is in, and the ones with the most lost,
2387    /// whatever page of items is shown. A list of 79 rows does not say that
2388    /// 45 of its 63 lost lines are one component that did not render.
2389    pub files: usize,
2390    pub by_file: Vec<CoverageDiffFile>,
2391    /// Whether what the two runs measured differs, from their fingerprints.
2392    /// Absent where a run has none.
2393    #[serde(skip_serializing_if = "Option::is_none")]
2394    pub inputs: Option<CoverageDiffInputs>,
2395    /// Asked with `--gained`: `lost` is left empty.
2396    #[serde(skip_serializing_if = "std::ops::Not::not")]
2397    pub gained_only: bool,
2398}
2399
2400/// What two runs of a comparison had in common. With the same source, a
2401/// difference between them is not a change in the code: two e2e runs of one
2402/// commit read "lost: 11 lines, 31 branches" with nothing to say so.
2403#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
2404#[serde(rename_all = "camelCase")]
2405pub struct CoverageDiffInputs {
2406    /// The measured source, its dependencies or its configuration.
2407    pub source_changed: bool,
2408    pub tests_changed: bool,
2409}
2410
2411#[derive(Debug, Clone, Copy)]
2412pub struct CoverageDiffQueryOptions<'a> {
2413    pub older_run: &'a str,
2414    pub newer_run: &'a str,
2415    pub view: CoverageViewId,
2416    pub kind: Option<&'a str>,
2417    pub runner: Option<&'a str>,
2418    pub offset: usize,
2419    pub limit: usize,
2420    /// Only what the newer run gained. A spec measured alone, compared with
2421    /// the suite's run, loses nearly everything; what it newly covered is
2422    /// the question.
2423    pub gained_only: bool,
2424}
2425
2426struct DiffSnapshot {
2427    summary: CoverageSummary,
2428    lines: BTreeSet<String>,
2429    branches: HashMap<String, String>,
2430    mcdc: HashMap<String, String>,
2431}
2432
2433fn diff_snapshot(
2434    index: &CoverageIndex<'_>,
2435    view: CoverageViewId,
2436) -> Result<DiffSnapshot, QueryError> {
2437    let summary = index.summary(view)?;
2438    let lines = index
2439        .lines(view)?
2440        .into_iter()
2441        .filter(|line| line.covered)
2442        .map(|line| format!("{}:{}", line.file, line.line))
2443        .collect();
2444    let branches = index
2445        .hit_metadata(view)?
2446        .into_iter()
2447        .filter(|metadata| metadata.obligation == "branch" && !metadata.tests.is_empty())
2448        .map(|metadata| {
2449            let parent = metadata
2450                .parent_id
2451                .ok_or(QueryError::InvalidRecordSelection)?;
2452            Ok((
2453                format!("{parent}:{}", metadata.id),
2454                format!(
2455                    "{}:{} {}",
2456                    metadata.file,
2457                    metadata.line,
2458                    metadata.alternative.unwrap_or_default()
2459                ),
2460            ))
2461        })
2462        .collect::<Result<HashMap<_, _>, QueryError>>()?;
2463    let mcdc = index
2464        .decision_details(view)?
2465        .into_iter()
2466        .flat_map(|decision| {
2467            decision
2468                .conditions
2469                .into_iter()
2470                .filter(|condition| condition.covered)
2471                .map(move |condition| {
2472                    (
2473                        format!("{}:c{}", decision.meta.id, condition.index),
2474                        format!(
2475                            "{}:{} C{} {}",
2476                            decision.meta.file,
2477                            decision.meta.line,
2478                            condition.index + 1,
2479                            condition.source
2480                        ),
2481                    )
2482                })
2483        })
2484        .collect();
2485    Ok(DiffSnapshot {
2486        summary,
2487        lines,
2488        branches,
2489        mcdc,
2490    })
2491}
2492
2493fn js_string_cmp(left: &str, right: &str) -> std::cmp::Ordering {
2494    left.encode_utf16().cmp(right.encode_utf16())
2495}
2496
2497fn rounded_delta(newer: f64, older: f64) -> f64 {
2498    ((newer - older) * 100.0).round() / 100.0
2499}
2500
2501pub fn coverage_diff_query(
2502    older: &CoverageIndex<'_>,
2503    newer: &CoverageIndex<'_>,
2504    options: CoverageDiffQueryOptions<'_>,
2505) -> Result<(CoverageDiffData, AgentPagination), QueryError> {
2506    if options.limit == 0 {
2507        return Err(QueryError::InvalidPagination);
2508    }
2509    let older = diff_snapshot(older, options.view)?;
2510    let newer = diff_snapshot(newer, options.view)?;
2511    let mut gained_lines = newer
2512        .lines
2513        .difference(&older.lines)
2514        .cloned()
2515        .collect::<Vec<_>>();
2516    let mut lost_lines = older
2517        .lines
2518        .difference(&newer.lines)
2519        .cloned()
2520        .collect::<Vec<_>>();
2521    let mut gained_branches = newer
2522        .branches
2523        .iter()
2524        .filter(|(id, _)| !older.branches.contains_key(*id))
2525        .map(|(_, label)| label.clone())
2526        .collect::<Vec<_>>();
2527    let mut lost_branches = older
2528        .branches
2529        .iter()
2530        .filter(|(id, _)| !newer.branches.contains_key(*id))
2531        .map(|(_, label)| label.clone())
2532        .collect::<Vec<_>>();
2533    let mut gained_mcdc = newer
2534        .mcdc
2535        .iter()
2536        .filter(|(id, _)| !older.mcdc.contains_key(*id))
2537        .map(|(_, label)| label.clone())
2538        .collect::<Vec<_>>();
2539    let mut lost_mcdc = older
2540        .mcdc
2541        .iter()
2542        .filter(|(id, _)| !newer.mcdc.contains_key(*id))
2543        .map(|(_, label)| label.clone())
2544        .collect::<Vec<_>>();
2545    if options.gained_only {
2546        lost_lines.clear();
2547        lost_branches.clear();
2548        lost_mcdc.clear();
2549    }
2550    for values in [
2551        &mut gained_lines,
2552        &mut lost_lines,
2553        &mut gained_branches,
2554        &mut lost_branches,
2555        &mut gained_mcdc,
2556        &mut lost_mcdc,
2557    ] {
2558        values.sort_by(|left, right| js_string_cmp(left, right));
2559    }
2560    // Every label starts `file:line`, and a file's path holds no space.
2561    let mut by_file = BTreeMap::<&str, (CoverageDiffCounts, CoverageDiffCounts)>::new();
2562    for (values, count) in [
2563        (&lost_lines, 0),
2564        (&lost_branches, 1),
2565        (&lost_mcdc, 2),
2566        (&gained_lines, 3),
2567        (&gained_branches, 4),
2568        (&gained_mcdc, 5),
2569    ] {
2570        for label in values {
2571            let place = label.split(' ').next().unwrap_or(label);
2572            let file = place.rsplit_once(':').map_or(place, |(file, _)| file);
2573            let (lost, gained) = by_file.entry(file).or_default();
2574            let side = if count < 3 { lost } else { gained };
2575            match count % 3 {
2576                0 => side.lines += 1,
2577                1 => side.branches += 1,
2578                _ => side.mcdc += 1,
2579            }
2580        }
2581    }
2582    let files = by_file.len();
2583    let mut by_file = by_file
2584        .into_iter()
2585        .map(|(file, (lost, gained))| CoverageDiffFile {
2586            file: file.to_owned(),
2587            lost,
2588            gained,
2589        })
2590        .collect::<Vec<_>>();
2591    by_file.sort_by(|left, right| {
2592        (right.lost.total(), right.gained.total())
2593            .cmp(&(left.lost.total(), left.gained.total()))
2594            .then_with(|| js_string_cmp(&left.file, &right.file))
2595    });
2596    by_file.truncate(DIFF_FILES);
2597    let total = [
2598        gained_lines.len(),
2599        gained_branches.len(),
2600        gained_mcdc.len(),
2601        lost_lines.len(),
2602        lost_branches.len(),
2603        lost_mcdc.len(),
2604    ]
2605    .into_iter()
2606    .max()
2607    .unwrap_or(0);
2608    let page = |values: &[String]| {
2609        values
2610            .iter()
2611            .skip(options.offset)
2612            .take(options.limit)
2613            .cloned()
2614            .collect::<Vec<_>>()
2615    };
2616    let gained = CoverageDiffSide {
2617        line_count: gained_lines.len(),
2618        branch_count: gained_branches.len(),
2619        mcdc_count: gained_mcdc.len(),
2620        lines: page(&gained_lines),
2621        branches: page(&gained_branches),
2622        mcdc: page(&gained_mcdc),
2623    };
2624    let lost = CoverageDiffSide {
2625        line_count: lost_lines.len(),
2626        branch_count: lost_branches.len(),
2627        mcdc_count: lost_mcdc.len(),
2628        lines: page(&lost_lines),
2629        branches: page(&lost_branches),
2630        mcdc: page(&lost_mcdc),
2631    };
2632    let returned = [
2633        gained.lines.len(),
2634        gained.branches.len(),
2635        gained.mcdc.len(),
2636        lost.lines.len(),
2637        lost.branches.len(),
2638        lost.mcdc.len(),
2639    ]
2640    .into_iter()
2641    .max()
2642    .unwrap_or(0);
2643    Ok((
2644        CoverageDiffData {
2645            filters: query_filters(options.view, options.kind, options.runner),
2646            older: options.older_run.into(),
2647            newer: options.newer_run.into(),
2648            delta: CoverageDiffDelta {
2649                lines: rounded_delta(
2650                    newer.summary.lines.percentage,
2651                    older.summary.lines.percentage,
2652                ),
2653                branches: rounded_delta(
2654                    newer.summary.branches.percentage,
2655                    older.summary.branches.percentage,
2656                ),
2657                mcdc: rounded_delta(
2658                    newer.summary.condition_coverage_pct,
2659                    older.summary.condition_coverage_pct,
2660                ),
2661            },
2662            gained,
2663            lost,
2664            files,
2665            by_file,
2666            inputs: None,
2667            gained_only: options.gained_only,
2668        },
2669        pagination(options.offset, options.limit, returned, total),
2670    ))
2671}
2672
2673pub fn coverage_scope_query(
2674    index: &CoverageIndex<'_>,
2675    options: CoverageScopeQueryOptions<'_>,
2676) -> Result<(CoverageScopeData, AgentPagination), QueryError> {
2677    if options.limit == 0 {
2678        return Err(QueryError::InvalidPagination);
2679    }
2680    let projection = index.projection(options.view, options.kind, options.runner)?;
2681    let scope = projection
2682        .source_scope
2683        .ok_or(QueryError::ScopeUnavailable)?;
2684    let mut entries = index.scope_entries(options.view)?;
2685    entries.sort_by(|left, right| {
2686        scope_status_rank(&left.status)
2687            .cmp(&scope_status_rank(&right.status))
2688            .then_with(|| left.file.cmp(&right.file))
2689    });
2690    let groups = scope_groups(&entries);
2691    let unclassified = unclassified_source(&scope.roots, &entries);
2692    let total = entries.len();
2693    let selected = entries
2694        .into_iter()
2695        .skip(options.offset)
2696        .take(options.limit)
2697        .collect::<Vec<_>>();
2698    let returned = selected.len();
2699    Ok((
2700        CoverageScopeData {
2701            run: options.run.into(),
2702            filters: CoverageQueryFilters {
2703                outcome: match options.view {
2704                    CoverageViewId::All => "all",
2705                    CoverageViewId::Passed => "passed",
2706                    CoverageViewId::Failed => "failed",
2707                }
2708                .into(),
2709                kind: options.kind.map(str::to_owned),
2710                runner: options.runner.map(str::to_owned),
2711            },
2712            kind: scope.kind,
2713            language: scope.language,
2714            model: scope.model,
2715            mode: scope.mode,
2716            roots: scope.roots,
2717            unit: scope.unit,
2718            measurement_complete: scope.measurement_complete,
2719            counts: ScopeCounts {
2720                included: scope.included,
2721                excluded: scope.excluded,
2722                ambiguous: scope.ambiguous,
2723            },
2724            measurement: projection.measurement,
2725            groups,
2726            unclassified_source: unclassified,
2727            entries: selected,
2728        },
2729        pagination(options.offset, options.limit, returned, total),
2730    ))
2731}
2732
2733pub fn coverage_summary_query(
2734    index: &CoverageIndex<'_>,
2735    options: CoverageSummaryQueryOptions<'_>,
2736) -> Result<CoverageSummaryData, QueryError> {
2737    let projection = index.projection(options.view, options.kind, options.runner)?;
2738    let mut test_kind_sources = BTreeMap::new();
2739    for test in index.test_summaries(options.view)?.iter().filter(|t| {
2740        t.role == "test"
2741            && options.kind.is_none_or(|k| t.provenance.kind == k)
2742            && options.runner.is_none_or(|r| t.provenance.runner == r)
2743    }) {
2744        *test_kind_sources
2745            .entry(test.provenance.source.clone())
2746            .or_insert(0) += 1;
2747    }
2748    let mut diagnostics = Vec::new();
2749    let mut transport_blockers = 0usize;
2750    if projection.empty_evidence_tests > 0 {
2751        diagnostics.push(CoverageDiagnostic {
2752            code: "TEST_EVIDENCE_MISSING".into(),
2753            severity: "warning".into(),
2754            message: format!(
2755                "{} test(s) recorded assertion phases but attributed zero coverage evidence; possible causes include checks of uninstrumented data, shared setup, lost async context or missing probe transport. First: {}",
2756                projection.empty_evidence_tests,
2757                projection.first_empty_evidence_test.as_deref().unwrap_or("unknown")
2758            ),
2759        });
2760    }
2761    if let Some(transport) = &projection.transport {
2762        if transport.corrupt_records > 0 {
2763            transport_blockers += 1;
2764            diagnostics.push(CoverageDiagnostic {
2765                code: "CORRUPT_EVIDENCE_RECORDS".into(),
2766                severity: "error".into(),
2767                message: format!(
2768                    "{} malformed evidence record(s) in {} file(s) were excluded; coverage is incomplete.",
2769                    transport.corrupt_records, transport.corrupt_files
2770                ),
2771            });
2772        }
2773        if transport.remote_launches > 0
2774            && transport.scoped_server_records == 0
2775            && transport.background_server_records == 0
2776            && projection.attribution.server_explicit == 0
2777            && projection.attribution.server_fallback == 0
2778        {
2779            transport_blockers += 1;
2780            diagnostics.push(CoverageDiagnostic {
2781                code: "REMOTE_SERVER_EVIDENCE_MISSING".into(),
2782                severity: "error".into(),
2783                message: "Remote launches were supervised, but neither scoped nor background server evidence returned. Supercov refuses to describe this measurement as complete.".into(),
2784            });
2785        }
2786    }
2787    let coverage_by_kind = index.dimensions(options.view, CoverageDimension::Kind)?;
2788    let coverage_by_runner = index.dimensions(options.view, CoverageDimension::Runner)?;
2789    let coverage_by_tests = if options.kind.is_none() && options.runner.is_none() {
2790        index
2791            .dimensions(options.view, CoverageDimension::Tests)?
2792            .into_iter()
2793            .next()
2794            .filter(|tests| tests.tests + tests.setups > 0)
2795            .map(|tests| tests.summary)
2796    } else {
2797        None
2798    };
2799    let other_e2e_kinds = coverage_by_kind
2800        .iter()
2801        .filter(|dimension| dimension.tests > 0)
2802        .filter_map(|dimension| dimension.kind.clone())
2803        .filter(|kind| kind != "e2e")
2804        .collect::<Vec<_>>();
2805    let e2e_observed = coverage_by_kind
2806        .iter()
2807        .any(|dimension| dimension.tests > 0 && dimension.kind.as_deref() == Some("e2e"));
2808    let e2e_gap_context = if options.kind.is_none()
2809        && options.runner.is_none()
2810        && e2e_observed
2811        && !other_e2e_kinds.is_empty()
2812    {
2813        let mut covered_elsewhere = IndexedGapDimensions {
2814            lines: 0,
2815            statements: 0,
2816            functions: 0,
2817            branches: 0,
2818            mcdc_conditions: 0,
2819        };
2820        let mut uncovered_everywhere = covered_elsewhere.clone();
2821        for gap in index.file_gaps(options.view, Some("e2e"), None)? {
2822            covered_elsewhere.lines += gap.covered_by_other_tests.lines;
2823            covered_elsewhere.statements += gap.covered_by_other_tests.statements;
2824            covered_elsewhere.functions += gap.covered_by_other_tests.functions;
2825            covered_elsewhere.branches += gap.covered_by_other_tests.branches;
2826            covered_elsewhere.mcdc_conditions += gap.covered_by_other_tests.mcdc_conditions;
2827            uncovered_everywhere.lines += gap.uncovered_everywhere.lines;
2828            uncovered_everywhere.statements += gap.uncovered_everywhere.statements;
2829            uncovered_everywhere.functions += gap.uncovered_everywhere.functions;
2830            uncovered_everywhere.branches += gap.uncovered_everywhere.branches;
2831            uncovered_everywhere.mcdc_conditions += gap.uncovered_everywhere.mcdc_conditions;
2832        }
2833        Some(CoverageKindGapContext {
2834            kind: "e2e".into(),
2835            other_kinds: other_e2e_kinds,
2836            covered_elsewhere,
2837            uncovered_everywhere,
2838        })
2839    } else {
2840        None
2841    };
2842    let filters = CoverageQueryFilters {
2843        outcome: match options.view {
2844            CoverageViewId::All => "all",
2845            CoverageViewId::Passed => "passed",
2846            CoverageViewId::Failed => "failed",
2847        }
2848        .into(),
2849        kind: options.kind.map(str::to_owned),
2850        runner: options.runner.map(str::to_owned),
2851    };
2852    let mut measurement = projection.measurement.clone();
2853    if transport_blockers > 0 {
2854        measurement.complete = false;
2855        measurement.limitations = measurement.limitations.saturating_add(transport_blockers);
2856        measurement.blocking = measurement.blocking.saturating_add(transport_blockers);
2857    }
2858    let structurally_complete = projection.summary.coverage_complete && measurement.complete;
2859    let complete = options.view == CoverageViewId::Passed
2860        && options.valid
2861        && !options.stale
2862        && structurally_complete;
2863    Ok(CoverageSummaryData {
2864        test_kind_sources,
2865        assertion_coverage: None,
2866        run: options.run.into(),
2867        command: Vec::new(),
2868        merged_across: Vec::new(),
2869        companions: Vec::new(),
2870        hints: Vec::new(),
2871        workspace: None,
2872        filters,
2873        model: index.model()?,
2874        generated_at: projection.generated_at,
2875        valid: options.valid,
2876        test_exit_code: options.test_exit_code,
2877        stale: options.stale,
2878        stale_reasons: options.stale_reasons,
2879        structurally_complete,
2880        complete,
2881        coverage: projection.summary,
2882        measurement,
2883        coverage_by_kind,
2884        coverage_by_tests,
2885        e2e_gap_context,
2886        coverage_by_runner,
2887        attribution: projection.attribution,
2888        test_attribution: {
2889            let mut counts = TestAttributionCounts::default();
2890            for test in index
2891                .test_summaries(options.view)?
2892                .iter()
2893                .filter(|test| test.role == "test")
2894            {
2895                match test.attribution.as_str() {
2896                    crate::coverage_report::ATTRIBUTION_RUN_WIDE => counts.run_wide += 1,
2897                    crate::coverage_report::ATTRIBUTION_PARTIAL => counts.partial += 1,
2898                    crate::coverage_report::ATTRIBUTION_BY_TIME => counts.by_time += 1,
2899                    _ => counts.exact += 1,
2900                }
2901            }
2902            counts
2903        },
2904        transport: projection.transport,
2905        diagnostics,
2906        confidence: (options.kind.is_none() && options.runner.is_none())
2907            .then_some(projection.confidence),
2908        files_with_gaps: projection.files_with_gaps,
2909        files_with_coverage_gaps: projection.files_with_coverage_gaps,
2910        files_with_measurement_limitations: projection.measurement.files,
2911        tests: projection.tests,
2912        setups: projection.setups,
2913        test_outcomes: projection.test_outcomes,
2914        unclassified_source: match &projection.source_scope {
2915            Some(scope) if scope.ambiguous > 0 => {
2916                unclassified_source(&scope.roots, &index.scope_entries(options.view)?)
2917            }
2918            _ => None,
2919        },
2920        source_scope: projection.source_scope,
2921    })
2922}
2923
2924#[derive(Debug, Clone, PartialEq)]
2925pub enum CoverageDimensionQueryData {
2926    Kinds(CoverageKindsData),
2927    Runners(CoverageRunnersData),
2928}
2929
2930/// Coverage of one directory: what its files have to cover, how much of it
2931/// is covered, and how much of it each kind of test covers.
2932#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
2933#[serde(rename_all = "camelCase")]
2934pub struct CoverageArea {
2935    pub directory: String,
2936    pub files: usize,
2937    pub totals: IndexedGapDimensions,
2938    pub covered: IndexedGapDimensions,
2939    pub by_kind: Vec<CoverageAreaKind>,
2940    /// What the tests of every kind cover together. The rest of `covered`
2941    /// ran while no test was running. Absent where the query names a kind or
2942    /// a runner, and for a run stored before this was recorded.
2943    #[serde(skip_serializing_if = "Option::is_none")]
2944    pub covered_by_tests: Option<IndexedGapDimensions>,
2945}
2946
2947#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
2948#[serde(rename_all = "camelCase")]
2949pub struct CoverageAreaKind {
2950    pub kind: String,
2951    pub covered: IndexedGapDimensions,
2952}
2953
2954#[derive(Debug, Clone, PartialEq, Serialize)]
2955#[serde(rename_all = "camelCase")]
2956pub struct CoverageAreasData {
2957    pub run: String,
2958    pub filters: CoverageQueryFilters,
2959    pub metric: MinimizeMetric,
2960    pub depth: usize,
2961    /// The kinds each area is split by, in the order of `byKind`. Empty when
2962    /// the query already names one kind or runner.
2963    pub kinds: Vec<String>,
2964    pub areas: Vec<CoverageArea>,
2965}
2966
2967#[derive(Debug, Clone, Copy)]
2968pub struct CoverageAreasQueryOptions<'a> {
2969    pub run: &'a str,
2970    pub view: CoverageViewId,
2971    pub metric: MinimizeMetric,
2972    pub kind: Option<&'a str>,
2973    pub runner: Option<&'a str>,
2974    pub depth: usize,
2975    pub offset: usize,
2976    pub limit: usize,
2977}
2978
2979/// The directory a file is counted under: its first `depth` folders, or all
2980/// of them when it has fewer. A file at the top of the project is in `.`.
2981fn area_directory(file: &str, depth: usize) -> String {
2982    let mut folders = file.split('/').collect::<Vec<_>>();
2983    folders.pop();
2984    if folders.is_empty() {
2985        ".".into()
2986    } else {
2987        folders[..folders.len().min(depth)].join("/")
2988    }
2989}
2990
2991fn add_dimensions(sum: &mut IndexedGapDimensions, value: &IndexedGapDimensions) {
2992    sum.lines += value.lines;
2993    sum.statements += value.statements;
2994    sum.functions += value.functions;
2995    sum.branches += value.branches;
2996    sum.mcdc_conditions += value.mcdc_conditions;
2997}
2998
2999const NO_DIMENSIONS: IndexedGapDimensions = IndexedGapDimensions {
3000    lines: 0,
3001    statements: 0,
3002    functions: 0,
3003    branches: 0,
3004    mcdc_conditions: 0,
3005};
3006
3007/// One metric of a file's or a directory's counts; `all` reads as lines.
3008pub fn dimension_for_metric(dimensions: &IndexedGapDimensions, metric: MinimizeMetric) -> usize {
3009    match metric {
3010        MinimizeMetric::All | MinimizeMetric::Lines => dimensions.lines,
3011        MinimizeMetric::Statements => dimensions.statements,
3012        MinimizeMetric::Functions => dimensions.functions,
3013        MinimizeMetric::Branches => dimensions.branches,
3014        MinimizeMetric::Mcdc => dimensions.mcdc_conditions,
3015    }
3016}
3017
3018/// Coverage by directory, split by test kind. "Where is the coverage" was the
3019/// first question after a full run, and answering it took an LCOV export and
3020/// a script: listings named files only and carried no totals.
3021pub fn coverage_areas_query(
3022    index: &CoverageIndex<'_>,
3023    options: CoverageAreasQueryOptions<'_>,
3024) -> Result<(CoverageAreasData, AgentPagination), QueryError> {
3025    if options.limit == 0 || options.depth == 0 {
3026        return Err(QueryError::InvalidPagination);
3027    }
3028    let files = index.file_gaps(options.view, options.kind, options.runner)?;
3029    if (options.kind.is_some() || options.runner.is_some()) && files.is_empty() {
3030        return Err(QueryError::TestFilterEmpty {
3031            kind: options.kind.map(str::to_owned),
3032            runner: options.runner.map(str::to_owned),
3033        });
3034    }
3035    let kinds = if options.kind.is_none() && options.runner.is_none() {
3036        index
3037            .dimensions(options.view, CoverageDimension::Kind)?
3038            .into_iter()
3039            .filter(|kind| kind.tests > 0 && kind.attributed > 0)
3040            .filter_map(|kind| kind.kind)
3041            .collect::<Vec<_>>()
3042    } else {
3043        Vec::new()
3044    };
3045    let mut areas = BTreeMap::<String, CoverageArea>::new();
3046    for file in &files {
3047        let directory = area_directory(&file.file, options.depth);
3048        let area = areas
3049            .entry(directory.clone())
3050            .or_insert_with(|| CoverageArea {
3051                directory,
3052                files: 0,
3053                totals: NO_DIMENSIONS,
3054                covered: NO_DIMENSIONS,
3055                by_kind: kinds
3056                    .iter()
3057                    .map(|kind| CoverageAreaKind {
3058                        kind: kind.clone(),
3059                        covered: NO_DIMENSIONS,
3060                    })
3061                    .collect(),
3062                covered_by_tests: None,
3063            });
3064        area.files += 1;
3065        add_dimensions(&mut area.totals, &file.totals);
3066        add_dimensions(&mut area.covered, &file.covered);
3067    }
3068    for (position, kind) in kinds.iter().enumerate() {
3069        for file in index.file_gaps(options.view, Some(kind), None)? {
3070            if let Some(area) = areas.get_mut(&area_directory(&file.file, options.depth)) {
3071                add_dimensions(&mut area.by_kind[position].covered, &file.covered);
3072            }
3073        }
3074    }
3075    if !kinds.is_empty() {
3076        let tested = index.file_gaps(options.view, Some(crate::coverage_index::ALL_TESTS), None)?;
3077        if !tested.is_empty() {
3078            for area in areas.values_mut() {
3079                area.covered_by_tests = Some(NO_DIMENSIONS);
3080            }
3081            for file in tested {
3082                if let Some(sum) = areas
3083                    .get_mut(&area_directory(&file.file, options.depth))
3084                    .and_then(|area| area.covered_by_tests.as_mut())
3085                {
3086                    add_dimensions(sum, &file.covered);
3087                }
3088            }
3089        }
3090    }
3091    let mut areas = areas.into_values().collect::<Vec<_>>();
3092    areas.sort_by(|left, right| {
3093        dimension_for_metric(&right.totals, options.metric)
3094            .cmp(&dimension_for_metric(&left.totals, options.metric))
3095            .then_with(|| left.directory.cmp(&right.directory))
3096    });
3097    let total = areas.len();
3098    let page = areas
3099        .into_iter()
3100        .skip(options.offset)
3101        .take(options.limit)
3102        .collect::<Vec<_>>();
3103    let returned = page.len();
3104    Ok((
3105        CoverageAreasData {
3106            run: options.run.into(),
3107            filters: CoverageQueryFilters {
3108                outcome: match options.view {
3109                    CoverageViewId::All => "all",
3110                    CoverageViewId::Passed => "passed",
3111                    CoverageViewId::Failed => "failed",
3112                }
3113                .into(),
3114                kind: options.kind.map(str::to_owned),
3115                runner: options.runner.map(str::to_owned),
3116            },
3117            metric: options.metric,
3118            depth: options.depth,
3119            kinds,
3120            areas: page,
3121        },
3122        pagination(options.offset, options.limit, returned, total),
3123    ))
3124}
3125
3126/// A test that made assertions and has no line, hit or decision to its name.
3127#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
3128#[serde(rename_all = "camelCase")]
3129pub struct TestWithoutEvidence {
3130    pub id: String,
3131    pub name: String,
3132    #[serde(skip_serializing_if = "Option::is_none")]
3133    pub file: Option<String>,
3134    pub kind: String,
3135    pub runner: String,
3136    pub outcome: String,
3137    pub assertions: usize,
3138    /// Whether the test's file imports any measured source, where Supercov
3139    /// reads its imports and the test runs outside a browser: one that
3140    /// imports none can only have checked test code, mocks or fixtures.
3141    #[serde(skip_serializing_if = "Option::is_none")]
3142    pub imports_measured: Option<bool>,
3143}
3144
3145#[derive(Debug, Clone, PartialEq, Serialize)]
3146#[serde(rename_all = "camelCase")]
3147pub struct TestsWithoutEvidenceData {
3148    pub run: String,
3149    pub filters: CoverageQueryFilters,
3150    pub tests: Vec<TestWithoutEvidence>,
3151}
3152
3153#[derive(Debug, Clone, Copy)]
3154pub struct TestsWithoutEvidenceOptions<'a> {
3155    pub run: &'a str,
3156    pub view: CoverageViewId,
3157    pub kind: Option<&'a str>,
3158    pub runner: Option<&'a str>,
3159    pub offset: usize,
3160    pub limit: usize,
3161}
3162
3163/// Every test the run's summary counts under "made assertions, but Supercov
3164/// received no source-coverage evidence". The summary names one of them; 63
3165/// tests behind one example could not be followed up.
3166pub fn tests_without_evidence_query(
3167    index: &CoverageIndex<'_>,
3168    options: TestsWithoutEvidenceOptions<'_>,
3169) -> Result<(TestsWithoutEvidenceData, AgentPagination), QueryError> {
3170    if options.limit == 0 {
3171        return Err(QueryError::InvalidPagination);
3172    }
3173    let mut assertions = BTreeMap::<String, usize>::new();
3174    for phase in index.phase_summaries(options.view)? {
3175        *assertions.entry(phase.test).or_insert(0) += 1;
3176    }
3177    let mut tests = index
3178        .test_details(options.view)?
3179        .into_iter()
3180        .filter(|test| {
3181            test.summary.role == "test"
3182                && test.lines.is_empty()
3183                && test.hits.is_empty()
3184                && test.decisions.is_empty()
3185                && options
3186                    .kind
3187                    .is_none_or(|kind| test.summary.provenance.kind == kind)
3188                && options
3189                    .runner
3190                    .is_none_or(|runner| test.summary.provenance.runner == runner)
3191        })
3192        .filter_map(|test| {
3193            let assertions = *assertions.get(&test.summary.id)?;
3194            Some(TestWithoutEvidence {
3195                id: test.summary.id,
3196                name: test.summary.name,
3197                file: test.summary.file,
3198                kind: test.summary.provenance.kind,
3199                runner: test.summary.provenance.runner,
3200                outcome: test.summary.outcome,
3201                assertions,
3202                imports_measured: None,
3203            })
3204        })
3205        .collect::<Vec<_>>();
3206    tests.sort_by(|left, right| {
3207        (&left.file, &left.name, &left.id).cmp(&(&right.file, &right.name, &right.id))
3208    });
3209    let total = tests.len();
3210    let page = tests
3211        .into_iter()
3212        .skip(options.offset)
3213        .take(options.limit)
3214        .collect::<Vec<_>>();
3215    let returned = page.len();
3216    Ok((
3217        TestsWithoutEvidenceData {
3218            run: options.run.into(),
3219            filters: CoverageQueryFilters {
3220                outcome: match options.view {
3221                    CoverageViewId::All => "all",
3222                    CoverageViewId::Passed => "passed",
3223                    CoverageViewId::Failed => "failed",
3224                }
3225                .into(),
3226                kind: options.kind.map(str::to_owned),
3227                runner: options.runner.map(str::to_owned),
3228            },
3229            tests: page,
3230        },
3231        pagination(options.offset, options.limit, returned, total),
3232    ))
3233}
3234
3235#[derive(Debug, Clone, PartialEq)]
3236pub enum CoverageFileQueryData {
3237    Files(CoverageFilesData),
3238    Gaps(CoverageGapsData),
3239}
3240
3241#[derive(Debug, Clone, PartialEq)]
3242pub struct CoverageFileQueryResult {
3243    pub data: CoverageFileQueryData,
3244    pub pagination: AgentPagination,
3245}
3246
3247#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
3248#[serde(rename_all = "lowercase")]
3249pub enum DecisionSort {
3250    Location,
3251    Missing,
3252}
3253
3254#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
3255#[serde(rename_all = "camelCase")]
3256pub struct DecisionGapTotals {
3257    pub decisions: usize,
3258    pub decisions_with_missing_conditions: usize,
3259    pub conditions: usize,
3260    pub missing_conditions: usize,
3261}
3262
3263#[derive(Debug, Clone, PartialEq, Serialize)]
3264#[serde(rename_all = "camelCase")]
3265pub struct CoverageFileDecisionsData {
3266    pub run: String,
3267    pub filters: CoverageQueryFilters,
3268    pub file: String,
3269    pub group: String,
3270    pub sort: DecisionSort,
3271    pub totals: DecisionGapTotals,
3272    pub decisions: Vec<IndexedDecisionGap>,
3273}
3274
3275#[derive(Debug, Clone, Copy)]
3276pub struct CoverageFileDecisionsOptions<'a> {
3277    pub run: &'a str,
3278    pub view: CoverageViewId,
3279    pub kind: Option<&'a str>,
3280    pub runner: Option<&'a str>,
3281    pub file: &'a str,
3282    pub sort: DecisionSort,
3283    pub offset: usize,
3284    pub limit: usize,
3285}
3286
3287#[derive(Debug, Clone)]
3288pub struct CoverageDimensionQueryOptions<'a> {
3289    pub run: &'a str,
3290    pub view: CoverageViewId,
3291    pub dimension: CoverageDimension,
3292    pub filters: CoverageQueryFilters,
3293    pub offset: usize,
3294    pub limit: usize,
3295}
3296
3297pub fn coverage_dimension_query(
3298    index: &CoverageIndex<'_>,
3299    options: CoverageDimensionQueryOptions<'_>,
3300) -> Result<(CoverageDimensionQueryData, AgentPagination), QueryError> {
3301    let CoverageDimensionQueryOptions {
3302        run,
3303        view,
3304        dimension,
3305        filters,
3306        offset,
3307        limit,
3308    } = options;
3309    if limit == 0 {
3310        return Err(QueryError::InvalidPagination);
3311    }
3312    let values = index.dimensions(view, dimension)?;
3313    let total = values.len();
3314    let selected = values
3315        .into_iter()
3316        .skip(offset)
3317        .take(limit)
3318        .collect::<Vec<_>>();
3319    let returned = selected.len();
3320    let data = match dimension {
3321        // The tests together are one entry of the summary, never a listing.
3322        CoverageDimension::Kind | CoverageDimension::Tests => {
3323            CoverageDimensionQueryData::Kinds(CoverageKindsData {
3324                run: run.into(),
3325                filters,
3326                kinds: selected,
3327            })
3328        }
3329        CoverageDimension::Runner => CoverageDimensionQueryData::Runners(CoverageRunnersData {
3330            run: run.into(),
3331            filters,
3332            runners: selected,
3333        }),
3334    };
3335    Ok((data, pagination(offset, limit, returned, total)))
3336}
3337
3338#[derive(Debug, Clone, Copy)]
3339pub struct CoverageFileQueryOptions<'a> {
3340    pub run: &'a str,
3341    pub view: CoverageViewId,
3342    pub metric: MinimizeMetric,
3343    pub gaps_only: bool,
3344    pub kind: Option<&'a str>,
3345    pub runner: Option<&'a str>,
3346    pub offset: usize,
3347    pub limit: usize,
3348}
3349
3350fn gap_metric_value(gap: &IndexedFileGap, metric: MinimizeMetric) -> usize {
3351    match metric {
3352        MinimizeMetric::All => gap.score,
3353        MinimizeMetric::Lines => gap.uncovered_lines,
3354        MinimizeMetric::Statements => gap.uncovered_statements,
3355        MinimizeMetric::Functions => gap.uncovered_functions,
3356        MinimizeMetric::Branches => gap.missing_branches,
3357        MinimizeMetric::Mcdc => gap.missing_mcdc_conditions,
3358    }
3359}
3360
3361fn has_gap_for_metric(gap: &IndexedFileGap, metric: MinimizeMetric) -> bool {
3362    match metric {
3363        MinimizeMetric::All => {
3364            gap.uncovered_lines > 0
3365                || gap.uncovered_statements > 0
3366                || gap.uncovered_functions > 0
3367                || gap.missing_branches > 0
3368                || gap.missing_mcdc_conditions > 0
3369        }
3370        _ => gap_metric_value(gap, metric) > 0,
3371    }
3372}
3373
3374pub fn coverage_file_decisions_query(
3375    index: &CoverageIndex<'_>,
3376    options: CoverageFileDecisionsOptions<'_>,
3377) -> Result<(CoverageFileDecisionsData, AgentPagination), QueryError> {
3378    if options.limit == 0 {
3379        return Err(QueryError::InvalidPagination);
3380    }
3381    let all = index.decision_gaps(options.view, options.kind, options.runner, options.file)?;
3382    if (options.kind.is_some() || options.runner.is_some()) && all.is_empty() {
3383        // A file with no decisions is valid, so consult the file projection to
3384        // distinguish it from a nonexistent test provenance projection.
3385        if index
3386            .file_gaps(options.view, options.kind, options.runner)?
3387            .is_empty()
3388        {
3389            return Err(QueryError::TestFilterEmpty {
3390                kind: options.kind.map(str::to_owned),
3391                runner: options.runner.map(str::to_owned),
3392            });
3393        }
3394    }
3395    let totals = DecisionGapTotals {
3396        decisions: all.len(),
3397        decisions_with_missing_conditions: all
3398            .iter()
3399            .filter(|decision| decision.missing_conditions > 0)
3400            .count(),
3401        conditions: all.iter().map(|decision| decision.conditions).sum(),
3402        missing_conditions: all.iter().map(|decision| decision.missing_conditions).sum(),
3403    };
3404    let mut missing = all
3405        .into_iter()
3406        .filter(|decision| decision.missing_conditions > 0)
3407        .collect::<Vec<_>>();
3408    missing.sort_by(|left, right| match options.sort {
3409        DecisionSort::Missing => right
3410            .missing_conditions
3411            .cmp(&left.missing_conditions)
3412            .then_with(|| left.line.cmp(&right.line))
3413            .then_with(|| left.column.cmp(&right.column)),
3414        DecisionSort::Location => left
3415            .line
3416            .cmp(&right.line)
3417            .then_with(|| left.column.cmp(&right.column))
3418            .then_with(|| left.id.cmp(&right.id)),
3419    });
3420    let total = missing.len();
3421    let rows = missing
3422        .into_iter()
3423        .skip(options.offset)
3424        .take(options.limit)
3425        .collect::<Vec<_>>();
3426    let returned = rows.len();
3427    let filters = CoverageQueryFilters {
3428        outcome: match options.view {
3429            CoverageViewId::All => "all",
3430            CoverageViewId::Passed => "passed",
3431            CoverageViewId::Failed => "failed",
3432        }
3433        .into(),
3434        kind: options.kind.map(str::to_owned),
3435        runner: options.runner.map(str::to_owned),
3436    };
3437    Ok((
3438        CoverageFileDecisionsData {
3439            run: options.run.into(),
3440            filters,
3441            file: options.file.into(),
3442            group: "decision".into(),
3443            sort: options.sort,
3444            totals,
3445            decisions: rows,
3446        },
3447        pagination(options.offset, options.limit, returned, total),
3448    ))
3449}
3450
3451pub fn coverage_file_query(
3452    index: &CoverageIndex<'_>,
3453    options: CoverageFileQueryOptions<'_>,
3454) -> Result<CoverageFileQueryResult, QueryError> {
3455    let CoverageFileQueryOptions {
3456        run,
3457        view,
3458        metric,
3459        gaps_only,
3460        kind,
3461        runner,
3462        offset,
3463        limit,
3464    } = options;
3465    if limit == 0 {
3466        return Err(QueryError::InvalidPagination);
3467    }
3468    let mut files = index.file_gaps(view, kind, runner)?;
3469    if (kind.is_some() || runner.is_some()) && files.is_empty() {
3470        return Err(QueryError::TestFilterEmpty {
3471            kind: kind.map(str::to_owned),
3472            runner: runner.map(str::to_owned),
3473        });
3474    }
3475    if gaps_only {
3476        files.retain(|gap| has_gap_for_metric(gap, metric) || gap.measurement_limitations > 0);
3477    }
3478    files.sort_by(|left, right| {
3479        gap_metric_value(right, metric)
3480            .cmp(&gap_metric_value(left, metric))
3481            .then_with(|| {
3482                right
3483                    .measurement_limitations
3484                    .cmp(&left.measurement_limitations)
3485            })
3486            .then_with(|| left.file.cmp(&right.file))
3487    });
3488    let total = files.len();
3489    let page = files
3490        .into_iter()
3491        .skip(offset)
3492        .take(limit)
3493        .collect::<Vec<_>>();
3494    let page_info = pagination(offset, limit, page.len(), total);
3495    let filters = CoverageQueryFilters {
3496        outcome: match view {
3497            CoverageViewId::All => "all",
3498            CoverageViewId::Passed => "passed",
3499            CoverageViewId::Failed => "failed",
3500        }
3501        .into(),
3502        kind: kind.map(str::to_owned),
3503        runner: runner.map(str::to_owned),
3504    };
3505    let data = if gaps_only {
3506        CoverageFileQueryData::Gaps(CoverageGapsData {
3507            run: run.into(),
3508            filters,
3509            metric,
3510            gaps: page,
3511        })
3512    } else {
3513        CoverageFileQueryData::Files(CoverageFilesData {
3514            run: run.into(),
3515            filters,
3516            metric,
3517            files: page,
3518        })
3519    };
3520    Ok(CoverageFileQueryResult {
3521        data,
3522        pagination: page_info,
3523    })
3524}
3525
3526#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
3527enum ObligationMetric {
3528    Lines,
3529    Statements,
3530    Functions,
3531    Branches,
3532    Mcdc,
3533}
3534
3535impl ObligationMetric {
3536    fn selected(self, metric: MinimizeMetric) -> bool {
3537        metric == MinimizeMetric::All
3538            || matches!(
3539                (self, metric),
3540                (Self::Lines, MinimizeMetric::Lines)
3541                    | (Self::Statements, MinimizeMetric::Statements)
3542                    | (Self::Functions, MinimizeMetric::Functions)
3543                    | (Self::Branches, MinimizeMetric::Branches)
3544                    | (Self::Mcdc, MinimizeMetric::Mcdc)
3545            )
3546    }
3547
3548    fn public(self) -> MinimizeMetric {
3549        match self {
3550            Self::Lines => MinimizeMetric::Lines,
3551            Self::Statements => MinimizeMetric::Statements,
3552            Self::Functions => MinimizeMetric::Functions,
3553            Self::Branches => MinimizeMetric::Branches,
3554            Self::Mcdc => MinimizeMetric::Mcdc,
3555        }
3556    }
3557}
3558
3559#[derive(Clone)]
3560struct Obligation {
3561    id: String,
3562    metric: ObligationMetric,
3563    /// Any one complete option satisfies this obligation.
3564    options: Vec<Vec<String>>,
3565}
3566
3567struct ObligationModel {
3568    obligations: Vec<Obligation>,
3569    setup_by_file: BTreeMap<String, Vec<String>>,
3570    tests_by_file: BTreeMap<String, Vec<String>>,
3571    background: Vec<String>,
3572}
3573
3574impl ObligationModel {
3575    fn expand(&self, selected: &BTreeSet<String>) -> BTreeSet<String> {
3576        let mut expanded = selected.clone();
3577        expanded.extend(self.background.iter().cloned());
3578        for (file, setup_ids) in &self.setup_by_file {
3579            if self
3580                .tests_by_file
3581                .get(file)
3582                .is_some_and(|tests| tests.iter().any(|id| selected.contains(id)))
3583            {
3584                expanded.extend(setup_ids.iter().cloned());
3585            }
3586        }
3587        expanded
3588    }
3589}
3590
3591fn deduplicate_options(options: impl IntoIterator<Item = Vec<String>>) -> Vec<Vec<String>> {
3592    let mut unique = BTreeMap::<String, Vec<String>>::new();
3593    for mut option in options {
3594        option.sort();
3595        option.dedup();
3596        let key = option.join("\0");
3597        unique.entry(key).or_insert(option);
3598    }
3599    unique.into_values().collect()
3600}
3601
3602fn evidence_choices(
3603    ids: &[Id],
3604    tests: &HashMap<&str, &crate::coverage_report::TestCoverageResult>,
3605    candidates: &BTreeSet<String>,
3606    tests_by_file: &BTreeMap<String, Vec<String>>,
3607) -> Vec<Vec<String>> {
3608    let mut choices = Vec::new();
3609    for id in ids {
3610        let Some(test) = tests.get(id.as_str()) else {
3611            continue;
3612        };
3613        if test.role == "background" {
3614            choices.push(Vec::new());
3615        } else if test.role == "setup" {
3616            if let Some(file) = &test.file {
3617                choices.extend(
3618                    tests_by_file
3619                        .get(file)
3620                        .into_iter()
3621                        .flatten()
3622                        .map(|candidate| vec![candidate.clone()]),
3623                );
3624            }
3625        } else if candidates.contains(id.as_str()) {
3626            choices.push(vec![id.to_string()]);
3627        }
3628    }
3629    deduplicate_options(choices)
3630}
3631
3632fn build_obligations(view: &CoverageView, candidates: &BTreeSet<String>) -> ObligationModel {
3633    let tests = view
3634        .tests
3635        .iter()
3636        .map(|test| (test.id.as_str(), test))
3637        .collect::<HashMap<_, _>>();
3638    let mut tests_by_file = BTreeMap::<String, Vec<String>>::new();
3639    let mut setup_by_file = BTreeMap::<String, Vec<String>>::new();
3640    let mut background = Vec::new();
3641    for test in &view.tests {
3642        match test.role.as_str() {
3643            "test" if candidates.contains(&test.id) => {
3644                if let Some(file) = &test.file {
3645                    tests_by_file
3646                        .entry(file.clone())
3647                        .or_default()
3648                        .push(test.id.clone());
3649                }
3650            }
3651            "setup" => {
3652                if let Some(file) = &test.file {
3653                    setup_by_file
3654                        .entry(file.clone())
3655                        .or_default()
3656                        .push(test.id.clone());
3657                }
3658            }
3659            "background" => background.push(test.id.clone()),
3660            _ => {}
3661        }
3662    }
3663    let choices = |ids: &[Id]| evidence_choices(ids, &tests, candidates, &tests_by_file);
3664    let mut obligations = Vec::new();
3665    let mut unique_lines = BTreeMap::new();
3666    for line in &view.lines {
3667        unique_lines.insert((line.file.as_str(), line.line), line);
3668    }
3669    for ((file, line), result) in unique_lines {
3670        obligations.push(Obligation {
3671            id: format!("line:{file}:{line}"),
3672            metric: ObligationMetric::Lines,
3673            options: choices(&result.tests),
3674        });
3675    }
3676    for point in &view.points {
3677        let (kind, metric) = match point.meta.kind {
3678            crate::coverage_analysis::PointKind::Statement => {
3679                ("statement", ObligationMetric::Statements)
3680            }
3681            crate::coverage_analysis::PointKind::Function => {
3682                ("function", ObligationMetric::Functions)
3683            }
3684        };
3685        obligations.push(Obligation {
3686            id: format!("{kind}:{}", point.meta.id),
3687            metric,
3688            options: choices(&point.tests),
3689        });
3690    }
3691    for branch in &view.branches {
3692        for alternative in &branch.alternatives {
3693            obligations.push(Obligation {
3694                id: format!("branch:{}:{}", branch.meta.id, alternative.id),
3695                metric: ObligationMetric::Branches,
3696                options: choices(&alternative.tests),
3697            });
3698        }
3699    }
3700    for decision in &view.decisions {
3701        for condition in 0..decision.meta.conditions.len() {
3702            let mut options = Vec::new();
3703            for left in 0..decision.vector_observations.len() {
3704                for right in (left + 1)..decision.vector_observations.len() {
3705                    let first = &decision.vector_observations[left];
3706                    let second = &decision.vector_observations[right];
3707                    if !is_independence_pair(&first.vector, &second.vector, condition) {
3708                        continue;
3709                    }
3710                    for first_choice in choices(&first.tests) {
3711                        for second_choice in choices(&second.tests) {
3712                            let mut combined = first_choice.clone();
3713                            combined.extend(second_choice);
3714                            options.push(combined);
3715                        }
3716                    }
3717                }
3718            }
3719            obligations.push(Obligation {
3720                id: format!("mcdc:{}:{condition}", decision.meta.id),
3721                metric: ObligationMetric::Mcdc,
3722                options: deduplicate_options(options),
3723            });
3724        }
3725    }
3726    ObligationModel {
3727        obligations,
3728        setup_by_file,
3729        tests_by_file,
3730        background,
3731    }
3732}
3733
3734fn percentage(metric: ObligationMetric, summary: &CoverageSummary) -> f64 {
3735    match metric {
3736        ObligationMetric::Lines => summary.lines.percentage,
3737        ObligationMetric::Statements => summary.statements.percentage,
3738        ObligationMetric::Functions => summary.functions.percentage,
3739        ObligationMetric::Branches => summary.branches.percentage,
3740        ObligationMetric::Mcdc => summary.condition_coverage_pct,
3741    }
3742}
3743
3744fn obligation_satisfied(obligation: &Obligation, selected: &BTreeSet<String>) -> bool {
3745    obligation
3746        .options
3747        .iter()
3748        .any(|option| option.iter().all(|test| selected.contains(test)))
3749}
3750
3751struct Search<'a> {
3752    obligations: &'a [Obligation],
3753    skip_limits: BTreeMap<ObligationMetric, usize>,
3754    best: BTreeSet<String>,
3755    explored_states: usize,
3756    max_states: usize,
3757    seen: BTreeSet<String>,
3758    candidate_tests: usize,
3759    target: f64,
3760    metric: MinimizeMetric,
3761}
3762
3763impl Search<'_> {
3764    fn visit(
3765        &mut self,
3766        selected: BTreeSet<String>,
3767        skipped: BTreeSet<String>,
3768        skipped_by_metric: BTreeMap<ObligationMetric, usize>,
3769    ) -> Result<(), QueryError> {
3770        self.explored_states += 1;
3771        if self.explored_states > self.max_states {
3772            return Err(QueryError::ComplexityLimit {
3773                candidate_tests: self.candidate_tests,
3774                obligations: self.obligations.len(),
3775                explored_states: self.explored_states,
3776                max_states: self.max_states,
3777                target: self.target,
3778                metric: self.metric,
3779            });
3780        }
3781        if selected.len() >= self.best.len() {
3782            return Ok(());
3783        }
3784        let state_key = format!(
3785            "{}|{}",
3786            selected.iter().cloned().collect::<Vec<_>>().join(","),
3787            skipped.iter().cloned().collect::<Vec<_>>().join(",")
3788        );
3789        if !self.seen.insert(state_key) {
3790            return Ok(());
3791        }
3792        let mut unmet = self
3793            .obligations
3794            .iter()
3795            .filter(|obligation| {
3796                !skipped.contains(&obligation.id) && !obligation_satisfied(obligation, &selected)
3797            })
3798            .collect::<Vec<_>>();
3799        if unmet.is_empty() {
3800            self.best = selected;
3801            return Ok(());
3802        }
3803        unmet.sort_by(|left, right| {
3804            let feasible = |obligation: &Obligation| {
3805                obligation
3806                    .options
3807                    .iter()
3808                    .filter(|option| option.iter().any(|test| !selected.contains(test)))
3809                    .count()
3810            };
3811            feasible(left)
3812                .cmp(&feasible(right))
3813                .then_with(|| left.id.cmp(&right.id))
3814        });
3815        let obligation = unmet[0];
3816        let mut additions = deduplicate_options(obligation.options.iter().filter_map(|option| {
3817            let addition = option
3818                .iter()
3819                .filter(|test| !selected.contains(*test))
3820                .cloned()
3821                .collect::<Vec<_>>();
3822            (!addition.is_empty()).then_some(addition)
3823        }));
3824        additions.sort_by(|left, right| {
3825            left.len()
3826                .cmp(&right.len())
3827                .then_with(|| left.join("\0").cmp(&right.join("\0")))
3828        });
3829        for addition in additions {
3830            if selected.len() + addition.len() >= self.best.len() {
3831                continue;
3832            }
3833            let mut next = selected.clone();
3834            next.extend(addition);
3835            self.visit(next, skipped.clone(), skipped_by_metric.clone())?;
3836        }
3837        let skipped_count = skipped_by_metric
3838            .get(&obligation.metric)
3839            .copied()
3840            .unwrap_or(0);
3841        if skipped_count
3842            < self
3843                .skip_limits
3844                .get(&obligation.metric)
3845                .copied()
3846                .unwrap_or(0)
3847        {
3848            let mut next_skipped = skipped;
3849            next_skipped.insert(obligation.id.clone());
3850            let mut next_counts = skipped_by_metric;
3851            next_counts.insert(obligation.metric, skipped_count + 1);
3852            self.visit(selected, next_skipped, next_counts)?;
3853        }
3854        Ok(())
3855    }
3856}
3857
3858pub fn minimum_test_set(
3859    view: &CoverageView,
3860    target: f64,
3861    metric: MinimizeMetric,
3862    max_states: usize,
3863) -> Result<MinimumTestSetResult, QueryError> {
3864    use crate::coverage_report::coverage_is_complete;
3865    if !target.is_finite() || !(0.0..=100.0).contains(&target) {
3866        return Err(QueryError::InvalidTarget(target));
3867    }
3868    if view.tests.iter().any(|test| {
3869        test.role == "background" && (!test.hits.is_empty() || !test.decisions.is_empty())
3870    }) {
3871        return Err(QueryError::UnattributedEvidence);
3872    }
3873    // And a test whose own coverage nothing recorded cannot be minimized
3874    // around either. Its empty hit set would read as a test that adds nothing
3875    // to any set, so the smallest set would drop it -- and what it covers is
3876    // precisely what nothing knows. The check above catches this whenever the
3877    // run-wide record holds anything, which is nearly always; this catches the
3878    // case where those tests happened to reach nothing measured.
3879    if view
3880        .tests
3881        .iter()
3882        .any(|test| test.role == "test" && !coverage_is_complete(&test.attribution))
3883    {
3884        return Err(QueryError::UnattributedEvidence);
3885    }
3886    let candidate_tests = view
3887        .tests
3888        .iter()
3889        .filter(|test| test.role == "test")
3890        .map(|test| test.id.clone())
3891        .collect::<BTreeSet<_>>();
3892    let model = build_obligations(view, &candidate_tests);
3893    let obligations = model
3894        .obligations
3895        .iter()
3896        .filter(|obligation| obligation.metric.selected(metric))
3897        .cloned()
3898        .collect::<Vec<_>>();
3899    let mut totals = BTreeMap::<ObligationMetric, usize>::new();
3900    for obligation in &obligations {
3901        *totals.entry(obligation.metric).or_default() += 1;
3902    }
3903    let metrics = [
3904        ObligationMetric::Lines,
3905        ObligationMetric::Statements,
3906        ObligationMetric::Functions,
3907        ObligationMetric::Branches,
3908        ObligationMetric::Mcdc,
3909    ]
3910    .into_iter()
3911    .filter(|candidate| candidate.selected(metric))
3912    .collect::<Vec<_>>();
3913    let skip_limits = metrics
3914        .iter()
3915        .map(|selected_metric| {
3916            let total = totals.get(selected_metric).copied().unwrap_or(0);
3917            let required = ((total as f64 * target) / 100.0).ceil() as usize;
3918            (*selected_metric, total.saturating_sub(required))
3919        })
3920        .collect::<BTreeMap<_, _>>();
3921    let full_expanded = model.expand(&candidate_tests);
3922    let full_summary = coverage_summary_for_tests(view, &full_expanded)?;
3923    for selected_metric in &metrics {
3924        let reachable = percentage(*selected_metric, &full_summary);
3925        if reachable + 1e-9 < target {
3926            return Err(QueryError::TargetUnreachable {
3927                metric: selected_metric.public(),
3928                target,
3929                reachable,
3930            });
3931        }
3932    }
3933    let mut search = Search {
3934        obligations: &obligations,
3935        skip_limits,
3936        best: candidate_tests.clone(),
3937        explored_states: 0,
3938        max_states,
3939        seen: BTreeSet::new(),
3940        candidate_tests: candidate_tests.len(),
3941        target,
3942        metric,
3943    };
3944    search.visit(BTreeSet::new(), BTreeSet::new(), BTreeMap::new())?;
3945    let expanded = model.expand(&search.best);
3946    let summary = coverage_summary_for_tests(view, &expanded)?;
3947    Ok(MinimumTestSetResult {
3948        optimal: true,
3949        target,
3950        metric,
3951        selected: search.best.into_iter().collect(),
3952        expanded: expanded.into_iter().collect(),
3953        summary,
3954        explored_states: search.explored_states,
3955    })
3956}
3957
3958#[cfg(test)]
3959mod tests {
3960    use crate::{
3961        coverage_analysis::McdcVector,
3962        coverage_report::{
3963            CoverageManifest, CoverageReport, CoverageReportRequest, DecisionMeta, ExitCodeInput,
3964            RawTestResult, RuntimeSnapshot, TestProvenance, analyze_coverage_results,
3965        },
3966    };
3967
3968    use super::*;
3969
3970    fn scope_entry(
3971        file: &str,
3972        status: &str,
3973        reason: &str,
3974        package: Option<&str>,
3975    ) -> IndexedScopeEntry {
3976        IndexedScopeEntry {
3977            file: file.into(),
3978            status: status.into(),
3979            reason: reason.into(),
3980            package_root: package.map(str::to_owned),
3981            measurement_limitations: 0,
3982            limitation_kinds: Vec::new(),
3983        }
3984    }
3985
3986    #[test]
3987    fn a_scope_is_grouped_by_place_and_names_the_roots_that_settle_it() {
3988        let unclassified = "unclassified first-party source";
3989        let entries = [
3990            scope_entry("components/a.tsx", "ambiguous", unclassified, None),
3991            scope_entry("components/ui/b.tsx", "ambiguous", unclassified, None),
3992            scope_entry("proxy.ts", "ambiguous", unclassified, None),
3993            scope_entry(
3994                "app/page.tsx",
3995                "included",
3996                "discovered package source root",
3997                None,
3998            ),
3999            scope_entry(
4000                "tests/a.test.ts",
4001                "excluded",
4002                "test or fixture source",
4003                None,
4004            ),
4005            scope_entry(
4006                "next.config.ts",
4007                "excluded",
4008                "build/test/tool configuration",
4009                None,
4010            ),
4011            scope_entry(
4012                "packages/ui/extra/c.ts",
4013                "ambiguous",
4014                unclassified,
4015                Some("packages/ui"),
4016            ),
4017            scope_entry(
4018                "packages/ui/src/d.ts",
4019                "included",
4020                "discovered package source root",
4021                Some("packages/ui"),
4022            ),
4023        ];
4024        let groups = scope_groups(&entries)
4025            .into_iter()
4026            .map(|group| (group.files, group.status, group.directory))
4027            .collect::<Vec<_>>();
4028        assert_eq!(
4029            groups,
4030            [
4031                (2, "ambiguous".to_owned(), "components".to_owned()),
4032                (1, "ambiguous".into(), ".".into()),
4033                (1, "ambiguous".into(), "packages/ui/extra".into()),
4034                (1, "included".into(), "app".into()),
4035                (1, "included".into(), "packages/ui/src".into()),
4036                (1, "excluded".into(), ".".into()),
4037                (1, "excluded".into(), "tests".into()),
4038            ]
4039        );
4040        // The roots discovery found, then each place: a directory by name,
4041        // and a file at the top by its own, never `.`.
4042        let hint =
4043            unclassified_source(&["app".into(), "packages/ui/src".into()], &entries).unwrap();
4044        assert_eq!(
4045            hint.roots,
4046            [
4047                "app",
4048                "packages/ui/src",
4049                "components",
4050                "packages/ui/extra",
4051                "proxy.ts"
4052            ]
4053        );
4054        assert_eq!(hint.directories[0].directory, "components");
4055        assert_eq!(hint.directories[0].files, 2);
4056        assert_eq!(unclassified_source(&[], &entries[3..6]), None);
4057    }
4058
4059    #[test]
4060    fn a_file_is_counted_under_its_first_folders() {
4061        assert_eq!(area_directory("app/api/cron/route.ts", 1), "app");
4062        assert_eq!(area_directory("app/api/cron/route.ts", 2), "app/api");
4063        assert_eq!(area_directory("app/page.tsx", 2), "app");
4064        assert_eq!(area_directory("proxy.ts", 2), ".");
4065    }
4066
4067    #[test]
4068    fn all_metric_keeps_statement_only_files_in_the_gap_set() {
4069        let gap = IndexedFileGap {
4070            view: CoverageViewId::All,
4071            file: "src/statement.js".into(),
4072            uncovered_lines: 0,
4073            uncovered_statements: 1,
4074            uncovered_functions: 0,
4075            missing_branches: 0,
4076            missing_mcdc_conditions: 0,
4077            measurement_limitations: 0,
4078            limitation_kinds: Vec::new(),
4079            covered_by_other_tests: crate::coverage_index::IndexedGapDimensions {
4080                lines: 0,
4081                statements: 0,
4082                functions: 0,
4083                branches: 0,
4084                mcdc_conditions: 0,
4085            },
4086            uncovered_everywhere: crate::coverage_index::IndexedGapDimensions {
4087                lines: 0,
4088                statements: 1,
4089                functions: 0,
4090                branches: 0,
4091                mcdc_conditions: 0,
4092            },
4093            totals: crate::coverage_index::IndexedGapDimensions {
4094                lines: 1,
4095                statements: 2,
4096                functions: 0,
4097                branches: 0,
4098                mcdc_conditions: 0,
4099            },
4100            covered: crate::coverage_index::IndexedGapDimensions {
4101                lines: 1,
4102                statements: 1,
4103                functions: 0,
4104                branches: 0,
4105                mcdc_conditions: 0,
4106            },
4107            score: 0,
4108        };
4109        assert!(has_gap_for_metric(&gap, MinimizeMetric::All));
4110        assert!(has_gap_for_metric(&gap, MinimizeMetric::Statements));
4111        assert!(!has_gap_for_metric(&gap, MinimizeMetric::Lines));
4112    }
4113
4114    fn result(id: &str, vector: McdcVector) -> RawTestResult {
4115        RawTestResult {
4116            test_id: Some(id.into()),
4117            scope: None,
4118            test: id.into(),
4119            test_file: Some("tests/permission.test.js".into()),
4120            title: None,
4121            retry: Some(0),
4122            status: Some("passed".into()),
4123            expected_status: None,
4124            flaky: false,
4125            provenance: TestProvenance {
4126                runner: "node:test".into(),
4127                kind: "unit".into(),
4128                project: None,
4129                source: "runner-default".into(),
4130            },
4131            role: "test".into(),
4132            attribution: crate::coverage_report::ATTRIBUTION_EXACT.into(),
4133            phases: Vec::new(),
4134            runtime: vec![RuntimeSnapshot {
4135                decisions: vec![crate::coverage_report::DecisionSnapshot {
4136                    meta: decision(),
4137                    vectors: vec![vector],
4138                }],
4139                hits: Vec::new(),
4140                events: Vec::new(),
4141                logicals: Vec::new(),
4142                phase_id: None,
4143            }],
4144            browser: Vec::new(),
4145            server: Vec::new(),
4146        }
4147    }
4148
4149    fn decision() -> DecisionMeta {
4150        DecisionMeta {
4151            id: "decision".into(),
4152            file: "src/permission.js".into(),
4153            line: 1,
4154            column: 1,
4155            source: "admin || owner".into(),
4156            conditions: vec!["admin".into(), "owner".into()],
4157            kind: "if".into(),
4158        }
4159    }
4160
4161    fn report(mut results: Vec<RawTestResult>) -> CoverageReport {
4162        analyze_coverage_results(&CoverageReportRequest {
4163            run_id: "run".into(),
4164            manifest: CoverageManifest {
4165                unmeasured: Vec::new(),
4166                decisions: vec![decision()],
4167                points: Vec::new(),
4168                branches: Vec::new(),
4169                limitations: Vec::new(),
4170                scope: None,
4171                assertion_sites: Vec::new(),
4172            },
4173            raw_results: std::mem::take(&mut results),
4174            generated_at: "time".into(),
4175            coverage_model: None,
4176            integrity: None,
4177            test_exit_code: ExitCodeInput::Present(Some(0)),
4178        })
4179        .unwrap()
4180    }
4181
4182    #[test]
4183    fn recomputes_mcdc_witnesses_and_removes_a_redundant_vector() {
4184        let report = report(vec![
4185            result(
4186                "admin",
4187                McdcVector {
4188                    values: vec![Some(true), None],
4189                    outcome: true,
4190                },
4191            ),
4192            result(
4193                "owner",
4194                McdcVector {
4195                    values: vec![Some(false), Some(true)],
4196                    outcome: true,
4197                },
4198            ),
4199            result(
4200                "both",
4201                McdcVector {
4202                    values: vec![Some(true), None],
4203                    outcome: true,
4204                },
4205            ),
4206            result(
4207                "neither",
4208                McdcVector {
4209                    values: vec![Some(false), Some(false)],
4210                    outcome: false,
4211                },
4212            ),
4213        ]);
4214        let minimized = minimum_test_set(&report.view, 100.0, MinimizeMetric::Mcdc, 5_000).unwrap();
4215        assert_eq!(minimized.selected.len(), 3);
4216        assert!(minimized.selected.contains(&"owner".into()));
4217        assert!(minimized.selected.contains(&"neither".into()));
4218        assert_eq!(minimized.summary.condition_coverage_pct, 100.0);
4219    }
4220
4221    #[test]
4222    fn a_test_that_belongs_to_no_test_is_not_minimized_away() {
4223        // The first guard catches this whenever the run-wide record holds
4224        // anything, which is nearly always -- so it is the rare run, where
4225        // those tests happened to reach nothing measured, that reaches this
4226        // one. Without it the smallest set would quietly drop a test whose
4227        // coverage is unknown rather than absent, and report the drop as a
4228        // saving.
4229        let mut parallel = result(
4230            "parallel",
4231            McdcVector {
4232                values: vec![Some(false), Some(false)],
4233                outcome: false,
4234            },
4235        );
4236        parallel.attribution = crate::coverage_report::ATTRIBUTION_RUN_WIDE.into();
4237        assert!(matches!(
4238            minimum_test_set(
4239                &report(vec![parallel]).view,
4240                100.0,
4241                MinimizeMetric::Mcdc,
4242                5_000,
4243            ),
4244            Err(QueryError::UnattributedEvidence)
4245        ));
4246    }
4247
4248    #[test]
4249    fn the_summary_counts_each_test_under_the_attribution_it_carries() {
4250        // These three numbers are what the `Attribution` line reads out, and
4251        // nothing -- here or in the integration scripts -- had ever checked
4252        // that a test lands in the bucket it was written with.
4253        let vector = || McdcVector {
4254            values: vec![Some(false), Some(false)],
4255            outcome: false,
4256        };
4257        let mut serial = result("serial", vector());
4258        serial.attribution = crate::coverage_report::ATTRIBUTION_EXACT.into();
4259        let mut parallel = result("parallel", vector());
4260        parallel.attribution = crate::coverage_report::ATTRIBUTION_RUN_WIDE.into();
4261        let mut ruby = result("ruby", vector());
4262        ruby.attribution = crate::coverage_report::ATTRIBUTION_PARTIAL.into();
4263        let mut also_parallel = result("also-parallel", vector());
4264        also_parallel.attribution = crate::coverage_report::ATTRIBUTION_RUN_WIDE.into();
4265        let report = report(vec![serial, parallel, ruby, also_parallel]);
4266
4267        let nonce = std::time::SystemTime::now()
4268            .duration_since(std::time::UNIX_EPOCH)
4269            .unwrap()
4270            .as_nanos();
4271        let root = std::env::temp_dir().join(format!(
4272            "supercov-attribution-counts-{}-{nonce}",
4273            std::process::id()
4274        ));
4275        std::fs::create_dir(&root).unwrap();
4276        let path = root.join("query-index.v1.bin");
4277        let identity = crate::query_index::QueryIndexIdentity {
4278            evidence_sha256: [1; 32],
4279            evidence_bytes: 100,
4280            analysis_sha256: [2; 32],
4281            producer_sha256: [3; 32],
4282            archive_schema_version: 2,
4283        };
4284        crate::query_index::write_query_index(
4285            &crate::coverage_index::coverage_index_sections(&report).unwrap(),
4286            &identity,
4287            &path,
4288        )
4289        .unwrap();
4290        let container = crate::query_index::QueryIndex::open(&path, &identity).unwrap();
4291        let index = crate::coverage_index::CoverageIndex::new(&container).unwrap();
4292
4293        let summary = coverage_summary_query(
4294            &index,
4295            CoverageSummaryQueryOptions {
4296                run: "run",
4297                view: CoverageViewId::All,
4298                kind: None,
4299                runner: None,
4300                valid: true,
4301                test_exit_code: Some(0),
4302                stale: false,
4303                stale_reasons: Vec::new(),
4304            },
4305        )
4306        .unwrap();
4307        assert_eq!(summary.test_attribution.exact, 1);
4308        assert_eq!(summary.test_attribution.run_wide, 2);
4309        assert_eq!(summary.test_attribution.partial, 1);
4310        assert_eq!(summary.test_attribution.total(), 4);
4311        std::fs::remove_dir_all(&root).ok();
4312    }
4313
4314    #[test]
4315    fn refuses_background_evidence() {
4316        let mut aggregate = result(
4317            "aggregate",
4318            McdcVector {
4319                values: vec![Some(false), Some(false)],
4320                outcome: false,
4321            },
4322        );
4323        aggregate.role = "background".into();
4324        assert!(matches!(
4325            minimum_test_set(
4326                &report(vec![aggregate]).view,
4327                100.0,
4328                MinimizeMetric::Mcdc,
4329                5_000,
4330            ),
4331            Err(QueryError::UnattributedEvidence)
4332        ));
4333    }
4334
4335    #[test]
4336    fn bounds_the_exact_search() {
4337        let report = report(vec![
4338            result(
4339                "admin",
4340                McdcVector {
4341                    values: vec![Some(true), None],
4342                    outcome: true,
4343                },
4344            ),
4345            result(
4346                "owner",
4347                McdcVector {
4348                    values: vec![Some(false), Some(true)],
4349                    outcome: true,
4350                },
4351            ),
4352            result(
4353                "neither",
4354                McdcVector {
4355                    values: vec![Some(false), Some(false)],
4356                    outcome: false,
4357                },
4358            ),
4359        ]);
4360        assert!(matches!(
4361            minimum_test_set(&report.view, 100.0, MinimizeMetric::Mcdc, 1),
4362            Err(QueryError::ComplexityLimit { .. })
4363        ));
4364    }
4365}