use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use cargoless_cas::sha256_hex;
pub const DEFAULT_STATE_DIR_REL: &str = ".cargoless";
pub const TF_STATE_DIR_REL: &str = ".triform/cargoless";
const BASE_CACHE: &str = "base.cache";
const TREE_CACHE: &str = "tree.cache";
const SOLO: &str = "solo";
const COMBINED: &str = "combined";
const PIN_MARKER: &str = "PIN";
const COMBINED_SCHEME: &[u8] = b"cargoless/cache/combined-overlay-set/v1";
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct OverlayHash(String);
impl OverlayHash {
pub fn new(hex: impl Into<String>) -> Self {
Self(hex.into())
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BasePin(String);
impl BasePin {
pub fn new(rev: impl Into<String>) -> Self {
Self(rev.into())
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Debug, Clone)]
pub struct CacheLayout {
root: PathBuf,
state_dir_rel: PathBuf,
}
impl CacheLayout {
pub fn at(state_root: impl Into<PathBuf>, state_dir_rel: impl Into<PathBuf>) -> Self {
Self {
root: state_root.into(),
state_dir_rel: state_dir_rel.into(),
}
}
pub fn for_repo(repo_root: impl AsRef<Path>, state_dir_rel: impl Into<PathBuf>) -> Self {
let state_dir_rel = state_dir_rel.into();
let root = repo_root.as_ref().join(&state_dir_rel);
Self {
root,
state_dir_rel,
}
}
#[must_use]
pub fn root(&self) -> &Path {
&self.root
}
#[must_use]
pub fn base_cache_dir(&self) -> PathBuf {
self.root.join(BASE_CACHE)
}
#[must_use]
pub fn tree_cache_dir(&self) -> PathBuf {
self.root.join(TREE_CACHE)
}
#[must_use]
pub fn solo_dir(&self) -> PathBuf {
self.root.join(SOLO)
}
#[must_use]
pub fn combined_dir(&self) -> PathBuf {
self.root.join(COMBINED)
}
#[must_use]
pub fn solo_entry(&self, hw: &OverlayHash) -> PathBuf {
self.solo_dir().join(hw.as_str())
}
#[must_use]
pub fn combined_entry(&self, set: &[OverlayHash]) -> PathBuf {
self.combined_dir().join(combined_key(set))
}
#[must_use]
pub fn worktree_tree_cache(&self, wt_root: impl AsRef<Path>) -> PathBuf {
wt_root.as_ref().join(&self.state_dir_rel).join(TREE_CACHE)
}
pub fn ensure_dirs(&self) -> io::Result<()> {
for d in [
self.base_cache_dir(),
self.tree_cache_dir(),
self.solo_dir(),
self.combined_dir(),
] {
fs::create_dir_all(d)?;
}
Ok(())
}
pub fn base_pin(&self) -> io::Result<Option<BasePin>> {
match fs::read_to_string(self.base_cache_dir().join(PIN_MARKER)) {
Ok(s) => Ok(Some(BasePin::new(s.trim_end_matches('\n').to_string()))),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e),
}
}
pub fn advance_base(&self, pin: &BasePin) -> io::Result<()> {
let dir = self.base_cache_dir();
fs::create_dir_all(&dir)?;
let final_path = dir.join(PIN_MARKER);
let mut contents = String::with_capacity(pin.as_str().len() + 1);
contents.push_str(pin.as_str());
contents.push('\n');
atomic_write(&dir, &final_path, contents.as_bytes())
}
}
#[must_use]
pub fn combined_key(set: &[OverlayHash]) -> String {
let mut sorted: Vec<&str> = set.iter().map(OverlayHash::as_str).collect();
sorted.sort_unstable();
sorted.dedup();
let mut buf = Vec::new();
buf.extend_from_slice(COMBINED_SCHEME);
buf.extend_from_slice(&(sorted.len() as u64).to_be_bytes());
for h in sorted {
let bytes = h.as_bytes();
buf.extend_from_slice(&(bytes.len() as u64).to_be_bytes());
buf.extend_from_slice(bytes);
}
sha256_hex(&buf)
}
fn atomic_write(dir: &Path, final_path: &Path, bytes: &[u8]) -> io::Result<()> {
use std::io::Write;
let tmp = dir.join(format!(".{PIN_MARKER}.tmp.{}", std::process::id()));
{
let mut f = fs::File::create(&tmp)?;
f.write_all(bytes)?;
f.sync_all()?;
}
match fs::rename(&tmp, final_path) {
Ok(()) => Ok(()),
Err(e) => {
let _ = fs::remove_file(&tmp);
Err(e)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn scratch(tag: &str) -> PathBuf {
let mut p = std::env::temp_dir();
p.push(format!(
"cargoless-cache-layout-{tag}-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&p);
fs::create_dir_all(&p).unwrap();
p
}
fn dir_fingerprint(dir: &Path) -> String {
fn walk(dir: &Path, out: &mut Vec<(String, Vec<u8>)>) {
let Ok(rd) = fs::read_dir(dir) else { return };
for e in rd.flatten() {
let p = e.path();
if p.is_dir() {
walk(&p, out);
} else if let Ok(b) = fs::read(&p) {
out.push((p.to_string_lossy().into_owned(), b));
}
}
}
let mut entries = Vec::new();
walk(dir, &mut entries);
entries.sort();
let mut buf = Vec::new();
for (name, bytes) in entries {
buf.extend_from_slice(name.as_bytes());
buf.push(0);
buf.extend_from_slice(&bytes);
buf.push(0);
}
sha256_hex(&buf)
}
#[test]
fn layout_paths_are_the_design_5_shape() {
let repo = scratch("paths");
let l = CacheLayout::for_repo(&repo, TF_STATE_DIR_REL);
assert_eq!(l.root(), repo.join(".triform/cargoless"));
assert!(l.base_cache_dir().ends_with("base.cache"));
assert!(l.tree_cache_dir().ends_with("tree.cache"));
assert!(l.solo_dir().ends_with("solo"));
assert!(l.combined_dir().ends_with("combined"));
let hw = OverlayHash::new("a1b2c3");
assert_eq!(l.solo_entry(&hw), l.solo_dir().join("a1b2c3"));
let _ = fs::remove_dir_all(&repo);
}
#[test]
fn default_state_dir_is_backward_compat_and_tf_override_is_distinct() {
let repo = scratch("statedir");
let v0 = CacheLayout::for_repo(&repo, DEFAULT_STATE_DIR_REL);
let tf = CacheLayout::for_repo(&repo, TF_STATE_DIR_REL);
assert_eq!(v0.root(), repo.join(".cargoless"));
assert_eq!(tf.root(), repo.join(".triform/cargoless"));
assert_ne!(
v0.root(),
tf.root(),
"the tf override must not alias the v0 backward-compat root"
);
let _ = fs::remove_dir_all(&repo);
}
#[test]
fn worktree_tree_cache_lives_under_the_worktree_not_the_base() {
let repo = scratch("wt");
let l = CacheLayout::for_repo(&repo, TF_STATE_DIR_REL);
let wt = repo.join(".claude/worktrees/agent-x");
let wtc = l.worktree_tree_cache(&wt);
assert_eq!(wtc, wt.join(".triform/cargoless").join("tree.cache"));
assert!(
!wtc.starts_with(l.root()),
"a worktree's tree.cache must NOT live under the base state root \
(base advances must not be able to touch a worktree's cache)"
);
let _ = fs::remove_dir_all(&repo);
}
#[test]
fn combined_key_is_set_keyed_order_and_dup_independent() {
let a = OverlayHash::new("aaaa");
let b = OverlayHash::new("bbbb");
let c = OverlayHash::new("cccc");
let ab = combined_key(&[a.clone(), b.clone()]);
let ba = combined_key(&[b.clone(), a.clone()]);
let aab = combined_key(&[a.clone(), a.clone(), b.clone()]);
assert_eq!(ab, ba, "{{A,B}} must equal {{B,A}} (set, not sequence)");
assert_eq!(aab, ab, "{{A,A,B}} must equal {{A,B}} (deduped)");
let abc = combined_key(&[a, b, c]);
assert_ne!(
abc, ab,
"a different membership set must be a different key"
);
assert_eq!(ab.len(), 64, "combined key is a 64-hex SHA-256");
}
#[test]
fn combined_key_resists_element_boundary_smear() {
let x = combined_key(&[OverlayHash::new("ab"), OverlayHash::new("c")]);
let y = combined_key(&[OverlayHash::new("a"), OverlayHash::new("bc")]);
assert_ne!(x, y, "element-boundary smear must not alias distinct sets");
let empty = combined_key(&[]);
assert_eq!(empty.len(), 64);
assert_ne!(empty, combined_key(&[OverlayHash::new("")]));
}
#[test]
fn solo_entry_is_content_addressed_stable() {
let repo = scratch("solo");
let l = CacheLayout::for_repo(&repo, TF_STATE_DIR_REL);
let hw = OverlayHash::new("deadbeef");
assert_eq!(l.solo_entry(&hw), l.solo_entry(&hw));
assert_ne!(l.solo_entry(&hw), l.solo_entry(&OverlayHash::new("cafe")));
let _ = fs::remove_dir_all(&repo);
}
#[test]
fn advance_base_records_pin_atomically_and_is_readable() {
let repo = scratch("advance");
let l = CacheLayout::for_repo(&repo, TF_STATE_DIR_REL);
l.ensure_dirs().unwrap();
assert_eq!(l.base_pin().unwrap(), None, "fresh layout: never advanced");
let pin1 = BasePin::new("49bb9c126");
l.advance_base(&pin1).unwrap();
assert_eq!(l.base_pin().unwrap(), Some(pin1));
let pin2 = BasePin::new("7f3a0011");
l.advance_base(&pin2).unwrap();
assert_eq!(l.base_pin().unwrap(), Some(pin2));
let leftover: Vec<_> = fs::read_dir(l.base_cache_dir())
.unwrap()
.flatten()
.filter(|e| e.file_name().to_string_lossy().contains(".tmp."))
.collect();
assert!(leftover.is_empty(), "atomic_write must not leak temp files");
let _ = fs::remove_dir_all(&repo);
}
#[test]
fn base_cache_is_byte_unmoved_by_inflight_writes() {
let repo = scratch("decoupled");
let l = CacheLayout::for_repo(&repo, TF_STATE_DIR_REL);
l.ensure_dirs().unwrap();
l.advance_base(&BasePin::new("base-rev-0001")).unwrap();
let pinned = dir_fingerprint(&l.base_cache_dir());
for i in 0..50 {
let tc = l.tree_cache_dir();
fs::create_dir_all(&tc).unwrap();
fs::write(tc.join(format!("inflight-{i}")), format!("edit {i}")).unwrap();
let hw = OverlayHash::new(format!("hw-{i:04}"));
let se = l.solo_entry(&hw);
fs::create_dir_all(se.parent().unwrap()).unwrap();
fs::write(&se, format!("solo verdict {i}")).unwrap();
let ce = l.combined_entry(&[hw.clone(), OverlayHash::new("peer")]);
fs::create_dir_all(ce.parent().unwrap()).unwrap();
fs::write(&ce, format!("combined verdict {i}")).unwrap();
let wtc = l.worktree_tree_cache(repo.join(format!(".claude/worktrees/w{i}")));
fs::create_dir_all(&wtc).unwrap();
fs::write(wtc.join("cli-status"), format!("verdict={i}")).unwrap();
}
assert_eq!(
pinned,
dir_fingerprint(&l.base_cache_dir()),
"base.cache/ MUST be byte-unmoved by in-flight tree/solo/combined \
writes — this is the operator's decoupled-lifecycle invariant; \
only advance_base() (an explicit git-advance op) may move it"
);
l.advance_base(&BasePin::new("base-rev-0002")).unwrap();
assert_ne!(
pinned,
dir_fingerprint(&l.base_cache_dir()),
"advance_base IS the one path that moves base.cache"
);
let _ = fs::remove_dir_all(&repo);
}
}