use std::fs;
use std::path::{Path, PathBuf};
fn cache_file_path(base: &Path) -> PathBuf {
let mut file = std::ffi::OsString::from("pipeline_cache_desktop_");
file.push(frust_paths::app_stem());
file.push(".bin");
base.join("frust").join(file)
}
pub fn cache_path() -> Option<PathBuf> {
Some(cache_file_path(&frust_paths::cache_dir()?))
}
pub fn load_cache() -> Option<Vec<u8>> {
let path = load_path(&cache_path()?, frust_paths::legacy_cache_dir().as_deref());
load_cache_from(&path)
}
fn load_path(new_path: &Path, legacy_base: Option<&Path>) -> PathBuf {
if let Some(legacy_base) = legacy_base
&& !new_path.try_exists().unwrap_or(true)
{
let legacy_path = cache_file_path(legacy_base);
if legacy_path.try_exists().unwrap_or(false) {
log::debug!(
"frust-shell-desktop: cache not found at {}, reading legacy {}",
new_path.display(),
legacy_path.display()
);
return legacy_path;
}
}
new_path.to_path_buf()
}
fn load_cache_from(path: &std::path::Path) -> Option<Vec<u8>> {
match fs::read(path) {
Ok(data) => {
log::debug!("frust-shell-desktop: loaded cache from {}", path.display());
Some(data)
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
log::debug!("frust-shell-desktop: cache not found at {}", path.display());
None
}
Err(e) => {
log::debug!(
"frust-shell-desktop: failed to read cache at {}: {}",
path.display(),
e
);
None
}
}
}
pub fn save_cache(data: &[u8]) {
let Some(path) = cache_path() else {
log::debug!("frust-shell-desktop: cache disabled (no cache dir)");
return;
};
if !cache_differs(load_cache_from(&path).as_deref(), data) {
log::debug!(
"frust-shell-desktop: cache unchanged, skipping write to {}",
path.display()
);
return;
}
save_cache_to(&path, data);
}
fn cache_differs(loaded: Option<&[u8]>, new: &[u8]) -> bool {
loaded != Some(new)
}
fn save_cache_to(path: &std::path::Path, data: &[u8]) {
match frust_paths::atomic_write(path, data) {
Ok(()) => {
log::debug!("frust-shell-desktop: saved cache to {}", path.display());
}
Err(e) => {
log::debug!(
"frust-shell-desktop: failed to save cache to {}: {}",
path.display(),
e
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_cache_path_is_per_binary() {
let path = cache_path().expect("cache dir resolvable on test hosts");
assert!(path.parent().unwrap().ends_with("frust"));
let file = path.file_name().unwrap().to_string_lossy().into_owned();
assert!(file.starts_with("pipeline_cache_desktop_"), "{file}");
assert!(file.ends_with(".bin"), "{file}");
let stem = std::env::current_exe()
.ok()
.and_then(|p| p.file_stem().map(|s| s.to_string_lossy().into_owned()))
.unwrap();
assert!(file.contains(&stem), "{file} should embed {stem}");
}
fn scratch_path(tag: &str) -> PathBuf {
let root =
std::env::temp_dir().join(format!("frust-cache-test-{}-{}", std::process::id(), tag));
fs::create_dir(&root).expect("scratch root must not already exist (planted path?)");
root.join("frust").join("pipeline_cache_desktop.bin")
}
#[test]
fn test_cache_differs() {
assert!(cache_differs(None, b"x"), "nothing on disk always differs");
assert!(
cache_differs(None, b""),
"empty new blob vs no file differs"
);
assert!(
!cache_differs(Some(b"x"), b"x"),
"identical bytes: no write"
);
assert!(cache_differs(Some(b"x"), b"y"), "changed bytes differ");
assert!(cache_differs(Some(b"x"), b"xy"), "length change differs");
assert!(!cache_differs(Some(b""), b""), "both empty: no write");
}
#[test]
fn test_load_cache_not_found() {
let path = scratch_path("not-found");
assert!(load_cache_from(&path).is_none());
}
#[test]
fn test_save_cache_round_trip_and_overwrite() {
let path = scratch_path("round-trip");
save_cache_to(&path, b"test-cache-data");
assert_eq!(
load_cache_from(&path).as_deref(),
Some(&b"test-cache-data"[..])
);
assert!(
!path
.with_extension(format!("tmp.{}", std::process::id()))
.exists(),
"temp file left behind"
);
save_cache_to(&path, b"second-write");
assert_eq!(
load_cache_from(&path).as_deref(),
Some(&b"second-write"[..])
);
let _ = fs::remove_dir_all(path.parent().unwrap().parent().unwrap());
}
fn scratch_base(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"frust-cache-test-{}-base-{}",
std::process::id(),
tag
));
fs::create_dir(&dir).expect("scratch base must not already exist (planted path?)");
dir
}
fn seed_cache_file(base: &Path, data: &[u8]) -> PathBuf {
let path = cache_file_path(base);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(&path, data).unwrap();
path
}
#[test]
fn test_load_path_reads_through_to_legacy_file_with_no_app_stem_dir() {
let new_base = scratch_base("rt-new");
let legacy_base = scratch_base("rt-legacy");
let legacy_path = seed_cache_file(&legacy_base, b"legacy-blob");
let stem_dir = legacy_base.join(frust_paths::app_stem());
assert!(!stem_dir.exists(), "test fixture must have no app-stem dir");
let resolved = load_path(&cache_file_path(&new_base), Some(&legacy_base));
assert_eq!(resolved, legacy_path);
assert_eq!(
load_cache_from(&resolved).as_deref(),
Some(&b"legacy-blob"[..])
);
let _ = fs::remove_dir_all(&new_base);
let _ = fs::remove_dir_all(&legacy_base);
}
#[test]
fn test_load_path_prefers_the_new_file_when_both_exist() {
let new_base = scratch_base("both-new");
let legacy_base = scratch_base("both-legacy");
let new_path = seed_cache_file(&new_base, b"new-blob");
let legacy_path = seed_cache_file(&legacy_base, b"legacy-blob");
let resolved = load_path(&new_path, Some(&legacy_base));
assert_eq!(resolved, new_path);
assert_eq!(
load_cache_from(&resolved).as_deref(),
Some(&b"new-blob"[..])
);
assert!(legacy_path.exists(), "legacy file must be left untouched");
let _ = fs::remove_dir_all(&new_base);
let _ = fs::remove_dir_all(&legacy_base);
}
#[test]
#[cfg(unix)]
fn test_load_path_does_not_divert_when_new_path_is_unstatable() {
use std::os::unix::fs::PermissionsExt;
let new_base = scratch_base("unstatable-new");
let locked_dir = new_base.join("locked");
fs::create_dir(&locked_dir).unwrap();
fs::set_permissions(&locked_dir, fs::Permissions::from_mode(0o000)).unwrap();
let legacy_base = scratch_base("unstatable-legacy");
let legacy_path = seed_cache_file(&legacy_base, b"legacy-blob");
let new_path = cache_file_path(&locked_dir);
if new_path.try_exists().is_ok() {
fs::set_permissions(&locked_dir, fs::Permissions::from_mode(0o700)).unwrap();
let _ = fs::remove_dir_all(&new_base);
let _ = fs::remove_dir_all(&legacy_base);
return;
}
let resolved = load_path(&new_path, Some(&legacy_base));
assert_eq!(
resolved, new_path,
"an unstatable new path must not divert to the legacy cache"
);
assert_ne!(resolved, legacy_path);
fs::set_permissions(&locked_dir, fs::Permissions::from_mode(0o700)).unwrap();
let _ = fs::remove_dir_all(&new_base);
let _ = fs::remove_dir_all(&legacy_base);
}
#[test]
fn test_load_path_falls_back_to_the_new_path() {
let new_base = scratch_base("none-new");
let legacy_base = scratch_base("none-legacy");
let new_path = cache_file_path(&new_base);
assert_eq!(load_path(&new_path, Some(&legacy_base)), new_path);
assert_eq!(load_path(&new_path, None), new_path);
}
#[test]
fn test_cache_path_is_the_shared_filename_shape() {
let base = frust_paths::cache_dir().expect("cache dir resolvable on test hosts");
assert_eq!(cache_path(), Some(cache_file_path(&base)));
}
}