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