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#[derive(Debug, Clone, Default, PartialEq, Eq)]
14pub struct CacheStats {
15 pub entries: usize,
17 pub total_bytes: u64,
19 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
45pub fn root_dir() -> PathBuf {
47 dirs::cache_dir()
48 .unwrap_or_else(std::env::temp_dir)
49 .join("run-kit")
50}
51
52pub 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
70pub 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
80pub 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
97pub 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
122pub 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
167pub 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}