Skip to main content

supercov_engine/
coverage_query.rs

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