use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::fs;
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct TreeManifestEntry {
pub relative_path: PathBuf,
pub is_dir: bool,
pub mode: u32,
pub blob_hash: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct TreeManifest {
pub schema_version: u32,
pub entries: Vec<TreeManifestEntry>,
}
pub struct LocalCas {
base_dir: PathBuf,
}
impl LocalCas {
pub fn new() -> Result<Self> {
let home = std::env::var("USERPROFILE")
.or_else(|_| std::env::var("HOME"))
.unwrap_or_else(|_| ".".to_string());
let base_dir = PathBuf::from(home).join(".rivox").join("cache").join("cas");
fs::create_dir_all(&base_dir).context("Failed to create local CAS directory")?;
Ok(Self { base_dir })
}
pub fn blob_path(&self, hash: &str) -> PathBuf {
let clean_hash = hash.trim_start_matches("sha256:");
let (dir1, dir2) = if clean_hash.len() >= 4 {
(&clean_hash[0..2], &clean_hash[2..4])
} else {
("00", "00")
};
self.base_dir
.join("blobs")
.join(dir1)
.join(dir2)
.join(clean_hash)
}
fn tree_manifest_path(&self, tree_key: &str) -> PathBuf {
let clean_key = tree_key.trim_start_matches("sha256:");
let (dir1, dir2) = if clean_key.len() >= 4 {
(&clean_key[0..2], &clean_key[2..4])
} else {
("00", "00")
};
self.base_dir
.join("trees")
.join(dir1)
.join(dir2)
.join(clean_key)
}
pub fn has_blob(&self, hash: &str) -> bool {
self.blob_path(hash).exists() || self.tree_manifest_path(hash).exists()
}
pub fn store_file(&self, hash: &str, source_path: &Path) -> Result<()> {
let target = self.blob_path(hash);
if target.exists() {
return Ok(());
}
if let Some(parent) = target.parent() {
fs::create_dir_all(parent)?;
}
if fs::hard_link(source_path, &target).is_err() {
fs::copy(source_path, &target)
.with_context(|| format!("Failed to copy file blob to {}", target.display()))?;
}
Ok(())
}
pub fn store_tree(&self, tree_key: &str, source_dir: &Path) -> Result<()> {
let manifest_target = self.tree_manifest_path(tree_key);
if manifest_target.exists() {
return Ok(());
}
if let Some(parent) = manifest_target.parent() {
fs::create_dir_all(parent)?;
}
let mut entries = Vec::new();
for entry in WalkDir::new(source_dir).into_iter().filter_map(|e| e.ok()) {
let path = entry.path();
let relative_path = path.strip_prefix(source_dir)?.to_path_buf();
if relative_path.as_os_str().is_empty() {
continue;
}
let metadata = entry.metadata()?;
let is_dir = metadata.is_dir();
let blob_hash = if !is_dir {
let content = fs::read(path)?;
let file_hash = format!("sha256:{}", hex::encode(Sha256::digest(&content)));
self.store_file(&file_hash, path)?;
Some(file_hash)
} else {
None
};
entries.push(TreeManifestEntry {
relative_path,
is_dir,
mode: 0o644,
blob_hash,
});
}
let manifest = TreeManifest {
schema_version: 1,
entries,
};
let json_content = serde_json::to_string_pretty(&manifest)?;
fs::write(manifest_target, json_content)?;
Ok(())
}
pub fn restore_tree(&self, tree_key: &str, destination_dir: &Path) -> Result<()> {
let manifest_path = self.tree_manifest_path(tree_key);
if !manifest_path.exists() {
anyhow::bail!("Tree manifest missing for key {}", tree_key);
}
let content = fs::read_to_string(&manifest_path)?;
let manifest: TreeManifest = serde_json::from_str(&content)?;
let dest_canonical = destination_dir
.canonicalize()
.unwrap_or_else(|_| destination_dir.to_path_buf());
fs::create_dir_all(&dest_canonical)?;
for entry in manifest.entries {
let target_path = dest_canonical.join(&entry.relative_path);
if entry.is_dir {
fs::create_dir_all(&target_path)?;
} else if let Some(blob_hash) = entry.blob_hash {
let source_blob = self.blob_path(&blob_hash);
if let Some(parent) = target_path.parent() {
fs::create_dir_all(parent)?;
}
if target_path.exists() {
fs::remove_file(&target_path)?;
}
if fs::hard_link(&source_blob, &target_path).is_err() {
fs::copy(&source_blob, &target_path)?;
}
}
}
Ok(())
}
pub fn prune(&self, max_age_days: u64) -> Result<usize> {
let mut pruned_count = 0;
let cutoff_seconds = max_age_days * 86400;
let now = std::time::SystemTime::now();
for root in &[self.base_dir.join("blobs"), self.base_dir.join("trees")] {
if !root.exists() {
continue;
}
for entry in WalkDir::new(root).into_iter().filter_map(|e| e.ok()) {
if entry.file_type().is_file() {
let is_old = entry
.metadata()
.ok()
.and_then(|m| m.modified().ok())
.and_then(|mod_time| now.duration_since(mod_time).ok())
.map(|elapsed| elapsed.as_secs() > cutoff_seconds)
.unwrap_or(false);
if is_old && fs::remove_file(entry.path()).is_ok() {
pruned_count += 1;
}
}
}
}
Ok(pruned_count)
}
}