Skip to main content

oxios_kernel/
backup.rs

1//! Backup and restore for Oxios state.
2
3use anyhow::{Context, Result};
4use serde::{Deserialize, Serialize};
5use std::path::Path;
6
7/// Backup manifest.
8#[derive(Debug, Serialize, Deserialize)]
9pub struct BackupManifest {
10    /// Manifest format version.
11    pub version: u32,
12    /// Timestamp when the backup was created.
13    pub created_at: String,
14    /// Oxios version that created the backup.
15    pub oxios_version: String,
16    /// Sections included in the backup.
17    pub sections: Vec<BackupSection>,
18}
19
20/// A single section in a backup manifest.
21#[derive(Debug, Serialize, Deserialize)]
22pub struct BackupSection {
23    /// Section name (e.g., "seeds", "memory/facts").
24    pub name: String,
25    /// Number of entries in this section.
26    pub entry_count: usize,
27}
28
29/// Create a backup by copying the state directory.
30pub 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            if !names.is_empty() {
56                manifest.sections.push(BackupSection {
57                    name: category.to_string(),
58                    entry_count: names.len(),
59                });
60            }
61        }
62    }
63
64    // Copy the entire state directory
65    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    // Write manifest into backup
72    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
79/// Restore state from a backup directory.
80pub 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    // Copy backup into state directory
90    copy_dir_recursive(backup_path, &state_store.base_path).await?;
91
92    tracing::info!(path = %backup_path.display(), sections = manifest.sections.len(), "Backup restored");
93    Ok(manifest)
94}
95
96fn copy_dir_recursive<'a>(
97    src: &'a Path,
98    dest: &'a Path,
99) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + Send + 'a>> {
100    Box::pin(async move {
101        tokio::fs::create_dir_all(dest).await?;
102        let mut entries = tokio::fs::read_dir(src).await?;
103        while let Some(entry) = entries.next_entry().await? {
104            let src_path = entry.path();
105            let dest_path = dest.join(entry.file_name());
106            if src_path.is_dir() {
107                copy_dir_recursive(&src_path, &dest_path).await?;
108            } else {
109                tokio::fs::copy(&src_path, &dest_path).await?;
110            }
111        }
112        Ok(())
113    })
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119
120    #[test]
121    fn test_backup_manifest_serialization() {
122        let manifest = BackupManifest {
123            version: 1,
124            created_at: "2025-01-01T00:00:00Z".to_string(),
125            oxios_version: "0.1.0".to_string(),
126            sections: vec![
127                BackupSection {
128                    name: "seeds".to_string(),
129                    entry_count: 42,
130                },
131                BackupSection {
132                    name: "memory/facts".to_string(),
133                    entry_count: 100,
134                },
135            ],
136        };
137
138        let json = serde_json::to_string_pretty(&manifest).unwrap();
139        let restored: BackupManifest = serde_json::from_str(&json).unwrap();
140
141        assert_eq!(restored.version, 1);
142        assert_eq!(restored.oxios_version, "0.1.0");
143        assert_eq!(restored.sections.len(), 2);
144        assert_eq!(restored.sections[0].name, "seeds");
145        assert_eq!(restored.sections[0].entry_count, 42);
146        assert_eq!(restored.sections[1].name, "memory/facts");
147        assert_eq!(restored.sections[1].entry_count, 100);
148    }
149
150    #[test]
151    fn test_backup_section_ordering() {
152        let sections = vec![
153            BackupSection {
154                name: "a".into(),
155                entry_count: 1,
156            },
157            BackupSection {
158                name: "b".into(),
159                entry_count: 2,
160            },
161        ];
162        let manifest = BackupManifest {
163            version: 1,
164            created_at: String::new(),
165            oxios_version: String::new(),
166            sections,
167        };
168        assert_eq!(manifest.sections[0].name, "a");
169        assert_eq!(manifest.sections[1].name, "b");
170    }
171
172    #[test]
173    fn test_backup_manifest_empty_sections() {
174        let manifest = BackupManifest {
175            version: 1,
176            created_at: "2025-01-01T00:00:00Z".to_string(),
177            oxios_version: "0.1.0".to_string(),
178            sections: vec![],
179        };
180        assert!(manifest.sections.is_empty());
181
182        let json = serde_json::to_string(&manifest).unwrap();
183        let restored: BackupManifest = serde_json::from_str(&json).unwrap();
184        assert!(restored.sections.is_empty());
185    }
186
187    #[tokio::test]
188    async fn test_copy_dir_recursive_basic() {
189        let src_dir = tempfile::tempdir().unwrap();
190        let dest_dir = tempfile::tempdir().unwrap();
191
192        // Create source files
193        tokio::fs::write(src_dir.path().join("file1.txt"), "hello")
194            .await
195            .unwrap();
196        tokio::fs::create_dir_all(src_dir.path().join("subdir"))
197            .await
198            .unwrap();
199        tokio::fs::write(src_dir.path().join("subdir/file2.txt"), "world")
200            .await
201            .unwrap();
202
203        copy_dir_recursive(src_dir.path(), dest_dir.path())
204            .await
205            .unwrap();
206
207        assert!(dest_dir.path().join("file1.txt").exists());
208        assert!(dest_dir.path().join("subdir/file2.txt").exists());
209
210        let content = tokio::fs::read_to_string(dest_dir.path().join("file1.txt"))
211            .await
212            .unwrap();
213        assert_eq!(content, "hello");
214    }
215}