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 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), ¤t));
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 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}