use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;
#[derive(Debug, Serialize, Deserialize)]
pub struct BackupManifest {
pub version: u32,
pub created_at: String,
pub oxios_version: String,
pub sections: Vec<BackupSection>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct BackupSection {
pub name: String,
pub entry_count: usize,
}
pub async fn create_backup(
state_store: &crate::state_store::StateStore,
output_path: &Path,
) -> Result<BackupManifest> {
let mut manifest = BackupManifest {
version: 1,
created_at: chrono::Utc::now().to_rfc3339(),
oxios_version: env!("CARGO_PKG_VERSION").to_string(),
sections: Vec::new(),
};
let categories = [
"seeds",
"evals",
"memory/conversations",
"memory/sessions",
"memory/facts",
"memory/episodes",
"memory/knowledge",
"sessions",
"agent_groups",
];
for category in &categories {
if let Ok(names) = state_store.list_category(category).await {
if !names.is_empty() {
manifest.sections.push(BackupSection {
name: category.to_string(),
entry_count: names.len(),
});
}
}
}
let src = &state_store.base_path;
if output_path.exists() {
tokio::fs::remove_dir_all(output_path).await?;
}
copy_dir_recursive(src, output_path).await?;
let manifest_json = serde_json::to_string_pretty(&manifest)?;
tokio::fs::write(output_path.join("manifest.json"), manifest_json).await?;
tracing::info!(path = %output_path.display(), sections = manifest.sections.len(), "Backup created");
Ok(manifest)
}
pub async fn restore_backup(
state_store: &crate::state_store::StateStore,
backup_path: &Path,
) -> Result<BackupManifest> {
let manifest_data = tokio::fs::read_to_string(backup_path.join("manifest.json"))
.await
.context("Backup missing manifest.json")?;
let manifest: BackupManifest = serde_json::from_str(&manifest_data)?;
copy_dir_recursive(backup_path, &state_store.base_path).await?;
tracing::info!(path = %backup_path.display(), sections = manifest.sections.len(), "Backup restored");
Ok(manifest)
}
fn copy_dir_recursive<'a>(
src: &'a Path,
dest: &'a Path,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + Send + 'a>> {
Box::pin(async move {
tokio::fs::create_dir_all(dest).await?;
let mut entries = tokio::fs::read_dir(src).await?;
while let Some(entry) = entries.next_entry().await? {
let src_path = entry.path();
let dest_path = dest.join(entry.file_name());
if src_path.is_dir() {
copy_dir_recursive(&src_path, &dest_path).await?;
} else {
tokio::fs::copy(&src_path, &dest_path).await?;
}
}
Ok(())
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_backup_manifest_serialization() {
let manifest = BackupManifest {
version: 1,
created_at: "2025-01-01T00:00:00Z".to_string(),
oxios_version: "0.1.0".to_string(),
sections: vec![
BackupSection {
name: "seeds".to_string(),
entry_count: 42,
},
BackupSection {
name: "memory/facts".to_string(),
entry_count: 100,
},
],
};
let json = serde_json::to_string_pretty(&manifest).unwrap();
let restored: BackupManifest = serde_json::from_str(&json).unwrap();
assert_eq!(restored.version, 1);
assert_eq!(restored.oxios_version, "0.1.0");
assert_eq!(restored.sections.len(), 2);
assert_eq!(restored.sections[0].name, "seeds");
assert_eq!(restored.sections[0].entry_count, 42);
assert_eq!(restored.sections[1].name, "memory/facts");
assert_eq!(restored.sections[1].entry_count, 100);
}
#[test]
fn test_backup_section_ordering() {
let sections = vec![
BackupSection {
name: "a".into(),
entry_count: 1,
},
BackupSection {
name: "b".into(),
entry_count: 2,
},
];
let manifest = BackupManifest {
version: 1,
created_at: String::new(),
oxios_version: String::new(),
sections,
};
assert_eq!(manifest.sections[0].name, "a");
assert_eq!(manifest.sections[1].name, "b");
}
#[test]
fn test_backup_manifest_empty_sections() {
let manifest = BackupManifest {
version: 1,
created_at: "2025-01-01T00:00:00Z".to_string(),
oxios_version: "0.1.0".to_string(),
sections: vec![],
};
assert!(manifest.sections.is_empty());
let json = serde_json::to_string(&manifest).unwrap();
let restored: BackupManifest = serde_json::from_str(&json).unwrap();
assert!(restored.sections.is_empty());
}
#[tokio::test]
async fn test_copy_dir_recursive_basic() {
let src_dir = tempfile::tempdir().unwrap();
let dest_dir = tempfile::tempdir().unwrap();
tokio::fs::write(src_dir.path().join("file1.txt"), "hello")
.await
.unwrap();
tokio::fs::create_dir_all(src_dir.path().join("subdir"))
.await
.unwrap();
tokio::fs::write(src_dir.path().join("subdir/file2.txt"), "world")
.await
.unwrap();
copy_dir_recursive(src_dir.path(), dest_dir.path())
.await
.unwrap();
assert!(dest_dir.path().join("file1.txt").exists());
assert!(dest_dir.path().join("subdir/file2.txt").exists());
let content = tokio::fs::read_to_string(dest_dir.path().join("file1.txt"))
.await
.unwrap();
assert_eq!(content, "hello");
}
}