1use std::{
4 collections::BTreeMap,
5 fs,
6 path::{Component, Path, PathBuf},
7 process::Command,
8};
9
10use serde::{Deserialize, Serialize};
11use sha2::{Digest, Sha256};
12
13use crate::{lifecycle::atomic_write, project_discovery::CoverageProject, run_store::RunIntegrity};
14
15pub const BUILD_CACHE_SCHEMA_VERSION: u32 = 1;
16const OUTPUT_CANDIDATES: &[&str] = &["build", "dist", ".next", ".nuxt", ".output"];
17const SCAN_EXCLUSIONS: &[&str] = &[".git", ".supercov", "node_modules"];
18const SCAN_DEPTH_LIMIT: usize = 6;
19
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "camelCase", deny_unknown_fields)]
22pub struct BuildCacheMetadata {
23 pub schema_version: u32,
24 pub key: String,
25 pub created_at: String,
26 pub artifact_paths: Vec<String>,
27}
28
29#[derive(Serialize)]
30#[serde(rename_all = "camelCase")]
31struct CacheIdentity<'a> {
32 schema_version: u32,
33 execution_fingerprint: &'a str,
34 instrumenter_fingerprint: &'a str,
37 adapter: crate::project_discovery::BuildAdapter,
38 root: &'a Path,
41 sources: &'a [String],
45 command: &'a [String],
46 environment: &'a BTreeMap<String, String>,
47 node: String,
48 platform: &'static str,
49 architecture: &'static str,
50}
51
52fn safe_relative(path: &Path) -> bool {
53 path.components().next().is_some()
54 && path
55 .components()
56 .all(|component| matches!(component, Component::Normal(_)))
57}
58
59fn regular_artifact(workspace: &Path, relative: &Path) -> bool {
60 safe_relative(relative)
61 && fs::symlink_metadata(workspace.join(relative))
62 .is_ok_and(|metadata| metadata.file_type().is_file() || metadata.file_type().is_dir())
63}
64
65fn node_version() -> String {
66 std::env::var("SUPERCOV_NODE_VERSION").unwrap_or_else(|_| {
67 Command::new("node")
68 .args(["--print", "process.versions.node"])
69 .output()
70 .ok()
71 .filter(|output| output.status.success())
72 .and_then(|output| String::from_utf8(output.stdout).ok())
73 .map(|value| value.trim().to_owned())
74 .filter(|value| !value.is_empty())
75 .unwrap_or_else(|| "unavailable".into())
76 })
77}
78
79pub fn build_cache_key(
80 integrity: &RunIntegrity,
81 project: &CoverageProject,
82) -> Result<String, String> {
83 let identity = CacheIdentity {
84 schema_version: BUILD_CACHE_SCHEMA_VERSION,
85 execution_fingerprint: &integrity.fingerprint.execution,
86 instrumenter_fingerprint: &integrity.fingerprint.instrumenter,
87 adapter: project.build_adapter,
88 root: &project.root,
89 sources: &project.source_files,
90 command: &project.build_command,
91 environment: &project.build_environment,
92 node: node_version(),
93 platform: std::env::consts::OS,
94 architecture: std::env::consts::ARCH,
95 };
96 let bytes = serde_json::to_vec(&identity)
97 .map_err(|error| format!("failed to serialize build-cache identity: {error}"))?;
98 Ok(format!("{:x}", Sha256::digest(bytes)))
99}
100
101pub fn read_build_cache(workspace: &Path, key: &str) -> Option<BuildCacheMetadata> {
102 let path = workspace.join(".supercov/build-cache.json");
103 if !fs::symlink_metadata(&path).is_ok_and(|metadata| metadata.file_type().is_file()) {
104 return None;
105 }
106 let metadata: BuildCacheMetadata = serde_json::from_slice(&fs::read(path).ok()?).ok()?;
107 if metadata.schema_version != BUILD_CACHE_SCHEMA_VERSION
108 || metadata.key != key
109 || metadata.artifact_paths.is_empty()
110 || metadata
111 .artifact_paths
112 .iter()
113 .any(|path| !regular_artifact(workspace, Path::new(path)))
114 {
115 return None;
116 }
117 Some(metadata)
118}
119
120pub fn reuse_paths(metadata: &BuildCacheMetadata) -> Vec<PathBuf> {
121 metadata
122 .artifact_paths
123 .iter()
124 .map(PathBuf::from)
125 .chain([PathBuf::from(".supercov/build-cache.json")])
126 .collect()
127}
128
129#[derive(Deserialize, Default)]
130struct DeclaredOutputs {
131 #[serde(default)]
132 paths: Vec<String>,
133}
134
135fn workspace_output_directories(workspace: &Path) -> Vec<String> {
139 let mut found = Vec::new();
140 let mut pending = vec![(workspace.to_owned(), 0usize)];
141 while let Some((directory, depth)) = pending.pop() {
142 let Ok(entries) = fs::read_dir(&directory) else {
143 continue;
144 };
145 for entry in entries.flatten() {
146 if !entry.file_type().is_ok_and(|kind| kind.is_dir()) {
147 continue;
148 }
149 let name = entry.file_name();
150 let Some(name) = name.to_str() else {
151 continue;
152 };
153 if SCAN_EXCLUSIONS.contains(&name) {
154 continue;
155 }
156 if OUTPUT_CANDIDATES.contains(&name) {
157 if let Ok(relative) = entry.path().strip_prefix(workspace) {
158 found.push(slash_path(relative));
159 }
160 } else if depth < SCAN_DEPTH_LIMIT {
161 pending.push((entry.path(), depth + 1));
162 }
163 }
164 }
165 found
166}
167
168fn slash_path(path: &Path) -> String {
172 path.components()
173 .map(|component| component.as_os_str().to_string_lossy().into_owned())
174 .collect::<Vec<_>>()
175 .join("/")
176}
177
178pub fn write_build_cache(
179 project_root: &Path,
180 workspace: &Path,
181 key: &str,
182 created_at: &str,
183) -> Result<Option<BuildCacheMetadata>, String> {
184 let declared = fs::read(workspace.join(".supercov/build-outputs.json"))
185 .ok()
186 .and_then(|bytes| serde_json::from_slice::<DeclaredOutputs>(&bytes).ok())
187 .unwrap_or_default();
188 let mut candidates = workspace_output_directories(workspace)
189 .into_iter()
190 .chain(
191 declared
192 .paths
193 .into_iter()
194 .filter(|path| safe_relative(Path::new(path))),
195 )
196 .collect::<Vec<_>>();
197 candidates.sort();
198 candidates.dedup();
199 candidates.retain(|path| regular_artifact(workspace, Path::new(path)));
200 let existing = candidates.clone();
201 candidates.retain(|path| {
202 !existing.iter().any(|parent| {
203 parent != path
204 && Path::new(path)
205 .strip_prefix(Path::new(parent))
206 .is_ok_and(|local| local.components().next().is_some())
207 })
208 });
209 if candidates.is_empty() || !regular_artifact(workspace, Path::new(".supercov/manifest.json")) {
210 return Ok(None);
211 }
212 candidates.push(".supercov/manifest.json".into());
213 let metadata = BuildCacheMetadata {
214 schema_version: BUILD_CACHE_SCHEMA_VERSION,
215 key: key.into(),
216 created_at: created_at.into(),
217 artifact_paths: candidates,
218 };
219 let mut bytes = serde_json::to_vec_pretty(&metadata)
220 .map_err(|error| format!("failed to serialize build-cache metadata: {error}"))?;
221 bytes.push(b'\n');
222 atomic_write(
223 project_root,
224 &workspace.join(".supercov/build-cache.json"),
225 &bytes,
226 )
227 .map_err(|error| error.to_string())?;
228 Ok(Some(metadata))
229}
230
231#[cfg(test)]
232mod tests {
233 use std::time::{SystemTime, UNIX_EPOCH};
234
235 use super::*;
236
237 fn temporary() -> PathBuf {
238 static UNIQUE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
241 let nonce = SystemTime::now()
242 .duration_since(UNIX_EPOCH)
243 .unwrap()
244 .as_nanos();
245 let root = std::env::temp_dir().join(format!(
246 "supercov-build-cache-{}-{nonce}-{}",
247 std::process::id(),
248 UNIQUE.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
249 ));
250 fs::create_dir_all(&root).unwrap();
251 root
252 }
253
254 #[test]
255 fn writes_reads_and_rejects_incomplete_exact_cache_metadata() {
256 let root = temporary();
257 let workspace = root.join(".supercov/cache/workspace/project");
258 fs::create_dir_all(workspace.join(".supercov")).unwrap();
259 fs::create_dir_all(workspace.join("dist")).unwrap();
260 fs::write(workspace.join("dist/app.js"), "built").unwrap();
261 fs::write(workspace.join(".supercov/manifest.json"), "{}").unwrap();
262 let written = write_build_cache(&root, &workspace, "key", "time")
263 .unwrap()
264 .unwrap();
265 assert_eq!(written.artifact_paths, ["dist", ".supercov/manifest.json"]);
266 assert_eq!(read_build_cache(&workspace, "key"), Some(written.clone()));
267 assert_eq!(
268 reuse_paths(&written),
269 [
270 PathBuf::from("dist"),
271 PathBuf::from(".supercov/manifest.json"),
272 PathBuf::from(".supercov/build-cache.json"),
273 ]
274 );
275 fs::remove_dir_all(workspace.join("dist")).unwrap();
276 assert_eq!(read_build_cache(&workspace, "key"), None);
277 fs::remove_dir_all(root).unwrap();
278 }
279
280 #[test]
281 fn records_package_level_outputs_and_skips_dependency_trees() {
282 let root = temporary();
283 let workspace = root.join(".supercov/cache/workspace/project");
284 fs::create_dir_all(workspace.join(".supercov")).unwrap();
285 fs::write(workspace.join(".supercov/manifest.json"), "{}").unwrap();
286 fs::create_dir_all(workspace.join("packages/app/dist/assets")).unwrap();
287 fs::write(workspace.join("packages/app/dist/app.js"), "built").unwrap();
288 fs::create_dir_all(workspace.join("packages/site/.next")).unwrap();
289 fs::create_dir_all(workspace.join("node_modules/library/dist")).unwrap();
290 fs::create_dir_all(workspace.join("packages/app/node_modules/local/dist")).unwrap();
291 let written = write_build_cache(&root, &workspace, "key", "time")
292 .unwrap()
293 .unwrap();
294 assert_eq!(
295 written.artifact_paths,
296 [
297 "packages/app/dist",
298 "packages/site/.next",
299 ".supercov/manifest.json"
300 ]
301 );
302 assert_eq!(read_build_cache(&workspace, "key"), Some(written));
303 fs::remove_dir_all(root).unwrap();
304 }
305}