//! Vault registry: persistent list of known vault directories and the active vault. //! //! A **vault** is a self-contained directory containing `audiofiles.db` + `samples/`. //! The **registry** is a small JSON file listing all known vaults and which one is //! currently active. It lives at the platform config directory so it is independent //! of any particular vault. use std::fs; use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; /// A single vault: a named path to a self-contained audiofiles directory. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct VaultEntry { pub name: String, pub path: PathBuf, } /// Persistent list of all known vaults and the currently active one. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct VaultRegistry { pub vaults: Vec, pub active: PathBuf, } /// Errors specific to vault operations. #[derive(Debug, thiserror::Error)] pub enum VaultError { #[error("I/O error: {0}")] Io(#[from] std::io::Error), #[error("JSON error: {0}")] Json(#[from] serde_json::Error), #[error("vault not found in registry: {0}")] NotFound(PathBuf), #[error("vault path is unreachable: {0}")] Unreachable(PathBuf), #[error("vault already exists in registry: {0}")] AlreadyExists(PathBuf), #[error("not a valid vault (missing audiofiles.db): {0}")] InvalidVault(PathBuf), } /// Platform path where the vault registry JSON is stored. pub fn registry_path() -> PathBuf { dirs::config_dir() .unwrap_or_else(|| { tracing::warn!("config_dir() unavailable ($HOME unset?), falling back to CWD"); PathBuf::from(".") }) .join("audiofiles") .join("vaults.json") } /// Load the vault registry from disk. /// /// Returns `Ok(None)` if the file does not exist (first launch). pub fn load_registry() -> Result, VaultError> { let path = registry_path(); match fs::read(&path) { Ok(bytes) => { let reg: VaultRegistry = serde_json::from_slice(&bytes)?; Ok(Some(reg)) } Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), Err(e) => Err(VaultError::Io(e)), } } /// Atomically save the registry to disk (write tmp + rename). pub fn save_registry(reg: &VaultRegistry) -> Result<(), VaultError> { let path = registry_path(); if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } let json = serde_json::to_string_pretty(reg)?; let tmp = path.with_extension("json.tmp"); fs::write(&tmp, &json)?; fs::rename(&tmp, &path)?; Ok(()) } /// Create a new vault: make the directory structure and add it to the registry. /// /// The vault directory is created with `audiofiles.db` and `samples/` inside. /// Does not create the database — that happens when `BrowserState::new()` opens it. pub fn create_vault(reg: &mut VaultRegistry, name: &str, path: &Path) -> Result<(), VaultError> { let canonical = normalize_path(path); if reg.vaults.iter().any(|v| normalize_path(&v.path) == canonical) { return Err(VaultError::AlreadyExists(path.to_path_buf())); } fs::create_dir_all(path)?; fs::create_dir_all(path.join("samples"))?; reg.vaults.push(VaultEntry { name: name.to_string(), path: path.to_path_buf(), }); Ok(()) } /// Add an existing vault directory to the registry. /// /// Validates that `audiofiles.db` exists at the given path. pub fn add_existing_vault( reg: &mut VaultRegistry, name: &str, path: &Path, ) -> Result<(), VaultError> { let canonical = normalize_path(path); if reg.vaults.iter().any(|v| normalize_path(&v.path) == canonical) { return Err(VaultError::AlreadyExists(path.to_path_buf())); } if !path.join("audiofiles.db").exists() { return Err(VaultError::InvalidVault(path.to_path_buf())); } reg.vaults.push(VaultEntry { name: name.to_string(), path: path.to_path_buf(), }); Ok(()) } /// Remove a vault from the registry (does not delete files on disk). pub fn remove_vault(reg: &mut VaultRegistry, path: &Path) -> Result<(), VaultError> { let canonical = normalize_path(path); let before = reg.vaults.len(); reg.vaults.retain(|v| normalize_path(&v.path) != canonical); if reg.vaults.len() == before { return Err(VaultError::NotFound(path.to_path_buf())); } Ok(()) } /// Rename a vault in the registry. pub fn rename_vault( reg: &mut VaultRegistry, path: &Path, new_name: &str, ) -> Result<(), VaultError> { let canonical = normalize_path(path); let entry = reg .vaults .iter_mut() .find(|v| normalize_path(&v.path) == canonical) .ok_or_else(|| VaultError::NotFound(path.to_path_buf()))?; entry.name = new_name.to_string(); Ok(()) } /// Repoint a vault registry entry from `old_path` to `new_path`. Used by the /// "Locate…" affordance in Settings when an offline vault's directory has /// moved on disk. The new path must contain an `audiofiles.db` to be accepted. pub fn relocate_vault( reg: &mut VaultRegistry, old_path: &Path, new_path: &Path, ) -> Result<(), VaultError> { if !new_path.join("audiofiles.db").exists() { return Err(VaultError::InvalidVault(new_path.to_path_buf())); } let canonical_old = normalize_path(old_path); let entry = reg .vaults .iter_mut() .find(|v| normalize_path(&v.path) == canonical_old) .ok_or_else(|| VaultError::NotFound(old_path.to_path_buf()))?; // Update active pointer if it was pointing at the old path. if normalize_path(®.active) == canonical_old { reg.active = new_path.to_path_buf(); } entry.path = new_path.to_path_buf(); Ok(()) } /// Check whether a vault path is reachable (directory exists). pub fn is_vault_reachable(path: &Path) -> bool { path.is_dir() } /// Best-effort path normalization without requiring the path to exist. fn normalize_path(path: &Path) -> PathBuf { path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) } /// Default vault path: the platform data directory. pub fn default_vault_path() -> PathBuf { dirs::data_dir() .unwrap_or_else(|| { tracing::warn!("data_dir() unavailable ($HOME unset?), falling back to CWD"); PathBuf::from(".") }) .join("audiofiles") } #[cfg(test)] mod tests { use super::*; /// Helper: create a temporary registry file location and an empty registry. fn temp_registry(dir: &Path) -> VaultRegistry { VaultRegistry { vaults: Vec::new(), active: dir.to_path_buf(), } } #[test] fn create_vault_adds_entry_and_creates_dirs() { let dir = tempfile::tempdir().unwrap(); let vault_path = dir.path().join("my_vault"); let mut reg = temp_registry(dir.path()); create_vault(&mut reg, "Test Vault", &vault_path).unwrap(); assert_eq!(reg.vaults.len(), 1); assert_eq!(reg.vaults[0].name, "Test Vault"); assert_eq!(reg.vaults[0].path, vault_path); assert!(vault_path.exists()); assert!(vault_path.join("samples").exists()); } #[test] fn create_vault_rejects_duplicate() { let dir = tempfile::tempdir().unwrap(); let vault_path = dir.path().join("vault"); let mut reg = temp_registry(dir.path()); create_vault(&mut reg, "V1", &vault_path).unwrap(); let err = create_vault(&mut reg, "V2", &vault_path).unwrap_err(); assert!(matches!(err, VaultError::AlreadyExists(_))); } #[test] fn add_existing_vault_validates_db() { let dir = tempfile::tempdir().unwrap(); let vault_path = dir.path().join("existing"); fs::create_dir_all(&vault_path).unwrap(); let mut reg = temp_registry(dir.path()); // No audiofiles.db → error let err = add_existing_vault(&mut reg, "Bad", &vault_path).unwrap_err(); assert!(matches!(err, VaultError::InvalidVault(_))); // Create the db file fs::write(vault_path.join("audiofiles.db"), b"").unwrap(); add_existing_vault(&mut reg, "Good", &vault_path).unwrap(); assert_eq!(reg.vaults.len(), 1); } #[test] fn remove_vault_from_registry() { let dir = tempfile::tempdir().unwrap(); let vault_path = dir.path().join("vault"); let mut reg = temp_registry(dir.path()); create_vault(&mut reg, "V", &vault_path).unwrap(); assert_eq!(reg.vaults.len(), 1); remove_vault(&mut reg, &vault_path).unwrap(); assert!(reg.vaults.is_empty()); } #[test] fn remove_vault_not_found() { let dir = tempfile::tempdir().unwrap(); let mut reg = temp_registry(dir.path()); let err = remove_vault(&mut reg, Path::new("/nonexistent")).unwrap_err(); assert!(matches!(err, VaultError::NotFound(_))); } #[test] fn rename_vault_updates_name() { let dir = tempfile::tempdir().unwrap(); let vault_path = dir.path().join("vault"); let mut reg = temp_registry(dir.path()); create_vault(&mut reg, "Old Name", &vault_path).unwrap(); rename_vault(&mut reg, &vault_path, "New Name").unwrap(); assert_eq!(reg.vaults[0].name, "New Name"); } #[test] fn rename_vault_not_found() { let dir = tempfile::tempdir().unwrap(); let mut reg = temp_registry(dir.path()); let err = rename_vault(&mut reg, Path::new("/nope"), "X").unwrap_err(); assert!(matches!(err, VaultError::NotFound(_))); } #[test] fn is_vault_reachable_checks_directory() { let dir = tempfile::tempdir().unwrap(); assert!(is_vault_reachable(dir.path())); assert!(!is_vault_reachable(Path::new("/nonexistent/vault/path"))); } #[test] fn save_and_load_roundtrip() { // Use a custom registry path for testing by saving/loading manually let dir = tempfile::tempdir().unwrap(); let vault_path = dir.path().join("vault_a"); let mut reg = temp_registry(&vault_path); create_vault(&mut reg, "A", &vault_path).unwrap(); reg.active = vault_path.clone(); let json_path = dir.path().join("test_registry.json"); let json = serde_json::to_string_pretty(®).unwrap(); fs::write(&json_path, &json).unwrap(); let bytes = fs::read(&json_path).unwrap(); let loaded: VaultRegistry = serde_json::from_slice(&bytes).unwrap(); assert_eq!(loaded.vaults.len(), 1); assert_eq!(loaded.vaults[0].name, "A"); assert_eq!(loaded.active, vault_path); } #[test] fn crud_roundtrip() { let dir = tempfile::tempdir().unwrap(); let mut reg = temp_registry(dir.path()); // Create two vaults let v1 = dir.path().join("v1"); let v2 = dir.path().join("v2"); create_vault(&mut reg, "Vault 1", &v1).unwrap(); create_vault(&mut reg, "Vault 2", &v2).unwrap(); assert_eq!(reg.vaults.len(), 2); // Rename rename_vault(&mut reg, &v1, "Primary").unwrap(); assert_eq!(reg.vaults[0].name, "Primary"); // Remove remove_vault(&mut reg, &v2).unwrap(); assert_eq!(reg.vaults.len(), 1); assert_eq!(reg.vaults[0].name, "Primary"); } }