Skip to main content

supercov_engine/
build_cache.rs

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