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