Skip to main content

supercov_engine/
run_query.rs

1//! Agent-facing queries over persisted run metadata.
2
3use serde::Serialize;
4use supercov_contracts::AgentPagination;
5
6use crate::{
7    agent_json,
8    coverage_analysis::serialize_javascript_number,
9    coverage_index::{CoverageIndex, CoverageViewId},
10    coverage_query::CoverageQueryFilters,
11    run_store::{
12        RawEvidenceMetadata, RunIntegrity, RunInventory, RunTimings, compare_run_integrity,
13        open_existing_query_index,
14    },
15};
16
17#[derive(Debug, Clone, PartialEq, Serialize)]
18#[serde(rename_all = "camelCase")]
19pub struct RunListEntry {
20    pub id: String,
21    pub generated_at: String,
22    pub coverage_indexed: bool,
23    #[serde(serialize_with = "serialize_optional_javascript_number")]
24    pub lines: Option<f64>,
25    #[serde(serialize_with = "serialize_optional_javascript_number")]
26    pub branches: Option<f64>,
27    #[serde(serialize_with = "serialize_optional_javascript_number")]
28    pub mcdc: Option<f64>,
29    pub command: Vec<String>,
30    #[serde(serialize_with = "serialize_javascript_number")]
31    pub duration_ms: f64,
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub timings: Option<RunTimings>,
34    pub test_exit_code: Option<i32>,
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub build_reused: Option<bool>,
37    pub raw_evidence: RawEvidenceMetadata,
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub stale: Option<bool>,
40    pub reasons: Vec<String>,
41}
42
43fn serialize_optional_javascript_number<S>(
44    value: &Option<f64>,
45    serializer: S,
46) -> Result<S::Ok, S::Error>
47where
48    S: serde::Serializer,
49{
50    match value {
51        Some(value) => serialize_javascript_number(value, serializer),
52        None => serializer.serialize_none(),
53    }
54}
55
56#[derive(Debug, Clone, PartialEq, Serialize)]
57#[serde(rename_all = "camelCase")]
58pub struct RunListData {
59    pub filters: CoverageQueryFilters,
60    pub runs: Vec<RunListEntry>,
61}
62
63pub fn run_list_query(
64    inventory: &RunInventory,
65    current_integrity: Option<&RunIntegrity>,
66    view: CoverageViewId,
67    offset: usize,
68    limit: usize,
69) -> (RunListData, AgentPagination) {
70    let runs = inventory
71        .runs
72        .iter()
73        .skip(offset)
74        .take(limit)
75        .map(|run| {
76            let summary = open_existing_query_index(run)
77                .ok()
78                .flatten()
79                .and_then(|container| CoverageIndex::new(&container).ok()?.summary(view).ok());
80            let comparison = current_integrity
81                .map(|current| compare_run_integrity(Some(&run.metadata.integrity), current));
82            RunListEntry {
83                id: run.id.clone(),
84                generated_at: run.metadata.started_at.clone(),
85                coverage_indexed: summary.is_some(),
86                lines: summary.as_ref().map(|summary| summary.lines.percentage),
87                branches: summary.as_ref().map(|summary| summary.branches.percentage),
88                mcdc: summary
89                    .as_ref()
90                    .map(|summary| summary.condition_coverage_pct),
91                command: run.metadata.command.clone(),
92                duration_ms: run.metadata.duration_ms,
93                timings: run.metadata.timings.clone(),
94                test_exit_code: run.metadata.test_exit_code,
95                build_reused: run
96                    .metadata
97                    .instrumented_build_cache
98                    .as_ref()
99                    .map(|cache| cache.reused),
100                raw_evidence: run.metadata.raw_evidence.clone(),
101                stale: comparison.as_ref().map(|comparison| comparison.stale),
102                reasons: comparison
103                    .map(|comparison| comparison.reasons)
104                    .unwrap_or_default(),
105            }
106        })
107        .collect::<Vec<_>>();
108    let page = agent_json::pagination(offset, limit, runs.len(), inventory.runs.len());
109    (
110        RunListData {
111            filters: CoverageQueryFilters {
112                outcome: match view {
113                    CoverageViewId::All => "all",
114                    CoverageViewId::Passed => "passed",
115                    CoverageViewId::Failed => "failed",
116                }
117                .into(),
118                kind: None,
119                runner: None,
120            },
121            runs,
122        },
123        page,
124    )
125}
126
127#[cfg(test)]
128mod tests {
129    use std::{
130        fs,
131        path::{Path, PathBuf},
132        time::{SystemTime, UNIX_EPOCH},
133    };
134
135    use crate::run_store::{discover_runs, open_or_rebuild_query_index, select_run};
136
137    use super::*;
138
139    fn temporary_directory() -> PathBuf {
140        let nonce = SystemTime::now()
141            .duration_since(UNIX_EPOCH)
142            .unwrap()
143            .as_nanos();
144        let path =
145            std::env::temp_dir().join(format!("supercov-run-query-{}-{nonce}", std::process::id()));
146        fs::create_dir_all(&path).unwrap();
147        path
148    }
149
150    fn copy_real_fixture_run(root: &Path) -> RunInventory {
151        let workspace = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
152        let fixture = workspace.join("tests/fixtures/generic-webpack");
153        let source_inventory = discover_runs(&fixture).unwrap();
154        let source = select_run(&source_inventory, Some("latest")).unwrap();
155        let destination = root.join(".supercov/runs").join(&source.id);
156        fs::create_dir_all(&destination).unwrap();
157        fs::copy(&source.metadata_path, destination.join("run.json")).unwrap();
158        fs::copy(&source.evidence_path, destination.join("evidence.raw.gz")).unwrap();
159        discover_runs(root).unwrap()
160    }
161
162    #[test]
163    fn lists_persisted_metadata_without_building_an_index_then_reads_the_typed_index() {
164        let root = temporary_directory();
165        let inventory = copy_real_fixture_run(&root);
166        let run = &inventory.runs[0];
167        let (before, page) = run_list_query(&inventory, None, CoverageViewId::All, 0, 20);
168        assert_eq!(page.total, 1);
169        assert!(!before.runs[0].coverage_indexed);
170        assert_eq!(before.runs[0].lines, None);
171        assert!(!run.query_index_path.exists());
172
173        open_or_rebuild_query_index(run).unwrap();
174        let (after, page) = run_list_query(
175            &inventory,
176            Some(&run.metadata.integrity),
177            CoverageViewId::Passed,
178            0,
179            20,
180        );
181        assert!(after.runs[0].coverage_indexed);
182        assert_eq!(after.runs[0].lines, Some(100.0));
183        assert_eq!(after.runs[0].branches, Some(100.0));
184        assert_eq!(after.runs[0].mcdc, Some(100.0));
185        assert_eq!(after.runs[0].stale, Some(false));
186        assert!(after.runs[0].reasons.is_empty());
187        assert_eq!(after.filters.outcome, "passed");
188        assert!(agent_json::success("runs", &after, Some(&page)).is_ok());
189
190        let (_, empty_page) = run_list_query(&inventory, None, CoverageViewId::All, 20, 20);
191        assert_eq!(empty_page.returned, 0);
192        assert!(!empty_page.has_more);
193        fs::remove_dir_all(root).unwrap();
194    }
195
196    #[test]
197    fn reports_staleness_in_contract_order_and_treats_a_bad_index_as_disposable() {
198        let root = temporary_directory();
199        let inventory = copy_real_fixture_run(&root);
200        let run = &inventory.runs[0];
201        open_or_rebuild_query_index(run).unwrap();
202        fs::write(&run.query_index_path, b"broken disposable index").unwrap();
203
204        let mut current = run.metadata.integrity.clone();
205        current.fingerprint.source = "1".repeat(64);
206        current.fingerprint.tests = "2".repeat(64);
207        let (listing, _) = run_list_query(&inventory, Some(&current), CoverageViewId::All, 0, 20);
208        assert!(!listing.runs[0].coverage_indexed);
209        assert_eq!(
210            listing.runs[0].reasons,
211            ["instrumented source changed", "test files changed"]
212        );
213        assert_eq!(
214            fs::read(&run.query_index_path).unwrap(),
215            b"broken disposable index"
216        );
217        fs::remove_dir_all(root).unwrap();
218    }
219}