1use 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 #[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), ¤t));
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 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}