use crate::config::VaultConfig;
use crate::error::{Error, Result};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::path::{Path, PathBuf};
use tokio::fs;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CacheMetadata {
pub active_vault: String,
pub last_updated: u64,
pub version: u32,
pub project_id: String,
pub working_dir: String,
}
pub struct VaultCache {
cache_dir: PathBuf,
project_cache_dir: PathBuf,
vaults_file: PathBuf,
metadata_file: PathBuf,
project_id: String,
working_dir: PathBuf,
}
impl VaultCache {
pub async fn init() -> Result<Self> {
let cache_dir = Self::get_cache_dir()?;
let working_dir = std::env::current_dir().map_err(Error::io)?;
let project_id = Self::get_project_id(&working_dir)?;
let project_cache_dir = cache_dir.join("projects").join(&project_id);
if !project_cache_dir.exists() {
fs::create_dir_all(&project_cache_dir)
.await
.map_err(Error::io)?;
}
Ok(Self {
cache_dir,
project_cache_dir: project_cache_dir.clone(),
vaults_file: project_cache_dir.join("vaults.yaml"),
metadata_file: project_cache_dir.join("metadata.json"),
project_id,
working_dir,
})
}
pub async fn init_with_project(project_root: &Path) -> Result<Self> {
let cache_dir = Self::get_cache_dir()?;
let project_id = Self::get_project_id(project_root)?;
let project_cache_dir = cache_dir.join("projects").join(&project_id);
if !project_cache_dir.exists() {
fs::create_dir_all(&project_cache_dir)
.await
.map_err(Error::io)?;
}
Ok(Self {
cache_dir,
project_cache_dir: project_cache_dir.clone(),
vaults_file: project_cache_dir.join("vaults.yaml"),
metadata_file: project_cache_dir.join("metadata.json"),
project_id,
working_dir: project_root.to_path_buf(),
})
}
fn get_project_id(start_path: &Path) -> Result<String> {
let markers = vec![
".git",
".obsidian",
"Cargo.toml",
"package.json",
".project",
];
let mut current = start_path.to_path_buf();
loop {
for marker in &markers {
let marker_path = current.join(marker);
if marker_path.exists() {
let canonical = current.canonicalize().unwrap_or_else(|_| current.clone());
let project_id = Self::hash_path(&canonical);
log::debug!(
"Detected project root: {} (hash: {})",
canonical.display(),
project_id
);
return Ok(project_id);
}
}
if !current.pop() {
let canonical = start_path
.canonicalize()
.unwrap_or_else(|_| start_path.to_path_buf());
let project_id = Self::hash_path(&canonical);
log::debug!(
"No project marker found, using start path: {} (hash: {})",
canonical.display(),
project_id
);
return Ok(project_id);
}
}
}
fn hash_path(path: &Path) -> String {
let path_str = path.to_string_lossy();
let mut hasher = Sha256::new();
hasher.update(path_str.as_bytes());
let result = hasher.finalize();
format!("{:x}", result)[..16].to_string() }
fn get_cache_dir() -> Result<PathBuf> {
if let Ok(cache_home) = std::env::var("XDG_CACHE_HOME") {
return Ok(PathBuf::from(cache_home).join("turbovault"));
}
#[cfg(target_os = "windows")]
{
if let Ok(local_app_data) = std::env::var("LOCALAPPDATA") {
return Ok(PathBuf::from(local_app_data)
.join("turbovault")
.join("cache"));
}
}
#[cfg(not(target_os = "windows"))]
{
if let Ok(home) = std::env::var("HOME") {
return Ok(PathBuf::from(home).join(".cache").join("turbovault"));
}
}
if let Ok(home) = std::env::var("HOME") {
return Ok(PathBuf::from(home).join(".turbovault").join("cache"));
}
Err(Error::config_error(
"Cannot determine cache directory: HOME not set and no platform-specific override found".to_string()
))
}
pub async fn save_vaults(&self, vaults: &[VaultConfig], active_vault: &str) -> Result<()> {
let vaults_yaml = serde_yaml::to_string(vaults)
.map_err(|e| Error::config_error(format!("Failed to serialize vaults: {}", e)))?;
fs::write(&self.vaults_file, vaults_yaml)
.await
.map_err(Error::io)?;
let metadata = CacheMetadata {
active_vault: active_vault.to_string(),
last_updated: Self::current_timestamp(),
version: 1,
project_id: self.project_id.clone(),
working_dir: self.working_dir.to_string_lossy().to_string(),
};
let metadata_json = serde_json::to_string_pretty(&metadata)
.map_err(|e| Error::config_error(format!("Failed to serialize metadata: {}", e)))?;
fs::write(&self.metadata_file, metadata_json)
.await
.map_err(Error::io)?;
log::debug!(
"Saved {} vaults to project cache {} (active: {})",
vaults.len(),
self.project_id,
active_vault
);
Ok(())
}
pub async fn load_vaults(&self) -> Result<Vec<VaultConfig>> {
if !self.vaults_file.exists() {
return Ok(Vec::new()); }
let content = fs::read_to_string(&self.vaults_file)
.await
.map_err(Error::io)?;
let vaults = serde_yaml::from_str(&content)
.map_err(|e| Error::config_error(format!("Invalid vaults cache format: {}", e)))?;
log::debug!("Loaded vaults from project cache {}", self.project_id);
Ok(vaults)
}
pub async fn load_metadata(&self) -> Result<CacheMetadata> {
if !self.metadata_file.exists() {
return Ok(CacheMetadata {
active_vault: String::new(),
last_updated: 0,
version: 1,
project_id: self.project_id.clone(),
working_dir: self.working_dir.to_string_lossy().to_string(),
});
}
let content = fs::read_to_string(&self.metadata_file)
.await
.map_err(Error::io)?;
let metadata = serde_json::from_str(&content)
.map_err(|e| Error::config_error(format!("Invalid metadata cache format: {}", e)))?;
Ok(metadata)
}
pub async fn clear(&self) -> Result<()> {
if self.vaults_file.exists() {
fs::remove_file(&self.vaults_file)
.await
.map_err(Error::io)?;
}
if self.metadata_file.exists() {
fs::remove_file(&self.metadata_file)
.await
.map_err(Error::io)?;
}
log::info!("Cache cleared for project {}", self.project_id);
Ok(())
}
pub fn cache_dir(&self) -> &Path {
&self.cache_dir
}
pub fn project_cache_dir(&self) -> &Path {
&self.project_cache_dir
}
pub fn project_id(&self) -> &str {
&self.project_id
}
pub fn working_dir(&self) -> &Path {
&self.working_dir
}
fn current_timestamp() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::VaultConfig;
use tempfile::TempDir;
async fn make_cache(temp: &TempDir) -> VaultCache {
unsafe { std::env::set_var("XDG_CACHE_HOME", temp.path()) };
VaultCache::init_with_project(temp.path()).await.unwrap()
}
fn make_vault_config(name: &str, path: &std::path::Path) -> VaultConfig {
VaultConfig {
name: name.to_string(),
path: path.to_path_buf(),
is_default: false,
watch_for_changes: None,
max_file_size: None,
allowed_extensions: None,
excluded_paths: None,
enable_caching: None,
cache_ttl: None,
template_dirs: None,
allowed_operations: None,
}
}
#[test]
fn test_hash_path_deterministic() {
let path = Path::new("/home/user/projects/vault");
let h1 = VaultCache::hash_path(path);
let h2 = VaultCache::hash_path(path);
assert_eq!(h1, h2, "same path must always produce the same hash");
assert_eq!(h1.len(), 16);
}
#[test]
fn test_hash_path_different_paths() {
let h1 = VaultCache::hash_path(Path::new("/home/user/vault-a"));
let h2 = VaultCache::hash_path(Path::new("/home/user/vault-b"));
assert_ne!(h1, h2, "different paths must produce different hashes");
}
#[tokio::test]
async fn test_init_creates_cache_directory() {
let temp = TempDir::new().unwrap();
unsafe { std::env::set_var("XDG_CACHE_HOME", temp.path()) };
let cache = VaultCache::init_with_project(temp.path()).await.unwrap();
assert!(
cache.project_cache_dir().exists(),
"project cache dir should be created by init_with_project"
);
assert!(cache.project_cache_dir().is_dir());
}
#[tokio::test]
async fn test_save_and_load_roundtrip() {
let temp = TempDir::new().unwrap();
let cache = make_cache(&temp).await;
let configs = vec![
make_vault_config("personal", &temp.path().join("personal")),
make_vault_config("work", &temp.path().join("work")),
];
cache.save_vaults(&configs, "personal").await.unwrap();
let loaded = cache.load_vaults().await.unwrap();
assert_eq!(loaded.len(), 2);
let names: Vec<&str> = loaded.iter().map(|v| v.name.as_str()).collect();
assert!(names.contains(&"personal"));
assert!(names.contains(&"work"));
let meta = cache.load_metadata().await.unwrap();
assert_eq!(meta.active_vault, "personal");
}
#[tokio::test]
async fn test_clear_removes_data() {
let temp = TempDir::new().unwrap();
let cache = make_cache(&temp).await;
let configs = vec![make_vault_config("v1", &temp.path().join("v1"))];
cache.save_vaults(&configs, "v1").await.unwrap();
assert_eq!(cache.load_vaults().await.unwrap().len(), 1);
cache.clear().await.unwrap();
let after = cache.load_vaults().await.unwrap();
assert!(after.is_empty(), "cleared cache should return no vaults");
}
#[tokio::test]
async fn test_load_nonexistent_returns_empty() {
let temp = TempDir::new().unwrap();
let cache = make_cache(&temp).await;
let vaults = cache.load_vaults().await.unwrap();
assert!(
vaults.is_empty(),
"missing cache file should produce empty vec, not an error"
);
}
#[tokio::test]
async fn test_corrupted_cache_graceful() {
let temp = TempDir::new().unwrap();
let cache = make_cache(&temp).await;
let vaults_path = cache.project_cache_dir().join("vaults.yaml");
tokio::fs::write(&vaults_path, b"{ not: [valid: yaml---\x00\xff")
.await
.unwrap();
let result = cache.load_vaults().await;
match result {
Ok(vaults) => {
let _ = vaults;
}
Err(_) => {
}
}
}
}