use std::collections::{HashMap, VecDeque};
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use ignore::WalkBuilder;
use serde_json::json;
use sha2::{Digest, Sha256};
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub enum TestCacheKind {
CargoTest,
CargoTestViaRunCommand,
NpmTest { package_subdir: String },
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub struct TestCacheKey {
pub workspace_root: PathBuf,
pub kind: TestCacheKind,
pub args_fingerprint: String,
pub inputs_fingerprint: String,
}
struct CachedEntry {
output: String,
}
struct LruCache {
map: HashMap<TestCacheKey, CachedEntry>,
order: VecDeque<TestCacheKey>,
cap: usize,
}
impl LruCache {
fn new(cap: usize) -> Self {
Self {
map: HashMap::new(),
order: VecDeque::new(),
cap: cap.max(1),
}
}
fn get(&mut self, k: &TestCacheKey) -> Option<String> {
if !self.map.contains_key(k) {
return None;
}
let out = self.map.get(k).map(|e| e.output.clone())?;
if let Some(pos) = self.order.iter().position(|x| x == k) {
self.order.remove(pos);
}
self.order.push_back(k.clone());
Some(out)
}
fn insert(&mut self, k: TestCacheKey, output: String) {
if self.map.contains_key(&k) {
self.map.insert(
k.clone(),
CachedEntry {
output: output.clone(),
},
);
if let Some(pos) = self.order.iter().position(|x| x == &k) {
self.order.remove(pos);
}
self.order.push_back(k);
return;
}
while self.map.len() >= self.cap && !self.order.is_empty() {
if let Some(old) = self.order.pop_front() {
self.map.remove(&old);
}
}
self.map.insert(
k.clone(),
CachedEntry {
output: output.clone(),
},
);
self.order.push_back(k);
}
}
static CACHE: Mutex<Option<LruCache>> = Mutex::new(None);
fn cache_singleton(max_entries: usize) -> std::sync::MutexGuard<'static, Option<LruCache>> {
let mut g = CACHE.lock().unwrap_or_else(|e| e.into_inner());
if g.is_none() {
*g = Some(LruCache::new(max_entries.max(1)));
} else if let Some(c) = g.as_mut()
&& c.cap != max_entries.max(1)
{
*g = Some(LruCache::new(max_entries.max(1)));
}
g
}
pub fn fingerprint_rust_workspace_sources(root: &Path) -> Option<String> {
let root = root.canonicalize().ok()?;
let mut hasher = Sha256::new();
let mut rows: Vec<(String, u128, u64)> = Vec::new();
let walker = WalkBuilder::new(&root)
.hidden(false)
.git_ignore(true)
.git_global(false)
.git_exclude(true)
.build();
for entry in walker.flatten() {
let path = entry.path();
if !path.is_file() {
continue;
}
let name = path.file_name().and_then(|s| s.to_str()).unwrap_or("");
let ext = path.extension().and_then(|s| s.to_str()).unwrap_or("");
let track = matches!(ext, "rs" | "toml") || name == "Cargo.lock";
if !track {
continue;
}
let rel = path.strip_prefix(&root).ok()?;
let rel_s = rel.to_string_lossy().replace('\\', "/");
let meta = std::fs::metadata(path).ok()?;
let mtime = meta
.modified()
.ok()?
.duration_since(std::time::UNIX_EPOCH)
.ok()?
.as_nanos();
let len = meta.len();
rows.push((rel_s, mtime, len));
}
rows.sort_by(|a, b| a.0.cmp(&b.0));
for (rel, mt, len) in rows {
hasher.update(rel.as_bytes());
hasher.update(0xffu8.to_le_bytes()); hasher.update(mt.to_le_bytes());
hasher.update(len.to_le_bytes());
}
let hash = hasher.finalize();
Some(hash.iter().map(|b| format!("{b:02x}")).collect::<String>())
}
pub fn fingerprint_npm_package_dir(workspace: &Path, subdir: &str) -> Option<String> {
let dir = workspace.join(subdir);
let pj = dir.join("package.json");
if !pj.is_file() {
return None;
}
let mut hasher = Sha256::new();
for name in ["package.json", "package-lock.json", "npm-shrinkwrap.json"] {
let p = dir.join(name);
if !p.is_file() {
continue;
}
hasher.update(name.as_bytes());
hasher.update(0xffu8.to_le_bytes());
let meta = std::fs::metadata(&p).ok()?;
let mt = meta
.modified()
.ok()?
.duration_since(std::time::UNIX_EPOCH)
.ok()?
.as_nanos();
hasher.update(mt.to_le_bytes());
hasher.update(meta.len().to_le_bytes());
}
let hash = hasher.finalize();
Some(hash.iter().map(|b| format!("{b:02x}")).collect::<String>())
}
const CACHE_BANNER: &str =
"[CrabMate 测试输出缓存命中] 输入指纹与上次相同,未重新执行;以下为缓存副本。\n指纹:";
pub fn wrap_cache_hit(fingerprint: &str, body: &str) -> String {
format!("{CACHE_BANNER}{fingerprint}\n---\n{body}")
}
pub fn cargo_test_args_fingerprint(v: &serde_json::Value) -> String {
let release = v.get("release").and_then(|x| x.as_bool()).unwrap_or(false);
let all_targets = v
.get("all_targets")
.and_then(|x| x.as_bool())
.unwrap_or(false);
let package = v.get("package").and_then(|x| x.as_str()).unwrap_or("");
let bin = v.get("bin").and_then(|x| x.as_str()).unwrap_or("");
let features = v.get("features").and_then(|x| x.as_str()).unwrap_or("");
let test_filter = v.get("test_filter").and_then(|x| x.as_str()).unwrap_or("");
let test_name = v.get("test_name").and_then(|x| x.as_str()).unwrap_or("");
let nocapture = v
.get("nocapture")
.and_then(|x| x.as_bool())
.unwrap_or(false);
serde_json::to_string(&json!({
"release": release,
"all_targets": all_targets,
"package": package,
"bin": bin,
"features": features,
"test_filter": test_filter,
"test_name": test_name,
"nocapture": nocapture,
}))
.unwrap_or_else(|_| "{}".to_string())
}
pub fn cargo_test_run_command_args_fingerprint(cmd_args: &[String]) -> String {
serde_json::to_string(&json!({ "argv": cmd_args })).unwrap_or_else(|_| "[]".to_string())
}
pub fn npm_test_args_fingerprint(subdir: &str, script: &str, extra: &[String]) -> String {
serde_json::to_string(&json!({
"subdir": subdir,
"script": script,
"extra": extra,
}))
.unwrap_or_else(|_| "{}".to_string())
}
pub fn try_get_cached(enabled: bool, max_entries: usize, key: &TestCacheKey) -> Option<String> {
if !enabled {
return None;
}
let mut guard = cache_singleton(max_entries);
let cache = guard.as_mut()?;
cache.get(key)
}
pub fn store_cached(enabled: bool, max_entries: usize, key: TestCacheKey, output: String) {
if !enabled || output.is_empty() {
return;
}
let mut guard = cache_singleton(max_entries);
if let Some(cache) = guard.as_mut() {
cache.insert(key, output);
}
}
pub fn reset_test_result_cache_for_tests() {
let mut g = CACHE.lock().unwrap_or_else(|e| e.into_inner());
*g = None;
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn reset_test_result_cache_for_tests_clears_singleton() {
let key = TestCacheKey {
workspace_root: PathBuf::from("/tmp/crabmate_reset_cache_test"),
kind: TestCacheKind::CargoTest,
args_fingerprint: "reset-test-args".to_string(),
inputs_fingerprint: "reset-test-inputs".to_string(),
};
store_cached(true, 8, key.clone(), "cached-output".to_string());
assert!(
try_get_cached(true, 8, &key)
.as_deref()
.is_some_and(|s| s == "cached-output")
);
reset_test_result_cache_for_tests();
assert!(try_get_cached(true, 8, &key).is_none());
}
#[test]
fn lru_evicts_oldest() {
let mut c = LruCache::new(2);
let mk = |i: u8| TestCacheKey {
workspace_root: PathBuf::from("/w"),
kind: TestCacheKind::CargoTest,
args_fingerprint: format!("a{i}"),
inputs_fingerprint: "x".to_string(),
};
c.insert(mk(1), "one".to_string());
c.insert(mk(2), "two".to_string());
c.insert(mk(3), "three".to_string());
assert!(c.get(&mk(1)).is_none());
assert_eq!(c.get(&mk(2)).as_deref(), Some("two"));
assert_eq!(c.get(&mk(3)).as_deref(), Some("three"));
}
#[test]
fn fingerprint_changes_when_file_touched() {
let dir =
std::env::temp_dir().join(format!("crabmate_test_cache_fp_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("Cargo.toml"), "[package]\nname=\"x\"\n").unwrap();
std::fs::create_dir_all(dir.join("src")).unwrap();
let lib = dir.join("src/lib.rs");
std::fs::write(&lib, "pub fn a() {}\n").unwrap();
let a = fingerprint_rust_workspace_sources(&dir).expect("fp");
std::thread::sleep(std::time::Duration::from_millis(20));
let mut f = std::fs::OpenOptions::new().append(true).open(&lib).unwrap();
writeln!(f, "// x").unwrap();
let b = fingerprint_rust_workspace_sources(&dir).expect("fp2");
assert_ne!(a, b);
let _ = std::fs::remove_dir_all(&dir);
}
}