use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::io::{self, Write};
use std::path::{Path, PathBuf};
pub fn meta_root() -> Option<PathBuf> {
let base = dirs::state_dir().or_else(dirs::data_local_dir)?;
Some(base.join("perfectstar2k"))
}
fn subdir(name: &str) -> Option<PathBuf> {
Some(meta_root()?.join(name))
}
pub fn sessions() -> Option<PathBuf> {
subdir("sessions")
}
#[allow(dead_code)]
pub fn projects() -> Option<PathBuf> {
subdir("projects")
}
#[allow(dead_code)]
pub fn snapshots() -> Option<PathBuf> {
subdir("snapshots")
}
#[allow(dead_code)]
pub fn meta() -> Option<PathBuf> {
subdir("meta")
}
#[allow(dead_code)]
pub fn recovery() -> Option<PathBuf> {
subdir("recovery")
}
#[allow(dead_code)]
pub fn stats() -> Option<PathBuf> {
subdir("stats")
}
pub fn path_key(file: &Path) -> String {
let canonical = file.canonicalize().unwrap_or_else(|_| file.to_path_buf());
let mut h = DefaultHasher::new();
canonical.hash(&mut h);
format!(
"{}-{:016x}",
canonical
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default(),
h.finish()
)
}
#[allow(dead_code)]
pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
write_atomic_with(path, |f| f.write_all(bytes))
}
pub fn write_atomic_with<F>(path: &Path, fill: F) -> io::Result<()>
where
F: FnOnce(&mut std::fs::File) -> io::Result<()>,
{
let mut tmp = path.to_path_buf().into_os_string();
tmp.push(".tmp~");
let tmp = PathBuf::from(tmp);
{
let mut file = std::fs::File::create(&tmp)?;
if let Err(e) = fill(&mut file).and_then(|()| file.sync_all()) {
let _ = std::fs::remove_file(&tmp);
return Err(e);
}
}
if let Err(e) = std::fs::rename(&tmp, path) {
let _ = std::fs::remove_file(&tmp);
return Err(e);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn root_is_named_perfectstar2k() {
if let Some(root) = meta_root() {
assert_eq!(root.file_name().unwrap(), "perfectstar2k");
}
}
#[test]
fn subdirs_are_children_of_root() {
let Some(root) = meta_root() else {
return; };
assert_eq!(sessions(), Some(root.join("sessions")));
assert_eq!(projects(), Some(root.join("projects")));
assert_eq!(snapshots(), Some(root.join("snapshots")));
assert_eq!(meta(), Some(root.join("meta")));
assert_eq!(recovery(), Some(root.join("recovery")));
assert_eq!(stats(), Some(root.join("stats")));
}
#[test]
fn path_key_is_deterministic() {
let p = Path::new("/tmp/pstar-nonexistent-xyz/chapter1.md");
assert_eq!(path_key(p), path_key(p));
}
#[test]
fn path_key_differs_by_path() {
let a = Path::new("/tmp/pstar-nonexistent-xyz/chapter1.md");
let b = Path::new("/tmp/pstar-nonexistent-xyz/chapter2.md");
assert_ne!(path_key(a), path_key(b));
}
#[test]
fn path_key_shape() {
let key = path_key(Path::new("/tmp/pstar-nonexistent-xyz/chapter1.md"));
assert!(key.starts_with("chapter1-"), "stem prefix, got {key}");
let hash = &key[key.len() - 16..];
assert_eq!(hash.len(), 16);
assert!(
hash.chars().all(|c| c.is_ascii_hexdigit()),
"hex hash, got {hash}"
);
assert_eq!(key.as_bytes()[key.len() - 17], b'-');
}
#[test]
fn path_key_handles_missing_stem() {
let key = path_key(Path::new("/"));
assert_eq!(key.len(), 17);
assert!(key.starts_with('-'));
}
fn scratch(tag: &str) -> PathBuf {
std::env::temp_dir().join(format!("pstar-paths-test-{tag}"))
}
#[test]
fn write_atomic_creates_file() {
let p = scratch("create");
let _ = std::fs::remove_file(&p);
write_atomic(&p, b"hello").unwrap();
assert_eq!(std::fs::read(&p).unwrap(), b"hello");
let _ = std::fs::remove_file(&p);
}
#[test]
fn write_atomic_replaces_existing() {
let p = scratch("replace");
std::fs::write(&p, b"old contents, longer").unwrap();
write_atomic(&p, b"new").unwrap();
assert_eq!(std::fs::read(&p).unwrap(), b"new");
let _ = std::fs::remove_file(&p);
}
#[test]
fn write_atomic_leaves_no_temp_file() {
let p = scratch("no-temp");
let _ = std::fs::remove_file(&p);
write_atomic(&p, b"data").unwrap();
let mut tmp = p.clone().into_os_string();
tmp.push(".tmp~");
assert!(!Path::new(&tmp).exists(), "temp file was left behind");
let _ = std::fs::remove_file(&p);
}
#[test]
fn write_atomic_preserves_prior_file_on_producer_failure() {
let p = scratch("producer-fail");
std::fs::write(&p, b"previous good file").unwrap();
let err = write_atomic_with(&p, |f| {
f.write_all(b"partial")?;
Err(io::Error::other("simulated failure"))
});
assert!(err.is_err());
assert_eq!(std::fs::read(&p).unwrap(), b"previous good file");
let mut tmp = p.clone().into_os_string();
tmp.push(".tmp~");
assert!(!Path::new(&tmp).exists(), "temp file was left behind");
let _ = std::fs::remove_file(&p);
}
#[test]
fn write_atomic_with_streams_bytes() {
let p = scratch("stream");
let _ = std::fs::remove_file(&p);
write_atomic_with(&p, |f| {
f.write_all(b"chunk1")?;
f.write_all(b"chunk2")
})
.unwrap();
assert_eq!(std::fs::read(&p).unwrap(), b"chunk1chunk2");
let _ = std::fs::remove_file(&p);
}
}