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