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, RunIndexError, RunIntegrity, RunInventory, RunTimings, StoredRun,
13        compare_run_integrity, open_or_rebuild_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    #[serde(skip_serializing_if = "Option::is_none")]
23    pub lines: Option<f64>,
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub branches: Option<f64>,
26    #[serde(skip_serializing_if = "Option::is_none")]
27    pub mcdc: Option<f64>,
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub coverage_error: Option<String>,
30    /// How many tests the run recorded. None ran in a run whose command
31    /// failed before its runner started, or was never a test command.
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub tests: Option<usize>,
34    pub command: Vec<String>,
35    #[serde(serialize_with = "serialize_javascript_number")]
36    pub duration_ms: f64,
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub timings: Option<RunTimings>,
39    pub test_exit_code: Option<i32>,
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub build_reused: Option<bool>,
42    pub raw_evidence: RawEvidenceMetadata,
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub stale: Option<bool>,
45    pub reasons: Vec<String>,
46}
47
48#[derive(Debug, Clone, PartialEq, Serialize)]
49#[serde(rename_all = "camelCase")]
50pub struct RunListData {
51    pub filters: CoverageQueryFilters,
52    pub runs: Vec<RunListEntry>,
53}
54
55pub fn run_list_query(
56    inventory: &RunInventory,
57    current_integrity: &dyn Fn(&StoredRun) -> Option<RunIntegrity>,
58    view: CoverageViewId,
59    offset: usize,
60    limit: usize,
61) -> Result<(RunListData, AgentPagination), RunIndexError> {
62    let runs = inventory
63        .runs
64        .iter()
65        .skip(offset)
66        .take(limit)
67        .map(|run| -> Result<RunListEntry, RunIndexError> {
68            let summary = open_or_rebuild_query_index(run).and_then(|container| {
69                let index = CoverageIndex::new(&container)?;
70                Ok((
71                    index.summary(view)?,
72                    index.projection(view, None, None)?.tests,
73                ))
74            });
75            let (lines, branches, mcdc, tests, coverage_error) = match summary {
76                Ok((summary, tests)) => (
77                    Some(summary.lines.percentage),
78                    Some(summary.branches.percentage),
79                    Some(summary.condition_coverage_pct),
80                    Some(tests),
81                    None,
82                ),
83                Err(error) => (None, None, None, None, Some(error.to_string())),
84            };
85            let comparison = current_integrity(run)
86                .map(|current| compare_run_integrity(Some(&run.metadata.integrity), &current));
87            Ok(RunListEntry {
88                id: run.id.clone(),
89                generated_at: run.metadata.started_at.clone(),
90                lines,
91                branches,
92                mcdc,
93                coverage_error,
94                tests,
95                command: run.metadata.command.clone(),
96                duration_ms: run.metadata.duration_ms,
97                timings: run.metadata.timings.clone(),
98                test_exit_code: run.metadata.test_exit_code,
99                build_reused: run
100                    .metadata
101                    .instrumented_build_cache
102                    .as_ref()
103                    .map(|cache| cache.reused),
104                raw_evidence: run.metadata.raw_evidence.clone(),
105                stale: comparison.as_ref().map(|comparison| comparison.stale),
106                reasons: comparison
107                    .map(|comparison| comparison.reasons)
108                    .unwrap_or_default(),
109            })
110        })
111        .collect::<Result<Vec<_>, _>>()?;
112    let page = agent_json::pagination(offset, limit, runs.len(), inventory.runs.len());
113    Ok((
114        RunListData {
115            filters: CoverageQueryFilters {
116                outcome: match view {
117                    CoverageViewId::All => "all",
118                    CoverageViewId::Passed => "passed",
119                    CoverageViewId::Failed => "failed",
120                }
121                .into(),
122                kind: None,
123                runner: None,
124            },
125            runs,
126        },
127        page,
128    ))
129}
130
131#[cfg(test)]
132mod tests {
133    use std::{
134        fs,
135        path::{Path, PathBuf},
136        sync::atomic::{AtomicU64, Ordering},
137        time::{SystemTime, UNIX_EPOCH},
138    };
139
140    use crate::run_store::{
141        create_analyzable_test_run, discover_runs, open_or_rebuild_query_index,
142    };
143
144    use super::*;
145
146    fn temporary_directory() -> PathBuf {
147        // The clock ticks once per microsecond, so two tests that start
148        // together drew the same directory and queried each other's runs. The
149        // counter is what makes each name its own, and `create_dir` -- not
150        // `create_dir_all` -- means a repeat fails here rather than somewhere
151        // that reads as a bug in the code under test.
152        static UNIQUE: AtomicU64 = AtomicU64::new(0);
153        let nonce = SystemTime::now()
154            .duration_since(UNIX_EPOCH)
155            .unwrap()
156            .as_nanos();
157        let path = std::env::temp_dir().join(format!(
158            "supercov-run-query-{}-{nonce}-{}",
159            std::process::id(),
160            UNIQUE.fetch_add(1, Ordering::Relaxed)
161        ));
162        fs::create_dir(&path).unwrap();
163        path
164    }
165
166    fn create_indexable_run(root: &Path) -> RunInventory {
167        create_analyzable_test_run(root, "test-run");
168        discover_runs(root).unwrap()
169    }
170
171    #[test]
172    fn lists_persisted_metadata_and_lazily_builds_the_typed_index() {
173        let root = temporary_directory();
174        let inventory = create_indexable_run(&root);
175        let run = &inventory.runs[0];
176        let (listing, page) =
177            run_list_query(&inventory, &|_| None, CoverageViewId::All, 0, 20).unwrap();
178        assert_eq!(page.total, 1);
179        assert_eq!(listing.runs[0].lines, Some(100.0));
180        assert_eq!(listing.runs[0].branches, Some(100.0));
181        assert_eq!(listing.runs[0].mcdc, Some(100.0));
182        assert_eq!(listing.runs[0].coverage_error, None);
183        assert!(run.query_index_path.exists());
184        assert!(agent_json::success("runs", &listing, Some(&page)).is_ok());
185
186        let (_, empty_page) =
187            run_list_query(&inventory, &|_| None, CoverageViewId::All, 20, 20).unwrap();
188        assert_eq!(empty_page.returned, 0);
189        assert!(!empty_page.has_more);
190        fs::remove_dir_all(root).unwrap();
191    }
192
193    #[test]
194    fn reports_staleness_in_contract_order_and_treats_a_bad_index_as_disposable() {
195        let root = temporary_directory();
196        let inventory = create_indexable_run(&root);
197        let run = &inventory.runs[0];
198        open_or_rebuild_query_index(run).unwrap();
199        fs::write(&run.query_index_path, b"broken disposable index").unwrap();
200
201        let mut current = run.metadata.integrity.clone();
202        current.fingerprint.source = "1".repeat(64);
203        current.fingerprint.tests = "2".repeat(64);
204        let (listing, _) = run_list_query(
205            &inventory,
206            &|_| Some(current.clone()),
207            CoverageViewId::All,
208            0,
209            20,
210        )
211        .unwrap();
212        assert_eq!(listing.runs[0].lines, Some(100.0));
213        assert_eq!(
214            listing.runs[0].reasons,
215            ["instrumented source changed", "test files changed"]
216        );
217        assert_ne!(
218            fs::read(&run.query_index_path).unwrap(),
219            b"broken disposable index"
220        );
221        fs::remove_dir_all(root).unwrap();
222    }
223}