1use anyhow::{Context, Result};
4use serde::{Deserialize, Serialize};
5use std::path::Path;
6
7#[derive(Debug, Serialize, Deserialize)]
9pub struct BackupManifest {
10 pub version: u32,
12 pub created_at: String,
14 pub oxios_version: String,
16 pub sections: Vec<BackupSection>,
18}
19
20#[derive(Debug, Serialize, Deserialize)]
22pub struct BackupSection {
23 pub name: String,
25 pub entry_count: usize,
27}
28
29pub async fn create_backup(
31 state_store: &crate::state_store::StateStore,
32 output_path: &Path,
33) -> Result<BackupManifest> {
34 let mut manifest = BackupManifest {
35 version: 1,
36 created_at: chrono::Utc::now().to_rfc3339(),
37 oxios_version: env!("CARGO_PKG_VERSION").to_string(),
38 sections: Vec::new(),
39 };
40
41 let categories = [
42 "seeds",
43 "evals",
44 "memory/conversations",
45 "memory/sessions",
46 "memory/facts",
47 "memory/episodes",
48 "memory/knowledge",
49 "sessions",
50 "agent_groups",
51 ];
52
53 for category in &categories {
54 if let Ok(names) = state_store.list_category(category).await
55 && !names.is_empty()
56 {
57 manifest.sections.push(BackupSection {
58 name: category.to_string(),
59 entry_count: names.len(),
60 });
61 }
62 }
63
64 let src = &state_store.base_path;
66 if output_path.exists() {
67 tokio::fs::remove_dir_all(output_path).await?;
68 }
69 copy_dir_recursive(src, output_path).await?;
70
71 let manifest_json = serde_json::to_string_pretty(&manifest)?;
73 tokio::fs::write(output_path.join("manifest.json"), manifest_json).await?;
74
75 tracing::info!(path = %output_path.display(), sections = manifest.sections.len(), "Backup created");
76 Ok(manifest)
77}
78
79pub async fn restore_backup(
81 state_store: &crate::state_store::StateStore,
82 backup_path: &Path,
83) -> Result<BackupManifest> {
84 let manifest_data = tokio::fs::read_to_string(backup_path.join("manifest.json"))
85 .await
86 .context("Backup missing manifest.json")?;
87 let manifest: BackupManifest = serde_json::from_str(&manifest_data)?;
88
89 const SUPPORTED_MANIFEST_VERSION: u32 = 1;
93 if manifest.version != SUPPORTED_MANIFEST_VERSION {
94 anyhow::bail!(
95 "Unsupported backup manifest version {}: only version {} is supported",
96 manifest.version,
97 SUPPORTED_MANIFEST_VERSION
98 );
99 }
100
101 if manifest.sections.is_empty() {
104 anyhow::bail!(
105 "Backup manifest declares no sections — refusing to restore an empty backup over live state"
106 );
107 }
108
109 copy_dir_recursive(backup_path, &state_store.base_path).await?;
111
112 tracing::info!(path = %backup_path.display(), sections = manifest.sections.len(), "Backup restored");
113 Ok(manifest)
114}
115
116fn copy_dir_recursive<'a>(
117 src: &'a Path,
118 dest: &'a Path,
119) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + Send + 'a>> {
120 Box::pin(async move {
121 tokio::fs::create_dir_all(dest).await?;
122 let mut entries = tokio::fs::read_dir(src).await?;
123 while let Some(entry) = entries.next_entry().await? {
124 let src_path = entry.path();
125 let dest_path = dest.join(entry.file_name());
126 let meta = tokio::fs::symlink_metadata(&src_path).await?;
131 let ft = meta.file_type();
132 if ft.is_symlink() {
133 tracing::warn!(
134 src = %src_path.display(),
135 "backup: skipping symlink during copy (will not follow)"
136 );
137 continue;
138 }
139 if ft.is_dir() {
140 copy_dir_recursive(&src_path, &dest_path).await?;
141 } else {
142 tokio::fs::copy(&src_path, &dest_path).await?;
143 }
144 }
145 Ok(())
146 })
147}
148
149#[cfg(test)]
150mod tests {
151 use super::*;
152
153 #[test]
154 fn test_backup_manifest_serialization() {
155 let manifest = BackupManifest {
156 version: 1,
157 created_at: "2025-01-01T00:00:00Z".to_string(),
158 oxios_version: "0.1.0".to_string(),
159 sections: vec![
160 BackupSection {
161 name: "seeds".to_string(),
162 entry_count: 42,
163 },
164 BackupSection {
165 name: "memory/facts".to_string(),
166 entry_count: 100,
167 },
168 ],
169 };
170
171 let json = serde_json::to_string_pretty(&manifest).unwrap();
172 let restored: BackupManifest = serde_json::from_str(&json).unwrap();
173
174 assert_eq!(restored.version, 1);
175 assert_eq!(restored.oxios_version, "0.1.0");
176 assert_eq!(restored.sections.len(), 2);
177 assert_eq!(restored.sections[0].name, "seeds");
178 assert_eq!(restored.sections[0].entry_count, 42);
179 assert_eq!(restored.sections[1].name, "memory/facts");
180 assert_eq!(restored.sections[1].entry_count, 100);
181 }
182
183 #[test]
184 fn test_backup_section_ordering() {
185 let sections = vec![
186 BackupSection {
187 name: "a".into(),
188 entry_count: 1,
189 },
190 BackupSection {
191 name: "b".into(),
192 entry_count: 2,
193 },
194 ];
195 let manifest = BackupManifest {
196 version: 1,
197 created_at: String::new(),
198 oxios_version: String::new(),
199 sections,
200 };
201 assert_eq!(manifest.sections[0].name, "a");
202 assert_eq!(manifest.sections[1].name, "b");
203 }
204
205 #[test]
206 fn test_backup_manifest_empty_sections() {
207 let manifest = BackupManifest {
208 version: 1,
209 created_at: "2025-01-01T00:00:00Z".to_string(),
210 oxios_version: "0.1.0".to_string(),
211 sections: vec![],
212 };
213 assert!(manifest.sections.is_empty());
214
215 let json = serde_json::to_string(&manifest).unwrap();
216 let restored: BackupManifest = serde_json::from_str(&json).unwrap();
217 assert!(restored.sections.is_empty());
218 }
219
220 #[tokio::test]
221 async fn test_copy_dir_recursive_basic() {
222 let src_dir = tempfile::tempdir().unwrap();
223 let dest_dir = tempfile::tempdir().unwrap();
224
225 tokio::fs::write(src_dir.path().join("file1.txt"), "hello")
227 .await
228 .unwrap();
229 tokio::fs::create_dir_all(src_dir.path().join("subdir"))
230 .await
231 .unwrap();
232 tokio::fs::write(src_dir.path().join("subdir/file2.txt"), "world")
233 .await
234 .unwrap();
235
236 copy_dir_recursive(src_dir.path(), dest_dir.path())
237 .await
238 .unwrap();
239
240 assert!(dest_dir.path().join("file1.txt").exists());
241 assert!(dest_dir.path().join("subdir/file2.txt").exists());
242
243 let content = tokio::fs::read_to_string(dest_dir.path().join("file1.txt"))
244 .await
245 .unwrap();
246 assert_eq!(content, "hello");
247 }
248}