use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::error::{IdentityError, Result};
use crate::spawn::{SpawnId, SpawnRecord};
const SPAWN_FILE_VERSION: u32 = 1;
#[derive(Debug, Serialize, Deserialize)]
struct SpawnFile {
version: u32,
record: SpawnRecord,
}
pub struct SpawnStore {
base_dir: PathBuf,
}
impl SpawnStore {
pub fn new(base_dir: impl Into<PathBuf>) -> Result<Self> {
let base_dir = base_dir.into();
std::fs::create_dir_all(&base_dir)?;
Ok(Self { base_dir })
}
pub fn save(&self, record: &SpawnRecord) -> Result<()> {
let file = SpawnFile {
version: SPAWN_FILE_VERSION,
record: record.clone(),
};
let json = serde_json::to_string_pretty(&file)
.map_err(|e| IdentityError::SerializationError(e.to_string()))?;
let path = self.record_path(&record.id);
std::fs::write(&path, json.as_bytes())?;
Ok(())
}
pub fn load(&self, id: &SpawnId) -> Result<SpawnRecord> {
let path = self.record_path(id);
if !path.exists() {
return Err(IdentityError::NotFound(format!(
"spawn record not found: {}",
id
)));
}
let bytes = std::fs::read(&path)?;
let file: SpawnFile = serde_json::from_slice(&bytes).map_err(|e| {
IdentityError::InvalidFileFormat(format!(
"failed to parse spawn file {}: {e}",
path.display()
))
})?;
Ok(file.record)
}
pub fn list(&self) -> Result<Vec<SpawnId>> {
let mut ids = Vec::new();
for entry in std::fs::read_dir(&self.base_dir)? {
let entry = entry?;
let name = entry.file_name();
let name_str = name.to_string_lossy();
if let Some(stem) = name_str.strip_suffix(".json") {
ids.push(SpawnId(stem.to_string()));
}
}
Ok(ids)
}
pub fn load_all(&self) -> Result<Vec<SpawnRecord>> {
let ids = self.list()?;
let mut records = Vec::with_capacity(ids.len());
for id in &ids {
match self.load(id) {
Ok(record) => records.push(record),
Err(_) => continue, }
}
Ok(records)
}
pub fn delete(&self, id: &SpawnId) -> Result<()> {
let path = self.record_path(id);
match std::fs::remove_file(&path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(IdentityError::Io(e)),
}
}
fn record_path(&self, id: &SpawnId) -> PathBuf {
self.base_dir.join(format!("{}.json", id.0))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::identity::IdentityAnchor;
use crate::spawn::{spawn_child, SpawnConstraints, SpawnLifetime, SpawnType};
use crate::trust::Capability;
fn make_record() -> SpawnRecord {
let parent = IdentityAnchor::new(Some("parent".to_string()));
let (_, record, _) = spawn_child(
&parent,
SpawnType::Worker,
"test-spawn",
vec![Capability::new("read:*")],
vec![Capability::new("read:*")],
SpawnLifetime::Indefinite,
SpawnConstraints::default(),
None,
&[],
)
.unwrap();
record
}
#[test]
fn test_spawn_store_save_load() {
let dir = tempfile::tempdir().unwrap();
let store = SpawnStore::new(dir.path()).unwrap();
let record = make_record();
let id = record.id.clone();
store.save(&record).expect("save failed");
let loaded = store.load(&id).expect("load failed");
assert_eq!(loaded.id.0, record.id.0);
assert_eq!(loaded.parent_id, record.parent_id);
assert_eq!(loaded.child_id, record.child_id);
assert!(!loaded.terminated);
}
#[test]
fn test_spawn_store_list() {
let dir = tempfile::tempdir().unwrap();
let store = SpawnStore::new(dir.path()).unwrap();
let r1 = make_record();
let r2 = make_record();
store.save(&r1).unwrap();
store.save(&r2).unwrap();
let ids = store.list().unwrap();
assert_eq!(ids.len(), 2);
}
#[test]
fn test_spawn_store_load_all() {
let dir = tempfile::tempdir().unwrap();
let store = SpawnStore::new(dir.path()).unwrap();
let r1 = make_record();
let r2 = make_record();
store.save(&r1).unwrap();
store.save(&r2).unwrap();
let all = store.load_all().unwrap();
assert_eq!(all.len(), 2);
}
#[test]
fn test_spawn_store_delete() {
let dir = tempfile::tempdir().unwrap();
let store = SpawnStore::new(dir.path()).unwrap();
let record = make_record();
let id = record.id.clone();
store.save(&record).unwrap();
assert!(store.load(&id).is_ok());
store.delete(&id).unwrap();
assert!(store.load(&id).is_err());
}
#[test]
fn test_spawn_store_load_not_found() {
let dir = tempfile::tempdir().unwrap();
let store = SpawnStore::new(dir.path()).unwrap();
let missing = SpawnId("aspawn_missing".to_string());
let result = store.load(&missing);
assert!(matches!(result, Err(IdentityError::NotFound(_))));
}
#[test]
fn test_spawn_store_creates_directory() {
let dir = tempfile::tempdir().unwrap();
let nested = dir.path().join("spawn").join("v1");
assert!(!nested.exists());
let _store = SpawnStore::new(&nested).unwrap();
assert!(nested.exists());
}
#[test]
fn test_spawn_store_overwrite_terminated() {
let dir = tempfile::tempdir().unwrap();
let store = SpawnStore::new(dir.path()).unwrap();
let mut record = make_record();
let id = record.id.clone();
store.save(&record).unwrap();
record.terminated = true;
record.terminated_at = Some(crate::time::now_micros());
record.termination_reason = Some("test".to_string());
store.save(&record).unwrap();
let loaded = store.load(&id).unwrap();
assert!(loaded.terminated);
assert_eq!(loaded.termination_reason.as_deref(), Some("test"));
}
}