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 = match serde_json::from_str(&data) {
60 Ok(f) => f,
61 Err(e) => {
62 let preserved = crate::store_io::quarantine_label(&self.path);
63 tracing::warn!(
64 "volumes file {} is corrupt ({e}); preserved a copy at {preserved} \
65 and started from an empty volume set",
66 self.path.display(),
67 );
68 return Ok(HashMap::new());
69 }
70 };
71
72 Ok(file.volumes)
73 }
74
75 pub fn save(&self, volumes: &HashMap<String, VolumeConfig>) -> Result<()> {
77 if let Some(parent) = self.path.parent() {
78 std::fs::create_dir_all(parent).map_err(|e| {
79 BoxError::ConfigError(format!(
80 "failed to create directory {}: {}",
81 parent.display(),
82 e
83 ))
84 })?;
85 }
86
87 let file = VolumesFile {
88 volumes: volumes.clone(),
89 };
90
91 let json = serde_json::to_string_pretty(&file).map_err(|e| {
92 BoxError::SerializationError(format!("failed to serialize volumes: {}", e))
93 })?;
94
95 let tmp_path = self.path.with_extension("json.tmp");
96 std::fs::write(&tmp_path, &json).map_err(|e| {
97 BoxError::ConfigError(format!(
98 "failed to write tmp file {}: {}",
99 tmp_path.display(),
100 e
101 ))
102 })?;
103
104 std::fs::rename(&tmp_path, &self.path).map_err(|e| {
105 BoxError::ConfigError(format!(
106 "failed to rename {} → {}: {}",
107 tmp_path.display(),
108 self.path.display(),
109 e
110 ))
111 })?;
112
113 Ok(())
114 }
115
116 fn with_write_lock<F, R>(&self, f: F) -> Result<R>
128 where
129 F: FnOnce(&mut HashMap<String, VolumeConfig>) -> Result<R>,
130 {
131 let _lock = crate::file_lock::FileLock::acquire(&self.path).map_err(|e| {
132 BoxError::ConfigError(format!(
133 "failed to lock volumes file {}: {e}",
134 self.path.display()
135 ))
136 })?;
137 let mut volumes = self.load()?;
138 let r = f(&mut volumes)?;
139 self.save(&volumes)?;
140 Ok(r)
141 }
142
143 pub fn get(&self, name: &str) -> Result<Option<VolumeConfig>> {
145 let volumes = self.load()?;
146 Ok(volumes.get(name).cloned())
147 }
148
149 pub fn create(&self, config: VolumeConfig) -> Result<VolumeConfig> {
155 self.with_write_lock(|volumes| {
156 if volumes.contains_key(&config.name) {
157 return Err(BoxError::ConfigError(format!(
158 "volume '{}' already exists",
159 config.name
160 )));
161 }
162 self.materialize(config, volumes)
163 })
164 }
165
166 pub fn get_or_create(&self, config: VolumeConfig) -> Result<VolumeConfig> {
170 self.with_write_lock(|volumes| {
171 if let Some(existing) = volumes.get(&config.name) {
172 return Ok(existing.clone());
173 }
174 self.materialize(config, volumes)
175 })
176 }
177
178 fn materialize(
181 &self,
182 mut config: VolumeConfig,
183 volumes: &mut HashMap<String, VolumeConfig>,
184 ) -> Result<VolumeConfig> {
185 let vol_dir = self.volumes_dir.join(&config.name);
186 std::fs::create_dir_all(&vol_dir).map_err(|e| {
187 BoxError::ConfigError(format!(
188 "failed to create volume directory {}: {}",
189 vol_dir.display(),
190 e
191 ))
192 })?;
193 config.mount_point = vol_dir.to_string_lossy().into_owned();
194 volumes.insert(config.name.clone(), config.clone());
195 Ok(config)
196 }
197
198 pub fn remove(&self, name: &str, force: bool) -> Result<VolumeConfig> {
200 let config = self.with_write_lock(|volumes| {
201 let config = volumes
202 .remove(name)
203 .ok_or_else(|| BoxError::ConfigError(format!("volume '{}' not found", name)))?;
204
205 if config.is_in_use() && !force {
206 volumes.insert(name.to_string(), config.clone());
208 return Err(BoxError::ConfigError(format!(
209 "volume '{}' is in use by {} box(es); use --force to remove",
210 name,
211 config.in_use_by.len()
212 )));
213 }
214 Ok(config)
215 })?;
216
217 let vol_dir = self.volumes_dir.join(name);
220 if vol_dir.exists() {
221 std::fs::remove_dir_all(&vol_dir).ok();
222 }
223
224 Ok(config)
225 }
226
227 pub fn list(&self) -> Result<Vec<VolumeConfig>> {
229 let volumes = self.load()?;
230 Ok(volumes.into_values().collect())
231 }
232
233 pub fn update(&self, config: &VolumeConfig) -> Result<()> {
239 self.with_write_lock(|volumes| {
240 if !volumes.contains_key(&config.name) {
241 return Err(BoxError::ConfigError(format!(
242 "volume '{}' not found",
243 config.name
244 )));
245 }
246 volumes.insert(config.name.clone(), config.clone());
247 Ok(())
248 })
249 }
250
251 pub fn modify<F>(&self, name: &str, f: F) -> Result<bool>
258 where
259 F: FnOnce(&mut VolumeConfig),
260 {
261 self.with_write_lock(|volumes| match volumes.get_mut(name) {
262 Some(config) => {
263 f(config);
264 Ok(true)
265 }
266 None => Ok(false),
267 })
268 }
269
270 pub fn prune(&self) -> Result<Vec<String>> {
272 let volumes = self.load()?;
273 let mut pruned = Vec::new();
274
275 for (name, config) in &volumes {
276 if !config.is_in_use() {
277 pruned.push(name.clone());
278 }
279 }
280
281 for name in &pruned {
282 self.remove(name, false).ok();
283 }
284
285 Ok(pruned)
286 }
287
288 pub fn volume_dir(&self, name: &str) -> PathBuf {
290 self.volumes_dir.join(name)
291 }
292
293 pub fn path(&self) -> &Path {
295 &self.path
296 }
297}
298
299impl a3s_box_core::traits::VolumeStoreBackend for VolumeStore {
300 fn get(&self, name: &str) -> Result<Option<VolumeConfig>> {
301 self.get(name)
302 }
303
304 fn create(&self, config: VolumeConfig) -> Result<VolumeConfig> {
305 self.create(config)
306 }
307
308 fn remove(&self, name: &str, force: bool) -> Result<VolumeConfig> {
309 self.remove(name, force)
310 }
311
312 fn list(&self) -> Result<Vec<VolumeConfig>> {
313 self.list()
314 }
315
316 fn update(&self, config: &VolumeConfig) -> Result<()> {
317 self.update(config)
318 }
319
320 fn prune(&self) -> Result<Vec<String>> {
321 self.prune()
322 }
323}
324
325#[cfg(test)]
326mod tests {
327 use super::*;
328
329 fn temp_store() -> (tempfile::TempDir, VolumeStore) {
330 let dir = tempfile::tempdir().unwrap();
331 let store = VolumeStore::new(dir.path().join("volumes.json"), dir.path().join("volumes"));
332 (dir, store)
333 }
334
335 #[test]
336 fn test_load_empty() {
337 let (_dir, store) = temp_store();
338 let volumes = store.load().unwrap();
339 assert!(volumes.is_empty());
340 }
341
342 #[test]
343 fn test_create_and_load() {
344 let (_dir, store) = temp_store();
345 let vol = VolumeConfig::new("mydata", "");
346 store.create(vol).unwrap();
347
348 let volumes = store.load().unwrap();
349 assert_eq!(volumes.len(), 1);
350 assert!(volumes.contains_key("mydata"));
351 }
352
353 #[test]
354 fn test_create_sets_mount_point() {
355 let (_dir, store) = temp_store();
356 let vol = VolumeConfig::new("mydata", "");
357 let created = store.create(vol).unwrap();
358
359 assert!(created.mount_point.contains("mydata"));
360 assert!(PathBuf::from(&created.mount_point).exists());
361 }
362
363 #[test]
364 fn test_create_duplicate() {
365 let (_dir, store) = temp_store();
366 let v1 = VolumeConfig::new("mydata", "");
367 let v2 = VolumeConfig::new("mydata", "");
368
369 store.create(v1).unwrap();
370 assert!(store.create(v2).is_err());
371 }
372
373 #[test]
374 fn test_get_existing() {
375 let (_dir, store) = temp_store();
376 store.create(VolumeConfig::new("mydata", "")).unwrap();
377
378 let found = store.get("mydata").unwrap();
379 assert!(found.is_some());
380 assert_eq!(found.unwrap().name, "mydata");
381 }
382
383 #[test]
384 fn test_get_nonexistent() {
385 let (_dir, store) = temp_store();
386 let found = store.get("nope").unwrap();
387 assert!(found.is_none());
388 }
389
390 #[test]
391 fn test_remove() {
392 let (_dir, store) = temp_store();
393 store.create(VolumeConfig::new("mydata", "")).unwrap();
394
395 let removed = store.remove("mydata", false).unwrap();
396 assert_eq!(removed.name, "mydata");
397
398 let volumes = store.load().unwrap();
399 assert!(volumes.is_empty());
400 }
401
402 #[test]
403 fn test_remove_nonexistent() {
404 let (_dir, store) = temp_store();
405 assert!(store.remove("nope", false).is_err());
406 }
407
408 #[test]
409 fn test_remove_in_use_fails() {
410 let (_dir, store) = temp_store();
411 let mut vol = VolumeConfig::new("mydata", "");
412 vol.attach("box-1");
413 let created = store.create(VolumeConfig::new("mydata", "")).unwrap();
415 let mut updated = created;
416 updated.attach("box-1");
417 store.update(&updated).unwrap();
418
419 assert!(store.remove("mydata", false).is_err());
420 }
421
422 #[test]
423 fn test_remove_in_use_force() {
424 let (_dir, store) = temp_store();
425 let created = store.create(VolumeConfig::new("mydata", "")).unwrap();
426 let mut updated = created;
427 updated.attach("box-1");
428 store.update(&updated).unwrap();
429
430 let removed = store.remove("mydata", true).unwrap();
431 assert_eq!(removed.name, "mydata");
432 }
433
434 #[test]
435 fn test_list() {
436 let (_dir, store) = temp_store();
437 store.create(VolumeConfig::new("vol1", "")).unwrap();
438 store.create(VolumeConfig::new("vol2", "")).unwrap();
439
440 let list = store.list().unwrap();
441 assert_eq!(list.len(), 2);
442 }
443
444 #[test]
445 fn test_update() {
446 let (_dir, store) = temp_store();
447 let created = store.create(VolumeConfig::new("mydata", "")).unwrap();
448
449 let mut updated = created;
450 updated.attach("box-1");
451 store.update(&updated).unwrap();
452
453 let loaded = store.get("mydata").unwrap().unwrap();
454 assert_eq!(loaded.in_use_by, vec!["box-1"]);
455 }
456
457 #[test]
458 fn test_update_nonexistent() {
459 let (_dir, store) = temp_store();
460 let vol = VolumeConfig::new("nope", "/tmp");
461 assert!(store.update(&vol).is_err());
462 }
463
464 #[test]
465 fn test_prune() {
466 let (_dir, store) = temp_store();
467 store.create(VolumeConfig::new("unused1", "")).unwrap();
468 store.create(VolumeConfig::new("unused2", "")).unwrap();
469
470 let created = store.create(VolumeConfig::new("in_use", "")).unwrap();
471 let mut updated = created;
472 updated.attach("box-1");
473 store.update(&updated).unwrap();
474
475 let pruned = store.prune().unwrap();
476 assert_eq!(pruned.len(), 2);
477 assert!(pruned.contains(&"unused1".to_string()));
478 assert!(pruned.contains(&"unused2".to_string()));
479
480 let remaining = store.list().unwrap();
482 assert_eq!(remaining.len(), 1);
483 assert_eq!(remaining[0].name, "in_use");
484 }
485
486 #[test]
487 fn test_atomic_write() {
488 let (_dir, store) = temp_store();
489 store.create(VolumeConfig::new("mydata", "")).unwrap();
490
491 let data = std::fs::read_to_string(store.path()).unwrap();
492 let _: serde_json::Value = serde_json::from_str(&data).unwrap();
493
494 let tmp = store.path().with_extension("json.tmp");
495 assert!(!tmp.exists());
496 }
497
498 #[test]
499 fn test_creates_parent_directory() {
500 let dir = tempfile::tempdir().unwrap();
501 let store = VolumeStore::new(
502 dir.path().join("subdir").join("volumes.json"),
503 dir.path().join("subdir").join("volumes"),
504 );
505
506 store.create(VolumeConfig::new("mydata", "")).unwrap();
507 assert!(store.path().exists());
508 }
509
510 #[test]
511 fn test_remove_cleans_up_directory() {
512 let (_dir, store) = temp_store();
513 let created = store.create(VolumeConfig::new("mydata", "")).unwrap();
514 let vol_dir = PathBuf::from(&created.mount_point);
515 assert!(vol_dir.exists());
516
517 store.remove("mydata", false).unwrap();
518 assert!(!vol_dir.exists());
519 }
520
521 #[test]
522 fn test_get_or_create_is_idempotent() {
523 let (_dir, store) = temp_store();
524 let first = store
525 .get_or_create(VolumeConfig::new("shared", ""))
526 .unwrap();
527 let second = store
528 .get_or_create(VolumeConfig::new("shared", ""))
529 .unwrap();
530 assert_eq!(first.mount_point, second.mount_point);
531 assert_eq!(store.list().unwrap().len(), 1);
532 }
533
534 #[test]
535 fn test_get_or_create_preserves_existing_config() {
536 let (_dir, store) = temp_store();
537 let created = store
538 .get_or_create(VolumeConfig::with_size_limit("shared", "", 4096))
539 .unwrap();
540 let mut updated = created;
541 updated.attach("box-1");
542 store.update(&updated).unwrap();
543
544 let reused = store
545 .get_or_create(VolumeConfig::with_size_limit("shared", "/ignored", 8192))
546 .unwrap();
547
548 assert_eq!(reused.size_limit, 4096);
549 assert_eq!(reused.in_use_by, vec!["box-1"]);
550 assert!(PathBuf::from(&reused.mount_point).exists());
551 }
552
553 #[test]
554 fn test_modify_missing_returns_false() {
555 let (_dir, store) = temp_store();
556 assert!(!store.modify("nope", |c| c.attach("box-1")).unwrap());
557 }
558
559 #[test]
560 fn test_modify_existing_returns_true_and_persists() {
561 let (_dir, store) = temp_store();
562 store.create(VolumeConfig::new("shared", "")).unwrap();
563
564 let modified = store.modify("shared", |c| c.attach("box-1")).unwrap();
565
566 assert!(modified);
567 assert_eq!(
568 store.get("shared").unwrap().unwrap().in_use_by,
569 vec!["box-1"]
570 );
571 }
572
573 #[test]
574 fn test_volume_dir_joins_name_under_base_directory() {
575 let (dir, store) = temp_store();
576 assert_eq!(
577 store.volume_dir("mydata"),
578 dir.path().join("volumes").join("mydata")
579 );
580 }
581
582 #[test]
583 fn test_volume_store_backend_trait_dispatch() {
584 let (_dir, store) = temp_store();
585 let backend: &dyn a3s_box_core::traits::VolumeStoreBackend = &store;
586
587 let created = backend.create(VolumeConfig::new("shared", "")).unwrap();
588 assert_eq!(created.name, "shared");
589 assert!(backend.get("shared").unwrap().is_some());
590
591 let mut updated = created;
592 updated.attach("box-1");
593 backend.update(&updated).unwrap();
594 assert_eq!(backend.list().unwrap().len(), 1);
595
596 let err = backend.remove("shared", false).unwrap_err().to_string();
597 assert!(err.contains("is in use"));
598
599 let removed = backend.remove("shared", true).unwrap();
600 assert_eq!(removed.name, "shared");
601 assert!(backend.list().unwrap().is_empty());
602 }
603
604 #[test]
608 fn concurrent_attaches_accumulate_without_lost_update() {
609 use std::sync::Arc;
610 use std::thread;
611
612 let dir = tempfile::tempdir().unwrap();
613 let store = Arc::new(VolumeStore::new(
614 dir.path().join("volumes.json"),
615 dir.path().join("volumes"),
616 ));
617 store.create(VolumeConfig::new("shared", "")).unwrap();
618
619 let n = 16;
620 let handles: Vec<_> = (0..n)
621 .map(|i| {
622 let store = Arc::clone(&store);
623 thread::spawn(move || {
624 store
625 .modify("shared", |c| c.attach(&format!("box-{i}")))
626 .unwrap();
627 })
628 })
629 .collect();
630 for h in handles {
631 h.join().unwrap();
632 }
633
634 let cfg = store.get("shared").unwrap().unwrap();
635 assert_eq!(
636 cfg.in_use_by.len(),
637 n,
638 "every concurrent attach must persist (no lost update): {:?}",
639 cfg.in_use_by
640 );
641 for i in 0..n {
642 assert!(cfg.in_use_by.contains(&format!("box-{i}")));
643 }
644 }
645
646 #[test]
647 fn concurrent_creates_persist_every_volume() {
648 use std::sync::Arc;
649 use std::thread;
650
651 let dir = tempfile::tempdir().unwrap();
652 let store = Arc::new(VolumeStore::new(
653 dir.path().join("volumes.json"),
654 dir.path().join("volumes"),
655 ));
656
657 let n = 16;
658 let handles: Vec<_> = (0..n)
659 .map(|i| {
660 let store = Arc::clone(&store);
661 thread::spawn(move || {
662 store
663 .create(VolumeConfig::new(&format!("vol-{i}"), ""))
664 .unwrap();
665 })
666 })
667 .collect();
668 for h in handles {
669 h.join().unwrap();
670 }
671
672 assert_eq!(
673 store.list().unwrap().len(),
674 n,
675 "every concurrent create must persist (no lost update)"
676 );
677 }
678
679 #[test]
680 fn corrupt_volumes_file_is_quarantined_not_fatal() {
681 let dir = tempfile::tempdir().unwrap();
682 let path = dir.path().join("volumes.json");
683 std::fs::write(&path, "{ not valid json").unwrap();
684 let store = VolumeStore::new(path.clone(), dir.path().join("volumes"));
685
686 assert!(store.load().unwrap().is_empty());
688 let quarantined = std::fs::read_dir(dir.path())
689 .unwrap()
690 .filter_map(|e| e.ok())
691 .any(|e| {
692 e.file_name()
693 .to_string_lossy()
694 .contains("volumes.json.corrupt-")
695 });
696 assert!(quarantined, "corrupt volumes.json must be quarantined");
697 }
698}