Skip to main content

max / audiofiles

11.2 KB · 339 lines History Blame Raw
1 //! Vault registry: persistent list of known vault directories and the active vault.
2 //!
3 //! A **vault** is a self-contained directory containing `audiofiles.db` + `samples/`.
4 //! The **registry** is a small JSON file listing all known vaults and which one is
5 //! currently active. It lives at the platform config directory so it is independent
6 //! of any particular vault.
7
8 use std::fs;
9 use std::path::{Path, PathBuf};
10
11 use serde::{Deserialize, Serialize};
12
13 /// A single vault: a named path to a self-contained audiofiles directory.
14 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
15 pub struct VaultEntry {
16 pub name: String,
17 pub path: PathBuf,
18 }
19
20 /// Persistent list of all known vaults and the currently active one.
21 #[derive(Debug, Clone, Serialize, Deserialize)]
22 pub struct VaultRegistry {
23 pub vaults: Vec<VaultEntry>,
24 pub active: PathBuf,
25 }
26
27 /// Errors specific to vault operations.
28 #[derive(Debug, thiserror::Error)]
29 pub enum VaultError {
30 #[error("I/O error: {0}")]
31 Io(#[from] std::io::Error),
32 #[error("JSON error: {0}")]
33 Json(#[from] serde_json::Error),
34 #[error("vault not found in registry: {0}")]
35 NotFound(PathBuf),
36 #[error("vault path is unreachable: {0}")]
37 Unreachable(PathBuf),
38 #[error("vault already exists in registry: {0}")]
39 AlreadyExists(PathBuf),
40 #[error("not a valid vault (missing audiofiles.db): {0}")]
41 InvalidVault(PathBuf),
42 }
43
44 /// Platform path where the vault registry JSON is stored.
45 pub fn registry_path() -> PathBuf {
46 dirs::config_dir()
47 .unwrap_or_else(|| {
48 tracing::warn!("config_dir() unavailable ($HOME unset?), falling back to CWD");
49 PathBuf::from(".")
50 })
51 .join("audiofiles")
52 .join("vaults.json")
53 }
54
55 /// Load the vault registry from disk.
56 ///
57 /// Returns `Ok(None)` if the file does not exist (first launch).
58 pub fn load_registry() -> Result<Option<VaultRegistry>, VaultError> {
59 let path = registry_path();
60 match fs::read(&path) {
61 Ok(bytes) => {
62 let reg: VaultRegistry = serde_json::from_slice(&bytes)?;
63 Ok(Some(reg))
64 }
65 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
66 Err(e) => Err(VaultError::Io(e)),
67 }
68 }
69
70 /// Atomically save the registry to disk (write tmp + rename).
71 pub fn save_registry(reg: &VaultRegistry) -> Result<(), VaultError> {
72 let path = registry_path();
73 if let Some(parent) = path.parent() {
74 fs::create_dir_all(parent)?;
75 }
76 let json = serde_json::to_string_pretty(reg)?;
77 let tmp = path.with_extension("json.tmp");
78 fs::write(&tmp, &json)?;
79 fs::rename(&tmp, &path)?;
80 Ok(())
81 }
82
83 /// Create a new vault: make the directory structure and add it to the registry.
84 ///
85 /// The vault directory is created with `audiofiles.db` and `samples/` inside.
86 /// Does not create the database — that happens when `BrowserState::new()` opens it.
87 pub fn create_vault(reg: &mut VaultRegistry, name: &str, path: &Path) -> Result<(), VaultError> {
88 let canonical = normalize_path(path);
89 if reg.vaults.iter().any(|v| normalize_path(&v.path) == canonical) {
90 return Err(VaultError::AlreadyExists(path.to_path_buf()));
91 }
92 fs::create_dir_all(path)?;
93 fs::create_dir_all(path.join("samples"))?;
94 reg.vaults.push(VaultEntry {
95 name: name.to_string(),
96 path: path.to_path_buf(),
97 });
98 Ok(())
99 }
100
101 /// Add an existing vault directory to the registry.
102 ///
103 /// Validates that `audiofiles.db` exists at the given path.
104 pub fn add_existing_vault(
105 reg: &mut VaultRegistry,
106 name: &str,
107 path: &Path,
108 ) -> Result<(), VaultError> {
109 let canonical = normalize_path(path);
110 if reg.vaults.iter().any(|v| normalize_path(&v.path) == canonical) {
111 return Err(VaultError::AlreadyExists(path.to_path_buf()));
112 }
113 if !path.join("audiofiles.db").exists() {
114 return Err(VaultError::InvalidVault(path.to_path_buf()));
115 }
116 reg.vaults.push(VaultEntry {
117 name: name.to_string(),
118 path: path.to_path_buf(),
119 });
120 Ok(())
121 }
122
123 /// Remove a vault from the registry (does not delete files on disk).
124 pub fn remove_vault(reg: &mut VaultRegistry, path: &Path) -> Result<(), VaultError> {
125 let canonical = normalize_path(path);
126 let before = reg.vaults.len();
127 reg.vaults.retain(|v| normalize_path(&v.path) != canonical);
128 if reg.vaults.len() == before {
129 return Err(VaultError::NotFound(path.to_path_buf()));
130 }
131 Ok(())
132 }
133
134 /// Rename a vault in the registry.
135 pub fn rename_vault(
136 reg: &mut VaultRegistry,
137 path: &Path,
138 new_name: &str,
139 ) -> Result<(), VaultError> {
140 let canonical = normalize_path(path);
141 let entry = reg
142 .vaults
143 .iter_mut()
144 .find(|v| normalize_path(&v.path) == canonical)
145 .ok_or_else(|| VaultError::NotFound(path.to_path_buf()))?;
146 entry.name = new_name.to_string();
147 Ok(())
148 }
149
150 /// Repoint a vault registry entry from `old_path` to `new_path`. Used by the
151 /// "Locate…" affordance in Settings when an offline vault's directory has
152 /// moved on disk. The new path must contain an `audiofiles.db` to be accepted.
153 pub fn relocate_vault(
154 reg: &mut VaultRegistry,
155 old_path: &Path,
156 new_path: &Path,
157 ) -> Result<(), VaultError> {
158 if !new_path.join("audiofiles.db").exists() {
159 return Err(VaultError::InvalidVault(new_path.to_path_buf()));
160 }
161 let canonical_old = normalize_path(old_path);
162 let entry = reg
163 .vaults
164 .iter_mut()
165 .find(|v| normalize_path(&v.path) == canonical_old)
166 .ok_or_else(|| VaultError::NotFound(old_path.to_path_buf()))?;
167 // Update active pointer if it was pointing at the old path.
168 if normalize_path(&reg.active) == canonical_old {
169 reg.active = new_path.to_path_buf();
170 }
171 entry.path = new_path.to_path_buf();
172 Ok(())
173 }
174
175 /// Check whether a vault path is reachable (directory exists).
176 pub fn is_vault_reachable(path: &Path) -> bool {
177 path.is_dir()
178 }
179
180 /// Best-effort path normalization without requiring the path to exist.
181 fn normalize_path(path: &Path) -> PathBuf {
182 path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
183 }
184
185 /// Default vault path: the platform data directory.
186 pub fn default_vault_path() -> PathBuf {
187 dirs::data_dir()
188 .unwrap_or_else(|| {
189 tracing::warn!("data_dir() unavailable ($HOME unset?), falling back to CWD");
190 PathBuf::from(".")
191 })
192 .join("audiofiles")
193 }
194
195 #[cfg(test)]
196 mod tests {
197 use super::*;
198
199 /// Helper: create a temporary registry file location and an empty registry.
200 fn temp_registry(dir: &Path) -> VaultRegistry {
201 VaultRegistry {
202 vaults: Vec::new(),
203 active: dir.to_path_buf(),
204 }
205 }
206
207 #[test]
208 fn create_vault_adds_entry_and_creates_dirs() {
209 let dir = tempfile::tempdir().unwrap();
210 let vault_path = dir.path().join("my_vault");
211 let mut reg = temp_registry(dir.path());
212
213 create_vault(&mut reg, "Test Vault", &vault_path).unwrap();
214
215 assert_eq!(reg.vaults.len(), 1);
216 assert_eq!(reg.vaults[0].name, "Test Vault");
217 assert_eq!(reg.vaults[0].path, vault_path);
218 assert!(vault_path.exists());
219 assert!(vault_path.join("samples").exists());
220 }
221
222 #[test]
223 fn create_vault_rejects_duplicate() {
224 let dir = tempfile::tempdir().unwrap();
225 let vault_path = dir.path().join("vault");
226 let mut reg = temp_registry(dir.path());
227
228 create_vault(&mut reg, "V1", &vault_path).unwrap();
229 let err = create_vault(&mut reg, "V2", &vault_path).unwrap_err();
230 assert!(matches!(err, VaultError::AlreadyExists(_)));
231 }
232
233 #[test]
234 fn add_existing_vault_validates_db() {
235 let dir = tempfile::tempdir().unwrap();
236 let vault_path = dir.path().join("existing");
237 fs::create_dir_all(&vault_path).unwrap();
238 let mut reg = temp_registry(dir.path());
239
240 // No audiofiles.db → error
241 let err = add_existing_vault(&mut reg, "Bad", &vault_path).unwrap_err();
242 assert!(matches!(err, VaultError::InvalidVault(_)));
243
244 // Create the db file
245 fs::write(vault_path.join("audiofiles.db"), b"").unwrap();
246 add_existing_vault(&mut reg, "Good", &vault_path).unwrap();
247 assert_eq!(reg.vaults.len(), 1);
248 }
249
250 #[test]
251 fn remove_vault_from_registry() {
252 let dir = tempfile::tempdir().unwrap();
253 let vault_path = dir.path().join("vault");
254 let mut reg = temp_registry(dir.path());
255
256 create_vault(&mut reg, "V", &vault_path).unwrap();
257 assert_eq!(reg.vaults.len(), 1);
258
259 remove_vault(&mut reg, &vault_path).unwrap();
260 assert!(reg.vaults.is_empty());
261 }
262
263 #[test]
264 fn remove_vault_not_found() {
265 let dir = tempfile::tempdir().unwrap();
266 let mut reg = temp_registry(dir.path());
267 let err = remove_vault(&mut reg, Path::new("/nonexistent")).unwrap_err();
268 assert!(matches!(err, VaultError::NotFound(_)));
269 }
270
271 #[test]
272 fn rename_vault_updates_name() {
273 let dir = tempfile::tempdir().unwrap();
274 let vault_path = dir.path().join("vault");
275 let mut reg = temp_registry(dir.path());
276
277 create_vault(&mut reg, "Old Name", &vault_path).unwrap();
278 rename_vault(&mut reg, &vault_path, "New Name").unwrap();
279 assert_eq!(reg.vaults[0].name, "New Name");
280 }
281
282 #[test]
283 fn rename_vault_not_found() {
284 let dir = tempfile::tempdir().unwrap();
285 let mut reg = temp_registry(dir.path());
286 let err = rename_vault(&mut reg, Path::new("/nope"), "X").unwrap_err();
287 assert!(matches!(err, VaultError::NotFound(_)));
288 }
289
290 #[test]
291 fn is_vault_reachable_checks_directory() {
292 let dir = tempfile::tempdir().unwrap();
293 assert!(is_vault_reachable(dir.path()));
294 assert!(!is_vault_reachable(Path::new("/nonexistent/vault/path")));
295 }
296
297 #[test]
298 fn save_and_load_roundtrip() {
299 // Use a custom registry path for testing by saving/loading manually
300 let dir = tempfile::tempdir().unwrap();
301 let vault_path = dir.path().join("vault_a");
302 let mut reg = temp_registry(&vault_path);
303 create_vault(&mut reg, "A", &vault_path).unwrap();
304 reg.active = vault_path.clone();
305
306 let json_path = dir.path().join("test_registry.json");
307 let json = serde_json::to_string_pretty(&reg).unwrap();
308 fs::write(&json_path, &json).unwrap();
309
310 let bytes = fs::read(&json_path).unwrap();
311 let loaded: VaultRegistry = serde_json::from_slice(&bytes).unwrap();
312 assert_eq!(loaded.vaults.len(), 1);
313 assert_eq!(loaded.vaults[0].name, "A");
314 assert_eq!(loaded.active, vault_path);
315 }
316
317 #[test]
318 fn crud_roundtrip() {
319 let dir = tempfile::tempdir().unwrap();
320 let mut reg = temp_registry(dir.path());
321
322 // Create two vaults
323 let v1 = dir.path().join("v1");
324 let v2 = dir.path().join("v2");
325 create_vault(&mut reg, "Vault 1", &v1).unwrap();
326 create_vault(&mut reg, "Vault 2", &v2).unwrap();
327 assert_eq!(reg.vaults.len(), 2);
328
329 // Rename
330 rename_vault(&mut reg, &v1, "Primary").unwrap();
331 assert_eq!(reg.vaults[0].name, "Primary");
332
333 // Remove
334 remove_vault(&mut reg, &v2).unwrap();
335 assert_eq!(reg.vaults.len(), 1);
336 assert_eq!(reg.vaults[0].name, "Primary");
337 }
338 }
339