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