Skip to main content

run/
cache.rs

1use std::collections::HashMap;
2use std::fs;
3use std::path::{Path, PathBuf};
4use std::time::{SystemTime, UNIX_EPOCH};
5
6use anyhow::{Context, Result};
7use serde::{Deserialize, Serialize};
8
9const MAX_CACHE_BYTES: u64 = 500 * 1024 * 1024;
10const MAX_CACHE_ENTRIES: usize = 200;
11
12/// Summary information for the persistent build cache.
13#[derive(Debug, Clone, Default, PartialEq, Eq)]
14pub struct CacheStats {
15    /// Number of indexed cache entries.
16    pub entries: usize,
17    /// Total bytes tracked by the cache index.
18    pub total_bytes: u64,
19    /// Entry count by language namespace.
20    pub by_language: Vec<(String, usize)>,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
24struct CacheIndex {
25    entries: HashMap<String, CacheIndexEntry>,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
29struct CacheIndexEntry {
30    lang: String,
31    path: PathBuf,
32    size: u64,
33    atime: u64,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
37struct CacheMeta {
38    lang: String,
39    source_hash: u64,
40    toolchain: String,
41    created_at: u64,
42    size: u64,
43}
44
45/// Return the root directory for run-kit cache data.
46pub fn root_dir() -> PathBuf {
47    dirs::cache_dir()
48        .unwrap_or_else(std::env::temp_dir)
49        .join("run-kit")
50}
51
52/// Return cache statistics from the current index.
53pub fn stats() -> Result<CacheStats> {
54    let index = read_index()?;
55    let mut by_lang: HashMap<String, usize> = HashMap::new();
56    let mut total = 0;
57    for entry in index.entries.values() {
58        *by_lang.entry(entry.lang.clone()).or_insert(0) += 1;
59        total += entry.size;
60    }
61    let mut by_language = by_lang.into_iter().collect::<Vec<_>>();
62    by_language.sort_by(|a, b| a.0.cmp(&b.0));
63    Ok(CacheStats {
64        entries: index.entries.len(),
65        total_bytes: total,
66        by_language,
67    })
68}
69
70/// Clear every persistent build cache entry.
71pub fn clear() -> Result<()> {
72    let root = root_dir();
73    if root.exists() {
74        fs::remove_dir_all(&root)
75            .with_context(|| format!("failed to remove {}", root.display()))?;
76    }
77    Ok(())
78}
79
80/// Clear entries belonging to a single language namespace.
81pub fn clear_lang(lang: &str) -> Result<()> {
82    let mut index = read_index()?;
83    let ids = index
84        .entries
85        .iter()
86        .filter_map(|(id, entry)| (entry.lang == lang).then_some(id.clone()))
87        .collect::<Vec<_>>();
88    for id in ids {
89        if let Some(entry) = index.entries.remove(&id) {
90            let entry_dir = entry.path.parent().unwrap_or(entry.path.as_path());
91            let _ = fs::remove_dir_all(entry_dir);
92        }
93    }
94    write_index(&index)
95}
96
97/// Look up a cached binary path for a language namespace and source hash.
98pub fn lookup(namespace: &str, source_hash: u64) -> Option<PathBuf> {
99    let toolchain = toolchain_fingerprint(namespace);
100    let id = entry_id(namespace, source_hash, &toolchain);
101    let path = entry_path(namespace, &id);
102    if !path.exists() {
103        return None;
104    }
105
106    if let Ok(mut index) = read_index() {
107        let size = path.metadata().map(|m| m.len()).unwrap_or(0);
108        index.entries.insert(
109            id,
110            CacheIndexEntry {
111                lang: namespace.to_string(),
112                path: path.clone(),
113                size,
114                atime: now_secs(),
115            },
116        );
117        let _ = write_index(&index);
118    }
119    Some(path)
120}
121
122/// Store a compiled binary for a language namespace and source hash.
123pub fn store(namespace: &str, source_hash: u64, binary: &Path) -> Option<PathBuf> {
124    let toolchain = toolchain_fingerprint(namespace);
125    let id = entry_id(namespace, source_hash, &toolchain);
126    let path = entry_path(namespace, &id);
127    let entry_dir = path.parent()?;
128    fs::create_dir_all(entry_dir).ok()?;
129    fs::copy(binary, &path).ok()?;
130
131    #[cfg(unix)]
132    {
133        use std::os::unix::fs::PermissionsExt;
134        let _ = fs::set_permissions(&path, fs::Permissions::from_mode(0o755));
135    }
136
137    let size = path.metadata().map(|m| m.len()).unwrap_or(0);
138    let meta = CacheMeta {
139        lang: namespace.to_string(),
140        source_hash,
141        toolchain,
142        created_at: now_secs(),
143        size,
144    };
145    let meta_path = entry_dir.join("meta.json");
146    if let Ok(text) = serde_json::to_string_pretty(&meta) {
147        let _ = fs::write(meta_path, text);
148    }
149
150    if let Ok(mut index) = read_index() {
151        index.entries.insert(
152            id,
153            CacheIndexEntry {
154                lang: namespace.to_string(),
155                path: path.clone(),
156                size,
157                atime: now_secs(),
158            },
159        );
160        let _ = evict_if_needed(&mut index);
161        let _ = write_index(&index);
162    }
163
164    Some(path)
165}
166
167/// Return an incremental workspace path managed by the build cache.
168pub fn workspace(namespace: &str, source_hash: u64) -> Result<PathBuf> {
169    let toolchain = toolchain_fingerprint(namespace);
170    let id = entry_id(namespace, source_hash, &toolchain);
171    let dir = root_dir()
172        .join("builds")
173        .join(namespace)
174        .join(id)
175        .join("workspace");
176    fs::create_dir_all(&dir).with_context(|| format!("failed to create {}", dir.display()))?;
177    Ok(dir)
178}
179
180fn entry_id(namespace: &str, source_hash: u64, toolchain: &str) -> String {
181    let mut hasher = blake3::Hasher::new();
182    hasher.update(b"run-kit/v1");
183    hasher.update(namespace.as_bytes());
184    hasher.update(&source_hash.to_le_bytes());
185    hasher.update(toolchain.as_bytes());
186    let hash = hasher.finalize();
187    hash.to_hex()[..16].to_string()
188}
189
190fn entry_path(namespace: &str, id: &str) -> PathBuf {
191    let suffix = std::env::consts::EXE_SUFFIX;
192    root_dir()
193        .join("builds")
194        .join(namespace)
195        .join(id)
196        .join(format!("bin{suffix}"))
197}
198
199fn index_path() -> PathBuf {
200    root_dir().join("index.json")
201}
202
203fn read_index() -> Result<CacheIndex> {
204    let path = index_path();
205    if !path.exists() {
206        return Ok(CacheIndex {
207            entries: HashMap::new(),
208        });
209    }
210    let text =
211        fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
212    serde_json::from_str(&text).with_context(|| format!("failed to parse {}", path.display()))
213}
214
215fn write_index(index: &CacheIndex) -> Result<()> {
216    let path = index_path();
217    if let Some(parent) = path.parent() {
218        fs::create_dir_all(parent)
219            .with_context(|| format!("failed to create {}", parent.display()))?;
220    }
221    let text = serde_json::to_string_pretty(index)?;
222    fs::write(&path, text).with_context(|| format!("failed to write {}", path.display()))
223}
224
225fn evict_if_needed(index: &mut CacheIndex) -> Result<()> {
226    loop {
227        let total = index.entries.values().map(|entry| entry.size).sum::<u64>();
228        if total <= MAX_CACHE_BYTES && index.entries.len() <= MAX_CACHE_ENTRIES {
229            break;
230        }
231        let Some((oldest_id, oldest)) = index
232            .entries
233            .iter()
234            .min_by_key(|(_, entry)| entry.atime)
235            .map(|(id, entry)| (id.clone(), entry.clone()))
236        else {
237            break;
238        };
239        index.entries.remove(&oldest_id);
240        let entry_dir = oldest.path.parent().unwrap_or(oldest.path.as_path());
241        let _ = fs::remove_dir_all(entry_dir);
242    }
243    Ok(())
244}
245
246fn now_secs() -> u64 {
247    SystemTime::now()
248        .duration_since(UNIX_EPOCH)
249        .map(|duration| duration.as_secs())
250        .unwrap_or(0)
251}
252
253fn toolchain_fingerprint(namespace: &str) -> String {
254    let lang = namespace.split('-').next().unwrap_or(namespace);
255    let candidates: &[(&str, &[&str])] = match lang {
256        "rust" => &[("rustc", &["--version"])],
257        "go" => &[("go", &["version"])],
258        "c" => &[
259            ("cc", &["--version"]),
260            ("clang", &["--version"]),
261            ("gcc", &["--version"]),
262        ],
263        "cpp" => &[
264            ("c++", &["--version"]),
265            ("clang++", &["--version"]),
266            ("g++", &["--version"]),
267        ],
268        "java" => &[("java", &["-version"])],
269        "kotlin" => &[("kotlinc", &["-version"])],
270        "zig" => &[("zig", &["version"])],
271        _ => &[],
272    };
273
274    for (program, args) in candidates {
275        let resolved = which::which(program).unwrap_or_else(|_| PathBuf::from(program));
276        if let Ok(output) = std::process::Command::new(resolved).args(*args).output() {
277            let mut text = String::from_utf8_lossy(&output.stdout).into_owned();
278            text.push_str(&String::from_utf8_lossy(&output.stderr));
279            let version = text.lines().next().unwrap_or("").trim();
280            if !version.is_empty() {
281                return version.to_string();
282            }
283        }
284    }
285
286    "unknown-toolchain".to_string()
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292
293    #[test]
294    fn entry_id_is_stable() {
295        assert_eq!(
296            entry_id("rust", 42, "rustc 1.0"),
297            entry_id("rust", 42, "rustc 1.0")
298        );
299    }
300
301    #[test]
302    fn entry_id_changes_with_toolchain() {
303        assert_ne!(
304            entry_id("rust", 42, "rustc 1.0"),
305            entry_id("rust", 42, "rustc 2.0")
306        );
307    }
308
309    #[test]
310    fn stats_empty_index_is_valid() {
311        let index = CacheIndex {
312            entries: HashMap::new(),
313        };
314        assert!(index.entries.is_empty());
315    }
316}