use std::path::Path;
use crate::common::paths::{APP_DIR_NAME, cache_dir};
pub fn migrate_legacy_cache_paths() {
let Some(new_root) = cache_dir() else {
return;
};
let Some(home) = dirs::home_dir() else {
return;
};
let old_root = home.join(".cache").join(APP_DIR_NAME);
if old_root == new_root {
return;
}
migrate_legacy_cache_root(&old_root, &new_root);
}
fn migrate_legacy_cache_root(old_root: &Path, new_root: &Path) {
let meta = match old_root.symlink_metadata() {
Ok(meta) => meta,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return,
Err(e) => {
eprintln!(
"all-smi: could not inspect legacy cache {}: {e}",
old_root.display()
);
return;
}
};
if meta.file_type().is_symlink() {
eprintln!(
"all-smi: skipping cache migration of {} (is a symlink)",
old_root.display()
);
return;
}
if !meta.is_dir() {
return;
}
let _ = std::fs::create_dir_all(new_root);
for name in MIGRATION_TARGETS {
try_move(&old_root.join(name), &new_root.join(name));
}
}
const MIGRATION_TARGETS: &[&str] = &["records", "energy-wal.bin"];
pub(crate) fn try_move(src: &Path, dst: &Path) {
match dst.symlink_metadata() {
Ok(_) => return,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => {
eprintln!(
"all-smi: could not inspect cache migration destination {}: {e}",
dst.display()
);
return;
}
}
let Ok(meta) = src.symlink_metadata() else {
return;
};
if meta.file_type().is_symlink() {
eprintln!(
"all-smi: skipping cache migration of {} (is a symlink)",
src.display()
);
return;
}
if let Some(parent) = dst.parent() {
let _ = std::fs::create_dir_all(parent);
}
match std::fs::rename(src, dst) {
Ok(()) => eprintln!(
"all-smi: migrated legacy cache {} -> {}",
src.display(),
dst.display()
),
Err(e) => {
eprintln!(
"all-smi: could not migrate legacy cache {}: {e}",
src.display()
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn tempdir() -> tempfile::TempDir {
tempfile::tempdir().expect("create tempdir")
}
#[test]
fn try_move_skips_when_source_missing() {
let root = tempdir();
let src = root.path().join("nope");
let dst = root.path().join("dst");
try_move(&src, &dst);
assert!(!dst.exists(), "missing source must not create destination");
}
#[test]
fn try_move_renames_file_when_dst_missing() {
let root = tempdir();
let src = root.path().join("energy-wal.bin");
let dst_dir = root.path().join("new");
let dst = dst_dir.join("energy-wal.bin");
std::fs::write(&src, b"payload").unwrap();
try_move(&src, &dst);
assert!(dst.exists(), "destination must be created");
assert!(!src.exists(), "source must be moved");
assert_eq!(std::fs::read(&dst).unwrap(), b"payload");
}
#[test]
fn try_move_renames_directory_when_dst_missing() {
let root = tempdir();
let src = root.path().join("records");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(src.join("a.ndjson.zst"), b"frame").unwrap();
let dst_dir = root.path().join("new");
let dst = dst_dir.join("records");
try_move(&src, &dst);
assert!(dst.exists(), "destination dir must be created");
assert!(!src.exists(), "source dir must be moved");
assert!(dst.join("a.ndjson.zst").exists(), "child must follow");
}
#[test]
fn try_move_no_op_when_dst_exists() {
let root = tempdir();
let src = root.path().join("records");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(src.join("src.ndjson.zst"), b"src").unwrap();
let dst = root.path().join("records-new");
std::fs::create_dir_all(&dst).unwrap();
std::fs::write(dst.join("dst.ndjson.zst"), b"dst").unwrap();
try_move(&src, &dst);
assert!(
src.join("src.ndjson.zst").exists(),
"source must be preserved"
);
assert!(
dst.join("dst.ndjson.zst").exists(),
"destination must be preserved"
);
assert!(!dst.join("src.ndjson.zst").exists());
}
#[cfg(unix)]
#[test]
fn try_move_refuses_symlink_source() {
let root = tempdir();
let real = root.path().join("real.bin");
std::fs::write(&real, b"important").unwrap();
let link = root.path().join("energy-wal.bin");
std::os::unix::fs::symlink(&real, &link).unwrap();
let dst = root.path().join("new").join("energy-wal.bin");
try_move(&link, &dst);
assert!(!dst.exists(), "symlink source must not be migrated");
let resolved = std::fs::read_link(&link).unwrap();
assert_eq!(resolved, real);
assert!(real.exists(), "follow-through target untouched");
}
#[cfg(unix)]
#[test]
fn migration_refuses_symlink_legacy_root() {
let root = tempdir();
let target = root.path().join("attacker-controlled");
std::fs::create_dir_all(target.join("records")).unwrap();
std::fs::write(target.join("records").join("a.ndjson.zst"), b"frame").unwrap();
std::fs::write(target.join("energy-wal.bin"), b"wal").unwrap();
let legacy_root = root.path().join("all-smi-link");
std::os::unix::fs::symlink(&target, &legacy_root).unwrap();
let new_root = root.path().join("new-cache");
migrate_legacy_cache_root(&legacy_root, &new_root);
assert!(
!new_root.exists(),
"migration must not create a destination from a symlinked legacy root"
);
assert!(
target.join("records").join("a.ndjson.zst").exists(),
"follow-through records target must remain untouched"
);
assert!(
target.join("energy-wal.bin").exists(),
"follow-through WAL target must remain untouched"
);
assert_eq!(std::fs::read_link(&legacy_root).unwrap(), target);
}
#[cfg(unix)]
#[test]
fn try_move_preserves_dangling_destination_symlink() {
let root = tempdir();
let src = root.path().join("energy-wal.bin");
std::fs::write(&src, b"payload").unwrap();
let dst = root.path().join("new").join("energy-wal.bin");
std::fs::create_dir_all(dst.parent().unwrap()).unwrap();
let missing_target = root.path().join("missing-target");
std::os::unix::fs::symlink(&missing_target, &dst).unwrap();
try_move(&src, &dst);
assert!(src.exists(), "source must remain when destination exists");
assert_eq!(
std::fs::read_link(&dst).unwrap(),
missing_target,
"dangling destination symlink must not be overwritten"
);
}
}