1use a3s_box_core::error::{BoxError, Result};
8use a3s_box_core::volume::VolumeConfig;
9use std::collections::HashMap;
10use std::path::{Path, PathBuf};
11
12#[derive(Debug)]
14pub struct VolumeStore {
15 path: PathBuf,
17 volumes_dir: PathBuf,
19}
20
21#[derive(Debug, serde::Serialize, serde::Deserialize, Default)]
23struct VolumesFile {
24 volumes: HashMap<String, VolumeConfig>,
25}
26
27impl VolumeStore {
28 pub fn new(path: impl Into<PathBuf>, volumes_dir: impl Into<PathBuf>) -> Self {
30 Self {
31 path: path.into(),
32 volumes_dir: volumes_dir.into(),
33 }
34 }
35
36 pub fn default_path() -> Result<Self> {
38 let home = a3s_box_core::dirs_home();
39 Ok(Self::new(home.join("volumes.json"), home.join("volumes")))
40 }
41
42 pub fn load(&self) -> Result<HashMap<String, VolumeConfig>> {
44 if !self.path.exists() {
45 return Ok(HashMap::new());
46 }
47
48 let data = std::fs::read_to_string(&self.path).map_err(|e| {
49 BoxError::ConfigError(format!(
50 "failed to read volumes file {}: {}",
51 self.path.display(),
52 e
53 ))
54 })?;
55
56 let file: VolumesFile = serde_json::from_str(&data).map_err(|e| {
57 BoxError::SerializationError(format!("failed to parse volumes file: {}", e))
58 })?;
59
60 Ok(file.volumes)
61 }
62
63 pub fn save(&self, volumes: &HashMap<String, VolumeConfig>) -> Result<()> {
65 if let Some(parent) = self.path.parent() {
66 std::fs::create_dir_all(parent).map_err(|e| {
67 BoxError::ConfigError(format!(
68 "failed to create directory {}: {}",
69 parent.display(),
70 e
71 ))
72 })?;
73 }
74
75 let file = VolumesFile {
76 volumes: volumes.clone(),
77 };
78
79 let json = serde_json::to_string_pretty(&file).map_err(|e| {
80 BoxError::SerializationError(format!("failed to serialize volumes: {}", e))
81 })?;
82
83 let tmp_path = self.path.with_extension("json.tmp");
84 std::fs::write(&tmp_path, &json).map_err(|e| {
85 BoxError::ConfigError(format!(
86 "failed to write tmp file {}: {}",
87 tmp_path.display(),
88 e
89 ))
90 })?;
91
92 std::fs::rename(&tmp_path, &self.path).map_err(|e| {
93 BoxError::ConfigError(format!(
94 "failed to rename {} → {}: {}",
95 tmp_path.display(),
96 self.path.display(),
97 e
98 ))
99 })?;
100
101 Ok(())
102 }
103
104 fn with_write_lock<F, R>(&self, f: F) -> Result<R>
116 where
117 F: FnOnce(&mut HashMap<String, VolumeConfig>) -> Result<R>,
118 {
119 let _lock = crate::file_lock::FileLock::acquire(&self.path).map_err(|e| {
120 BoxError::ConfigError(format!(
121 "failed to lock volumes file {}: {e}",
122 self.path.display()
123 ))
124 })?;
125 let mut volumes = self.load()?;
126 let r = f(&mut volumes)?;
127 self.save(&volumes)?;
128 Ok(r)
129 }
130
131 pub fn get(&self, name: &str) -> Result<Option<VolumeConfig>> {
133 let volumes = self.load()?;
134 Ok(volumes.get(name).cloned())
135 }
136
137 pub fn create(&self, config: VolumeConfig) -> Result<VolumeConfig> {
143 self.with_write_lock(|volumes| {
144 if volumes.contains_key(&config.name) {
145 return Err(BoxError::ConfigError(format!(
146 "volume '{}' already exists",
147 config.name
148 )));
149 }
150 self.materialize(config, volumes)
151 })
152 }
153
154 pub fn get_or_create(&self, config: VolumeConfig) -> Result<VolumeConfig> {
158 self.with_write_lock(|volumes| {
159 if let Some(existing) = volumes.get(&config.name) {
160 return Ok(existing.clone());
161 }
162 self.materialize(config, volumes)
163 })
164 }
165
166 fn materialize(
169 &self,
170 mut config: VolumeConfig,
171 volumes: &mut HashMap<String, VolumeConfig>,
172 ) -> Result<VolumeConfig> {
173 let vol_dir = self.volumes_dir.join(&config.name);
174 std::fs::create_dir_all(&vol_dir).map_err(|e| {
175 BoxError::ConfigError(format!(
176 "failed to create volume directory {}: {}",
177 vol_dir.display(),
178 e
179 ))
180 })?;
181 config.mount_point = vol_dir.to_string_lossy().into_owned();
182 volumes.insert(config.name.clone(), config.clone());
183 Ok(config)
184 }
185
186 pub fn remove(&self, name: &str, force: bool) -> Result<VolumeConfig> {
188 let config = self.with_write_lock(|volumes| {
189 let config = volumes
190 .remove(name)
191 .ok_or_else(|| BoxError::ConfigError(format!("volume '{}' not found", name)))?;
192
193 if config.is_in_use() && !force {
194 volumes.insert(name.to_string(), config.clone());
196 return Err(BoxError::ConfigError(format!(
197 "volume '{}' is in use by {} box(es); use --force to remove",
198 name,
199 config.in_use_by.len()
200 )));
201 }
202 Ok(config)
203 })?;
204
205 let vol_dir = self.volumes_dir.join(name);
208 if vol_dir.exists() {
209 std::fs::remove_dir_all(&vol_dir).ok();
210 }
211
212 Ok(config)
213 }
214
215 pub fn list(&self) -> Result<Vec<VolumeConfig>> {
217 let volumes = self.load()?;
218 Ok(volumes.into_values().collect())
219 }
220
221 pub fn update(&self, config: &VolumeConfig) -> Result<()> {
227 self.with_write_lock(|volumes| {
228 if !volumes.contains_key(&config.name) {
229 return Err(BoxError::ConfigError(format!(
230 "volume '{}' not found",
231 config.name
232 )));
233 }
234 volumes.insert(config.name.clone(), config.clone());
235 Ok(())
236 })
237 }
238
239 pub fn modify<F>(&self, name: &str, f: F) -> Result<bool>
246 where
247 F: FnOnce(&mut VolumeConfig),
248 {
249 self.with_write_lock(|volumes| match volumes.get_mut(name) {
250 Some(config) => {
251 f(config);
252 Ok(true)
253 }
254 None => Ok(false),
255 })
256 }
257
258 pub fn prune(&self) -> Result<Vec<String>> {
260 let volumes = self.load()?;
261 let mut pruned = Vec::new();
262
263 for (name, config) in &volumes {
264 if !config.is_in_use() {
265 pruned.push(name.clone());
266 }
267 }
268
269 for name in &pruned {
270 self.remove(name, false).ok();
271 }
272
273 Ok(pruned)
274 }
275
276 pub fn volume_dir(&self, name: &str) -> PathBuf {
278 self.volumes_dir.join(name)
279 }
280
281 pub fn path(&self) -> &Path {
283 &self.path
284 }
285}
286
287impl a3s_box_core::traits::VolumeStoreBackend for VolumeStore {
288 fn get(&self, name: &str) -> Result<Option<VolumeConfig>> {
289 self.get(name)
290 }
291
292 fn create(&self, config: VolumeConfig) -> Result<VolumeConfig> {
293 self.create(config)
294 }
295
296 fn remove(&self, name: &str, force: bool) -> Result<VolumeConfig> {
297 self.remove(name, force)
298 }
299
300 fn list(&self) -> Result<Vec<VolumeConfig>> {
301 self.list()
302 }
303
304 fn update(&self, config: &VolumeConfig) -> Result<()> {
305 self.update(config)
306 }
307
308 fn prune(&self) -> Result<Vec<String>> {
309 self.prune()
310 }
311}
312
313#[cfg(test)]
314mod tests {
315 use super::*;
316
317 fn temp_store() -> (tempfile::TempDir, VolumeStore) {
318 let dir = tempfile::tempdir().unwrap();
319 let store = VolumeStore::new(dir.path().join("volumes.json"), dir.path().join("volumes"));
320 (dir, store)
321 }
322
323 #[test]
324 fn test_load_empty() {
325 let (_dir, store) = temp_store();
326 let volumes = store.load().unwrap();
327 assert!(volumes.is_empty());
328 }
329
330 #[test]
331 fn test_create_and_load() {
332 let (_dir, store) = temp_store();
333 let vol = VolumeConfig::new("mydata", "");
334 store.create(vol).unwrap();
335
336 let volumes = store.load().unwrap();
337 assert_eq!(volumes.len(), 1);
338 assert!(volumes.contains_key("mydata"));
339 }
340
341 #[test]
342 fn test_create_sets_mount_point() {
343 let (_dir, store) = temp_store();
344 let vol = VolumeConfig::new("mydata", "");
345 let created = store.create(vol).unwrap();
346
347 assert!(created.mount_point.contains("mydata"));
348 assert!(PathBuf::from(&created.mount_point).exists());
349 }
350
351 #[test]
352 fn test_create_duplicate() {
353 let (_dir, store) = temp_store();
354 let v1 = VolumeConfig::new("mydata", "");
355 let v2 = VolumeConfig::new("mydata", "");
356
357 store.create(v1).unwrap();
358 assert!(store.create(v2).is_err());
359 }
360
361 #[test]
362 fn test_get_existing() {
363 let (_dir, store) = temp_store();
364 store.create(VolumeConfig::new("mydata", "")).unwrap();
365
366 let found = store.get("mydata").unwrap();
367 assert!(found.is_some());
368 assert_eq!(found.unwrap().name, "mydata");
369 }
370
371 #[test]
372 fn test_get_nonexistent() {
373 let (_dir, store) = temp_store();
374 let found = store.get("nope").unwrap();
375 assert!(found.is_none());
376 }
377
378 #[test]
379 fn test_remove() {
380 let (_dir, store) = temp_store();
381 store.create(VolumeConfig::new("mydata", "")).unwrap();
382
383 let removed = store.remove("mydata", false).unwrap();
384 assert_eq!(removed.name, "mydata");
385
386 let volumes = store.load().unwrap();
387 assert!(volumes.is_empty());
388 }
389
390 #[test]
391 fn test_remove_nonexistent() {
392 let (_dir, store) = temp_store();
393 assert!(store.remove("nope", false).is_err());
394 }
395
396 #[test]
397 fn test_remove_in_use_fails() {
398 let (_dir, store) = temp_store();
399 let mut vol = VolumeConfig::new("mydata", "");
400 vol.attach("box-1");
401 let created = store.create(VolumeConfig::new("mydata", "")).unwrap();
403 let mut updated = created;
404 updated.attach("box-1");
405 store.update(&updated).unwrap();
406
407 assert!(store.remove("mydata", false).is_err());
408 }
409
410 #[test]
411 fn test_remove_in_use_force() {
412 let (_dir, store) = temp_store();
413 let created = store.create(VolumeConfig::new("mydata", "")).unwrap();
414 let mut updated = created;
415 updated.attach("box-1");
416 store.update(&updated).unwrap();
417
418 let removed = store.remove("mydata", true).unwrap();
419 assert_eq!(removed.name, "mydata");
420 }
421
422 #[test]
423 fn test_list() {
424 let (_dir, store) = temp_store();
425 store.create(VolumeConfig::new("vol1", "")).unwrap();
426 store.create(VolumeConfig::new("vol2", "")).unwrap();
427
428 let list = store.list().unwrap();
429 assert_eq!(list.len(), 2);
430 }
431
432 #[test]
433 fn test_update() {
434 let (_dir, store) = temp_store();
435 let created = store.create(VolumeConfig::new("mydata", "")).unwrap();
436
437 let mut updated = created;
438 updated.attach("box-1");
439 store.update(&updated).unwrap();
440
441 let loaded = store.get("mydata").unwrap().unwrap();
442 assert_eq!(loaded.in_use_by, vec!["box-1"]);
443 }
444
445 #[test]
446 fn test_update_nonexistent() {
447 let (_dir, store) = temp_store();
448 let vol = VolumeConfig::new("nope", "/tmp");
449 assert!(store.update(&vol).is_err());
450 }
451
452 #[test]
453 fn test_prune() {
454 let (_dir, store) = temp_store();
455 store.create(VolumeConfig::new("unused1", "")).unwrap();
456 store.create(VolumeConfig::new("unused2", "")).unwrap();
457
458 let created = store.create(VolumeConfig::new("in_use", "")).unwrap();
459 let mut updated = created;
460 updated.attach("box-1");
461 store.update(&updated).unwrap();
462
463 let pruned = store.prune().unwrap();
464 assert_eq!(pruned.len(), 2);
465 assert!(pruned.contains(&"unused1".to_string()));
466 assert!(pruned.contains(&"unused2".to_string()));
467
468 let remaining = store.list().unwrap();
470 assert_eq!(remaining.len(), 1);
471 assert_eq!(remaining[0].name, "in_use");
472 }
473
474 #[test]
475 fn test_atomic_write() {
476 let (_dir, store) = temp_store();
477 store.create(VolumeConfig::new("mydata", "")).unwrap();
478
479 let data = std::fs::read_to_string(store.path()).unwrap();
480 let _: serde_json::Value = serde_json::from_str(&data).unwrap();
481
482 let tmp = store.path().with_extension("json.tmp");
483 assert!(!tmp.exists());
484 }
485
486 #[test]
487 fn test_creates_parent_directory() {
488 let dir = tempfile::tempdir().unwrap();
489 let store = VolumeStore::new(
490 dir.path().join("subdir").join("volumes.json"),
491 dir.path().join("subdir").join("volumes"),
492 );
493
494 store.create(VolumeConfig::new("mydata", "")).unwrap();
495 assert!(store.path().exists());
496 }
497
498 #[test]
499 fn test_remove_cleans_up_directory() {
500 let (_dir, store) = temp_store();
501 let created = store.create(VolumeConfig::new("mydata", "")).unwrap();
502 let vol_dir = PathBuf::from(&created.mount_point);
503 assert!(vol_dir.exists());
504
505 store.remove("mydata", false).unwrap();
506 assert!(!vol_dir.exists());
507 }
508
509 #[test]
510 fn test_get_or_create_is_idempotent() {
511 let (_dir, store) = temp_store();
512 let first = store
513 .get_or_create(VolumeConfig::new("shared", ""))
514 .unwrap();
515 let second = store
516 .get_or_create(VolumeConfig::new("shared", ""))
517 .unwrap();
518 assert_eq!(first.mount_point, second.mount_point);
519 assert_eq!(store.list().unwrap().len(), 1);
520 }
521
522 #[test]
523 fn test_modify_missing_returns_false() {
524 let (_dir, store) = temp_store();
525 assert!(!store.modify("nope", |c| c.attach("box-1")).unwrap());
526 }
527
528 #[test]
532 fn concurrent_attaches_accumulate_without_lost_update() {
533 use std::sync::Arc;
534 use std::thread;
535
536 let dir = tempfile::tempdir().unwrap();
537 let store = Arc::new(VolumeStore::new(
538 dir.path().join("volumes.json"),
539 dir.path().join("volumes"),
540 ));
541 store.create(VolumeConfig::new("shared", "")).unwrap();
542
543 let n = 16;
544 let handles: Vec<_> = (0..n)
545 .map(|i| {
546 let store = Arc::clone(&store);
547 thread::spawn(move || {
548 store
549 .modify("shared", |c| c.attach(&format!("box-{i}")))
550 .unwrap();
551 })
552 })
553 .collect();
554 for h in handles {
555 h.join().unwrap();
556 }
557
558 let cfg = store.get("shared").unwrap().unwrap();
559 assert_eq!(
560 cfg.in_use_by.len(),
561 n,
562 "every concurrent attach must persist (no lost update): {:?}",
563 cfg.in_use_by
564 );
565 for i in 0..n {
566 assert!(cfg.in_use_by.contains(&format!("box-{i}")));
567 }
568 }
569
570 #[test]
571 fn concurrent_creates_persist_every_volume() {
572 use std::sync::Arc;
573 use std::thread;
574
575 let dir = tempfile::tempdir().unwrap();
576 let store = Arc::new(VolumeStore::new(
577 dir.path().join("volumes.json"),
578 dir.path().join("volumes"),
579 ));
580
581 let n = 16;
582 let handles: Vec<_> = (0..n)
583 .map(|i| {
584 let store = Arc::clone(&store);
585 thread::spawn(move || {
586 store
587 .create(VolumeConfig::new(&format!("vol-{i}"), ""))
588 .unwrap();
589 })
590 })
591 .collect();
592 for h in handles {
593 h.join().unwrap();
594 }
595
596 assert_eq!(
597 store.list().unwrap().len(),
598 n,
599 "every concurrent create must persist (no lost update)"
600 );
601 }
602}