use a3s_box_core::error::{BoxError, Result};
use a3s_box_core::volume::VolumeConfig;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
#[derive(Debug)]
pub struct VolumeStore {
path: PathBuf,
volumes_dir: PathBuf,
}
#[derive(Debug, serde::Serialize, serde::Deserialize, Default)]
struct VolumesFile {
volumes: HashMap<String, VolumeConfig>,
}
impl VolumeStore {
pub fn new(path: impl Into<PathBuf>, volumes_dir: impl Into<PathBuf>) -> Self {
Self {
path: path.into(),
volumes_dir: volumes_dir.into(),
}
}
pub fn default_path() -> Result<Self> {
let home = a3s_box_core::dirs_home();
Ok(Self::new(home.join("volumes.json"), home.join("volumes")))
}
pub fn load(&self) -> Result<HashMap<String, VolumeConfig>> {
if !self.path.exists() {
return Ok(HashMap::new());
}
let data = std::fs::read_to_string(&self.path).map_err(|e| {
BoxError::ConfigError(format!(
"failed to read volumes file {}: {}",
self.path.display(),
e
))
})?;
let file: VolumesFile = match serde_json::from_str(&data) {
Ok(f) => f,
Err(e) => {
let preserved = crate::store_io::quarantine_label(&self.path);
tracing::warn!(
"volumes file {} is corrupt ({e}); preserved a copy at {preserved} \
and started from an empty volume set",
self.path.display(),
);
return Ok(HashMap::new());
}
};
Ok(file.volumes)
}
pub fn save(&self, volumes: &HashMap<String, VolumeConfig>) -> Result<()> {
if let Some(parent) = self.path.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
BoxError::ConfigError(format!(
"failed to create directory {}: {}",
parent.display(),
e
))
})?;
}
let file = VolumesFile {
volumes: volumes.clone(),
};
let json = serde_json::to_string_pretty(&file).map_err(|e| {
BoxError::SerializationError(format!("failed to serialize volumes: {}", e))
})?;
let tmp_path = self.path.with_extension("json.tmp");
std::fs::write(&tmp_path, &json).map_err(|e| {
BoxError::ConfigError(format!(
"failed to write tmp file {}: {}",
tmp_path.display(),
e
))
})?;
std::fs::rename(&tmp_path, &self.path).map_err(|e| {
BoxError::ConfigError(format!(
"failed to rename {} → {}: {}",
tmp_path.display(),
self.path.display(),
e
))
})?;
Ok(())
}
fn with_write_lock<F, R>(&self, f: F) -> Result<R>
where
F: FnOnce(&mut HashMap<String, VolumeConfig>) -> Result<R>,
{
let _lock = crate::file_lock::FileLock::acquire(&self.path).map_err(|e| {
BoxError::ConfigError(format!(
"failed to lock volumes file {}: {e}",
self.path.display()
))
})?;
let mut volumes = self.load()?;
let r = f(&mut volumes)?;
self.save(&volumes)?;
Ok(r)
}
pub fn get(&self, name: &str) -> Result<Option<VolumeConfig>> {
let volumes = self.load()?;
Ok(volumes.get(name).cloned())
}
pub fn create(&self, config: VolumeConfig) -> Result<VolumeConfig> {
self.with_write_lock(|volumes| {
if volumes.contains_key(&config.name) {
return Err(BoxError::ConfigError(format!(
"volume '{}' already exists",
config.name
)));
}
self.materialize(config, volumes)
})
}
pub fn get_or_create(&self, config: VolumeConfig) -> Result<VolumeConfig> {
self.with_write_lock(|volumes| {
if let Some(existing) = volumes.get(&config.name) {
return Ok(existing.clone());
}
self.materialize(config, volumes)
})
}
fn materialize(
&self,
mut config: VolumeConfig,
volumes: &mut HashMap<String, VolumeConfig>,
) -> Result<VolumeConfig> {
let vol_dir = self.volumes_dir.join(&config.name);
std::fs::create_dir_all(&vol_dir).map_err(|e| {
BoxError::ConfigError(format!(
"failed to create volume directory {}: {}",
vol_dir.display(),
e
))
})?;
config.mount_point = vol_dir.to_string_lossy().into_owned();
volumes.insert(config.name.clone(), config.clone());
Ok(config)
}
pub fn remove(&self, name: &str, force: bool) -> Result<VolumeConfig> {
let config = self.with_write_lock(|volumes| {
let config = volumes
.remove(name)
.ok_or_else(|| BoxError::ConfigError(format!("volume '{}' not found", name)))?;
if config.is_in_use() && !force {
volumes.insert(name.to_string(), config.clone());
return Err(BoxError::ConfigError(format!(
"volume '{}' is in use by {} box(es); use --force to remove",
name,
config.in_use_by.len()
)));
}
Ok(config)
})?;
let vol_dir = self.volumes_dir.join(name);
if vol_dir.exists() {
std::fs::remove_dir_all(&vol_dir).ok();
}
Ok(config)
}
pub fn list(&self) -> Result<Vec<VolumeConfig>> {
let volumes = self.load()?;
Ok(volumes.into_values().collect())
}
pub fn update(&self, config: &VolumeConfig) -> Result<()> {
self.with_write_lock(|volumes| {
if !volumes.contains_key(&config.name) {
return Err(BoxError::ConfigError(format!(
"volume '{}' not found",
config.name
)));
}
volumes.insert(config.name.clone(), config.clone());
Ok(())
})
}
pub fn modify<F>(&self, name: &str, f: F) -> Result<bool>
where
F: FnOnce(&mut VolumeConfig),
{
self.with_write_lock(|volumes| match volumes.get_mut(name) {
Some(config) => {
f(config);
Ok(true)
}
None => Ok(false),
})
}
pub fn prune(&self) -> Result<Vec<String>> {
let volumes = self.load()?;
let mut pruned = Vec::new();
for (name, config) in &volumes {
if !config.is_in_use() {
pruned.push(name.clone());
}
}
for name in &pruned {
self.remove(name, false).ok();
}
Ok(pruned)
}
pub fn volume_dir(&self, name: &str) -> PathBuf {
self.volumes_dir.join(name)
}
pub fn path(&self) -> &Path {
&self.path
}
}
impl a3s_box_core::traits::VolumeStoreBackend for VolumeStore {
fn get(&self, name: &str) -> Result<Option<VolumeConfig>> {
self.get(name)
}
fn create(&self, config: VolumeConfig) -> Result<VolumeConfig> {
self.create(config)
}
fn remove(&self, name: &str, force: bool) -> Result<VolumeConfig> {
self.remove(name, force)
}
fn list(&self) -> Result<Vec<VolumeConfig>> {
self.list()
}
fn update(&self, config: &VolumeConfig) -> Result<()> {
self.update(config)
}
fn prune(&self) -> Result<Vec<String>> {
self.prune()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_store() -> (tempfile::TempDir, VolumeStore) {
let dir = tempfile::tempdir().unwrap();
let store = VolumeStore::new(dir.path().join("volumes.json"), dir.path().join("volumes"));
(dir, store)
}
#[test]
fn test_load_empty() {
let (_dir, store) = temp_store();
let volumes = store.load().unwrap();
assert!(volumes.is_empty());
}
#[test]
fn test_create_and_load() {
let (_dir, store) = temp_store();
let vol = VolumeConfig::new("mydata", "");
store.create(vol).unwrap();
let volumes = store.load().unwrap();
assert_eq!(volumes.len(), 1);
assert!(volumes.contains_key("mydata"));
}
#[test]
fn test_create_sets_mount_point() {
let (_dir, store) = temp_store();
let vol = VolumeConfig::new("mydata", "");
let created = store.create(vol).unwrap();
assert!(created.mount_point.contains("mydata"));
assert!(PathBuf::from(&created.mount_point).exists());
}
#[test]
fn test_create_duplicate() {
let (_dir, store) = temp_store();
let v1 = VolumeConfig::new("mydata", "");
let v2 = VolumeConfig::new("mydata", "");
store.create(v1).unwrap();
assert!(store.create(v2).is_err());
}
#[test]
fn test_get_existing() {
let (_dir, store) = temp_store();
store.create(VolumeConfig::new("mydata", "")).unwrap();
let found = store.get("mydata").unwrap();
assert!(found.is_some());
assert_eq!(found.unwrap().name, "mydata");
}
#[test]
fn test_get_nonexistent() {
let (_dir, store) = temp_store();
let found = store.get("nope").unwrap();
assert!(found.is_none());
}
#[test]
fn test_remove() {
let (_dir, store) = temp_store();
store.create(VolumeConfig::new("mydata", "")).unwrap();
let removed = store.remove("mydata", false).unwrap();
assert_eq!(removed.name, "mydata");
let volumes = store.load().unwrap();
assert!(volumes.is_empty());
}
#[test]
fn test_remove_nonexistent() {
let (_dir, store) = temp_store();
assert!(store.remove("nope", false).is_err());
}
#[test]
fn test_remove_in_use_fails() {
let (_dir, store) = temp_store();
let mut vol = VolumeConfig::new("mydata", "");
vol.attach("box-1");
let created = store.create(VolumeConfig::new("mydata", "")).unwrap();
let mut updated = created;
updated.attach("box-1");
store.update(&updated).unwrap();
assert!(store.remove("mydata", false).is_err());
}
#[test]
fn test_remove_in_use_force() {
let (_dir, store) = temp_store();
let created = store.create(VolumeConfig::new("mydata", "")).unwrap();
let mut updated = created;
updated.attach("box-1");
store.update(&updated).unwrap();
let removed = store.remove("mydata", true).unwrap();
assert_eq!(removed.name, "mydata");
}
#[test]
fn test_list() {
let (_dir, store) = temp_store();
store.create(VolumeConfig::new("vol1", "")).unwrap();
store.create(VolumeConfig::new("vol2", "")).unwrap();
let list = store.list().unwrap();
assert_eq!(list.len(), 2);
}
#[test]
fn test_update() {
let (_dir, store) = temp_store();
let created = store.create(VolumeConfig::new("mydata", "")).unwrap();
let mut updated = created;
updated.attach("box-1");
store.update(&updated).unwrap();
let loaded = store.get("mydata").unwrap().unwrap();
assert_eq!(loaded.in_use_by, vec!["box-1"]);
}
#[test]
fn test_update_nonexistent() {
let (_dir, store) = temp_store();
let vol = VolumeConfig::new("nope", "/tmp");
assert!(store.update(&vol).is_err());
}
#[test]
fn test_prune() {
let (_dir, store) = temp_store();
store.create(VolumeConfig::new("unused1", "")).unwrap();
store.create(VolumeConfig::new("unused2", "")).unwrap();
let created = store.create(VolumeConfig::new("in_use", "")).unwrap();
let mut updated = created;
updated.attach("box-1");
store.update(&updated).unwrap();
let pruned = store.prune().unwrap();
assert_eq!(pruned.len(), 2);
assert!(pruned.contains(&"unused1".to_string()));
assert!(pruned.contains(&"unused2".to_string()));
let remaining = store.list().unwrap();
assert_eq!(remaining.len(), 1);
assert_eq!(remaining[0].name, "in_use");
}
#[test]
fn test_atomic_write() {
let (_dir, store) = temp_store();
store.create(VolumeConfig::new("mydata", "")).unwrap();
let data = std::fs::read_to_string(store.path()).unwrap();
let _: serde_json::Value = serde_json::from_str(&data).unwrap();
let tmp = store.path().with_extension("json.tmp");
assert!(!tmp.exists());
}
#[test]
fn test_creates_parent_directory() {
let dir = tempfile::tempdir().unwrap();
let store = VolumeStore::new(
dir.path().join("subdir").join("volumes.json"),
dir.path().join("subdir").join("volumes"),
);
store.create(VolumeConfig::new("mydata", "")).unwrap();
assert!(store.path().exists());
}
#[test]
fn test_remove_cleans_up_directory() {
let (_dir, store) = temp_store();
let created = store.create(VolumeConfig::new("mydata", "")).unwrap();
let vol_dir = PathBuf::from(&created.mount_point);
assert!(vol_dir.exists());
store.remove("mydata", false).unwrap();
assert!(!vol_dir.exists());
}
#[test]
fn test_get_or_create_is_idempotent() {
let (_dir, store) = temp_store();
let first = store
.get_or_create(VolumeConfig::new("shared", ""))
.unwrap();
let second = store
.get_or_create(VolumeConfig::new("shared", ""))
.unwrap();
assert_eq!(first.mount_point, second.mount_point);
assert_eq!(store.list().unwrap().len(), 1);
}
#[test]
fn test_get_or_create_preserves_existing_config() {
let (_dir, store) = temp_store();
let created = store
.get_or_create(VolumeConfig::with_size_limit("shared", "", 4096))
.unwrap();
let mut updated = created;
updated.attach("box-1");
store.update(&updated).unwrap();
let reused = store
.get_or_create(VolumeConfig::with_size_limit("shared", "/ignored", 8192))
.unwrap();
assert_eq!(reused.size_limit, 4096);
assert_eq!(reused.in_use_by, vec!["box-1"]);
assert!(PathBuf::from(&reused.mount_point).exists());
}
#[test]
fn test_modify_missing_returns_false() {
let (_dir, store) = temp_store();
assert!(!store.modify("nope", |c| c.attach("box-1")).unwrap());
}
#[test]
fn test_modify_existing_returns_true_and_persists() {
let (_dir, store) = temp_store();
store.create(VolumeConfig::new("shared", "")).unwrap();
let modified = store.modify("shared", |c| c.attach("box-1")).unwrap();
assert!(modified);
assert_eq!(
store.get("shared").unwrap().unwrap().in_use_by,
vec!["box-1"]
);
}
#[test]
fn test_volume_dir_joins_name_under_base_directory() {
let (dir, store) = temp_store();
assert_eq!(
store.volume_dir("mydata"),
dir.path().join("volumes").join("mydata")
);
}
#[test]
fn test_volume_store_backend_trait_dispatch() {
let (_dir, store) = temp_store();
let backend: &dyn a3s_box_core::traits::VolumeStoreBackend = &store;
let created = backend.create(VolumeConfig::new("shared", "")).unwrap();
assert_eq!(created.name, "shared");
assert!(backend.get("shared").unwrap().is_some());
let mut updated = created;
updated.attach("box-1");
backend.update(&updated).unwrap();
assert_eq!(backend.list().unwrap().len(), 1);
let err = backend.remove("shared", false).unwrap_err().to_string();
assert!(err.contains("is in use"));
let removed = backend.remove("shared", true).unwrap();
assert_eq!(removed.name, "shared");
assert!(backend.list().unwrap().is_empty());
}
#[test]
fn concurrent_attaches_accumulate_without_lost_update() {
use std::sync::Arc;
use std::thread;
let dir = tempfile::tempdir().unwrap();
let store = Arc::new(VolumeStore::new(
dir.path().join("volumes.json"),
dir.path().join("volumes"),
));
store.create(VolumeConfig::new("shared", "")).unwrap();
let n = 16;
let handles: Vec<_> = (0..n)
.map(|i| {
let store = Arc::clone(&store);
thread::spawn(move || {
store
.modify("shared", |c| c.attach(&format!("box-{i}")))
.unwrap();
})
})
.collect();
for h in handles {
h.join().unwrap();
}
let cfg = store.get("shared").unwrap().unwrap();
assert_eq!(
cfg.in_use_by.len(),
n,
"every concurrent attach must persist (no lost update): {:?}",
cfg.in_use_by
);
for i in 0..n {
assert!(cfg.in_use_by.contains(&format!("box-{i}")));
}
}
#[test]
fn concurrent_creates_persist_every_volume() {
use std::sync::Arc;
use std::thread;
let dir = tempfile::tempdir().unwrap();
let store = Arc::new(VolumeStore::new(
dir.path().join("volumes.json"),
dir.path().join("volumes"),
));
let n = 16;
let handles: Vec<_> = (0..n)
.map(|i| {
let store = Arc::clone(&store);
thread::spawn(move || {
store
.create(VolumeConfig::new(&format!("vol-{i}"), ""))
.unwrap();
})
})
.collect();
for h in handles {
h.join().unwrap();
}
assert_eq!(
store.list().unwrap().len(),
n,
"every concurrent create must persist (no lost update)"
);
}
#[test]
fn corrupt_volumes_file_is_quarantined_not_fatal() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("volumes.json");
std::fs::write(&path, "{ not valid json").unwrap();
let store = VolumeStore::new(path.clone(), dir.path().join("volumes"));
assert!(store.load().unwrap().is_empty());
let quarantined = std::fs::read_dir(dir.path())
.unwrap()
.filter_map(|e| e.ok())
.any(|e| {
e.file_name()
.to_string_lossy()
.contains("volumes.json.corrupt-")
});
assert!(quarantined, "corrupt volumes.json must be quarantined");
}
}