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