1use crate::cli::CliOutput;
8use crate::db;
9use anyhow::{Context, Result};
10use clap::Args;
11use std::path::{Path, PathBuf};
12
13const BACKUP_TS_FMT: &str = "%Y-%m-%dT%H%M%SZ";
16
17#[derive(Args)]
18pub struct BackupArgs {
19 #[arg(long, default_value = "./backups")]
22 pub to: PathBuf,
23 #[arg(long, default_value_t = 48)]
26 pub keep: usize,
27}
28
29#[derive(Args)]
30pub struct RestoreArgs {
31 #[arg(long)]
34 pub from: PathBuf,
35 #[arg(long)]
37 pub skip_verify: bool,
38}
39
40#[derive(serde::Serialize, serde::Deserialize)]
41pub struct BackupManifest {
42 pub snapshot: String,
43 pub sha256: String,
44 pub bytes: u64,
45 pub source_db: String,
46 pub version: String,
47 pub created_at: String,
48}
49
50pub fn run_backup(
52 db_path: &Path,
53 args: &BackupArgs,
54 json_out: bool,
55 out: &mut CliOutput<'_>,
56) -> Result<()> {
57 use std::io::Read;
58 std::fs::create_dir_all(&args.to)
59 .with_context(|| format!("creating backup dir {}", args.to.display()))?;
60 let conn = db::open(db_path).context("opening source DB for backup")?;
64 let ts = chrono::Utc::now().format(BACKUP_TS_FMT).to_string();
65 let snapshot_name = format!("ai-memory-{ts}.db");
66 let snapshot_path = args.to.join(&snapshot_name);
67 if snapshot_path.exists() {
68 anyhow::bail!(
69 "refusing to overwrite existing snapshot {}",
70 snapshot_path.display()
71 );
72 }
73 conn.execute(
74 "VACUUM INTO ?1",
75 rusqlite::params![snapshot_path.to_string_lossy()],
76 )
77 .context("VACUUM INTO failed")?;
78 drop(conn);
79
80 let bytes = std::fs::metadata(&snapshot_path)?.len();
81 let sha = {
82 use sha2::Digest;
83 let mut hasher = sha2::Sha256::new();
84 let mut f = std::fs::File::open(&snapshot_path)?;
85 let mut buf = vec![0u8; 64 * 1024];
86 loop {
87 let n = f.read(&mut buf)?;
88 if n == 0 {
89 break;
90 }
91 hasher.update(&buf[..n]);
92 }
93 format!("{:x}", hasher.finalize())
94 };
95
96 let manifest = BackupManifest {
97 snapshot: snapshot_name.clone(),
98 sha256: sha.clone(),
99 bytes,
100 source_db: db_path.to_string_lossy().into_owned(),
101 version: env!("CARGO_PKG_VERSION").to_string(),
102 created_at: chrono::Utc::now().to_rfc3339(),
103 };
104 let manifest_path = args.to.join(format!("ai-memory-{ts}.manifest.json"));
105 let manifest_text = serde_json::to_string_pretty(&manifest)?;
106 std::fs::write(&manifest_path, manifest_text.as_bytes())?;
107
108 if args.keep > 0 {
110 prune_old_snapshots(&args.to, args.keep)?;
111 }
112
113 if json_out {
114 writeln!(out.stdout, "{}", serde_json::to_string(&manifest)?)?;
115 } else {
116 writeln!(out.stdout, "Snapshot: {}", snapshot_path.display())?;
117 writeln!(out.stdout, "Manifest: {}", manifest_path.display())?;
118 writeln!(out.stdout, "SHA-256 : {sha}")?;
119 writeln!(out.stdout, "Bytes : {bytes}")?;
120 }
121 Ok(())
122}
123
124fn prune_old_snapshots(dir: &Path, keep: usize) -> Result<()> {
128 let mut snaps: Vec<(std::time::SystemTime, PathBuf)> = std::fs::read_dir(dir)?
129 .filter_map(std::result::Result::ok)
130 .filter_map(|entry| {
131 let path = entry.path();
132 let name = path.file_name()?.to_str()?.to_owned();
133 let is_snapshot = name.starts_with("ai-memory-")
134 && path
135 .extension()
136 .is_some_and(|ext| ext.eq_ignore_ascii_case("db"));
137 if is_snapshot {
138 let mtime = entry.metadata().ok()?.modified().ok()?;
139 Some((mtime, path))
140 } else {
141 None
142 }
143 })
144 .collect();
145 snaps.sort_by_key(|b| std::cmp::Reverse(b.0));
146 for (_, path) in snaps.into_iter().skip(keep) {
147 let _ = std::fs::remove_file(&path);
148 if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
150 let manifest = dir.join(format!("{stem}.manifest.json"));
151 let _ = std::fs::remove_file(manifest);
152 }
153 }
154 Ok(())
155}
156
157pub fn run_restore(
159 db_path: &Path,
160 args: &RestoreArgs,
161 json_out: bool,
162 out: &mut CliOutput<'_>,
163) -> Result<()> {
164 use std::io::Read;
165 let (snapshot_path, manifest_path) = if args.from.is_dir() {
166 let mut snaps: Vec<(std::time::SystemTime, PathBuf)> = std::fs::read_dir(&args.from)?
168 .filter_map(std::result::Result::ok)
169 .filter_map(|entry| {
170 let path = entry.path();
171 let name = path.file_name()?.to_str()?.to_owned();
172 let is_snapshot = name.starts_with("ai-memory-")
173 && path
174 .extension()
175 .is_some_and(|ext| ext.eq_ignore_ascii_case("db"));
176 if is_snapshot {
177 let mtime = entry.metadata().ok()?.modified().ok()?;
178 Some((mtime, path))
179 } else {
180 None
181 }
182 })
183 .collect();
184 snaps.sort_by_key(|b| std::cmp::Reverse(b.0));
185 let snap = snaps
186 .into_iter()
187 .next()
188 .map(|(_, p)| p)
189 .ok_or_else(|| anyhow::anyhow!("no snapshots found in {}", args.from.display()))?;
190 let stem = snap.file_stem().and_then(|s| s.to_str()).unwrap_or("");
191 let manifest = args.from.join(format!("{stem}.manifest.json"));
192 (snap, manifest)
193 } else {
194 let snap = args.from.clone();
196 let stem = snap.file_stem().and_then(|s| s.to_str()).unwrap_or("");
197 let parent = snap.parent().unwrap_or_else(|| Path::new("."));
198 let manifest = parent.join(format!("{stem}.manifest.json"));
199 (snap, manifest)
200 };
201
202 if !snapshot_path.exists() {
203 anyhow::bail!("snapshot {} does not exist", snapshot_path.display());
204 }
205
206 if !args.skip_verify {
208 if !manifest_path.exists() {
209 anyhow::bail!(
210 "manifest {} not found; pass --skip-verify to restore anyway",
211 manifest_path.display()
212 );
213 }
214 let manifest_text = std::fs::read_to_string(&manifest_path)?;
215 let manifest: BackupManifest = serde_json::from_str(&manifest_text)
216 .with_context(|| format!("parsing manifest {}", manifest_path.display()))?;
217 let observed = {
218 use sha2::Digest;
219 let mut hasher = sha2::Sha256::new();
220 let mut f = std::fs::File::open(&snapshot_path)?;
221 let mut buf = vec![0u8; 64 * 1024];
222 loop {
223 let n = f.read(&mut buf)?;
224 if n == 0 {
225 break;
226 }
227 hasher.update(&buf[..n]);
228 }
229 format!("{:x}", hasher.finalize())
230 };
231 if observed != manifest.sha256 {
232 anyhow::bail!(
233 "sha256 mismatch — manifest says {}, snapshot is {}",
234 manifest.sha256,
235 observed
236 );
237 }
238 }
239
240 if db_path.exists() {
242 let ts = chrono::Utc::now().format(BACKUP_TS_FMT).to_string();
243 let aside = db_path.with_extension(format!("pre-restore-{ts}.db"));
244 std::fs::rename(db_path, &aside)
245 .with_context(|| format!("moving current DB aside to {}", aside.display()))?;
246 if !json_out {
247 writeln!(out.stdout, "Previous DB moved to {}", aside.display())?;
248 }
249 }
250
251 std::fs::copy(&snapshot_path, db_path)
252 .with_context(|| format!("copying snapshot to {}", db_path.display()))?;
253
254 if json_out {
255 writeln!(
256 out.stdout,
257 "{}",
258 serde_json::json!({
259 "status": "restored",
260 "from": snapshot_path.to_string_lossy(),
261 "to": db_path.to_string_lossy(),
262 })
263 )?;
264 } else {
265 writeln!(
266 out.stdout,
267 "Restored {} → {}",
268 snapshot_path.display(),
269 db_path.display()
270 )?;
271 }
272 Ok(())
273}
274
275#[cfg(test)]
276mod tests {
277 use super::*;
278 use crate::cli::test_utils::{TestEnv, seed_memory};
279
280 #[test]
281 fn test_backup_happy_path_creates_snapshot_and_manifest() {
282 let mut env = TestEnv::fresh();
283 let db = env.db_path.clone();
284 seed_memory(&db, "ns", "t", "c");
285 let backup_dir = db.parent().unwrap().join("backups-x1");
286 let args = BackupArgs {
287 to: backup_dir.clone(),
288 keep: 48,
289 };
290 {
291 let mut out = env.output();
292 run_backup(&db, &args, false, &mut out).unwrap();
293 }
294 let mut snap_count = 0;
296 let mut manifest_count = 0;
297 for entry in std::fs::read_dir(&backup_dir).unwrap().flatten() {
298 let name = entry.file_name();
299 let s = name.to_string_lossy();
300 if s.starts_with("ai-memory-") && s.ends_with(".db") {
301 snap_count += 1;
302 }
303 if s.ends_with(".manifest.json") {
304 manifest_count += 1;
305 }
306 }
307 assert!(snap_count >= 1, "expected at least one snapshot");
308 assert!(manifest_count >= 1, "expected at least one manifest");
309 assert!(env.stdout_str().contains("Snapshot:"));
310 }
311
312 #[test]
313 fn test_backup_json_emits_manifest_with_sha256() {
314 let mut env = TestEnv::fresh();
315 let db = env.db_path.clone();
316 seed_memory(&db, "ns", "t", "c");
317 let backup_dir = db.parent().unwrap().join("backups-x2");
318 let args = BackupArgs {
319 to: backup_dir,
320 keep: 48,
321 };
322 {
323 let mut out = env.output();
324 run_backup(&db, &args, true, &mut out).unwrap();
325 }
326 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
327 assert!(v["sha256"].is_string());
328 let sha = v["sha256"].as_str().unwrap();
329 assert_eq!(sha.len(), 64); }
331
332 #[test]
333 fn test_restore_from_directory_picks_newest() {
334 let mut env = TestEnv::fresh();
335 let db = env.db_path.clone();
336 seed_memory(&db, "ns", "before-backup", "stuff");
337 let backup_dir = db.parent().unwrap().join("backups-x3");
338 let backup_args = BackupArgs {
339 to: backup_dir.clone(),
340 keep: 48,
341 };
342 {
343 let mut out = env.output();
344 run_backup(&db, &backup_args, false, &mut out).unwrap();
345 }
346 env.stdout.clear();
347 env.stderr.clear();
348 let restore_args = RestoreArgs {
349 from: backup_dir,
350 skip_verify: false,
351 };
352 {
353 let mut out = env.output();
354 run_restore(&db, &restore_args, false, &mut out).unwrap();
355 }
356 assert!(env.stdout_str().contains("Restored"));
357 }
358
359 #[test]
360 fn test_restore_from_explicit_file_path() {
361 let mut env = TestEnv::fresh();
362 let db = env.db_path.clone();
363 seed_memory(&db, "ns", "t", "c");
364 let backup_dir = db.parent().unwrap().join("backups-x4");
365 let backup_args = BackupArgs {
366 to: backup_dir.clone(),
367 keep: 48,
368 };
369 {
370 let mut out = env.output();
371 run_backup(&db, &backup_args, true, &mut out).unwrap();
372 }
373 let manifest: BackupManifest = serde_json::from_str(env.stdout_str().trim()).unwrap();
374 let snap_path = backup_dir.join(&manifest.snapshot);
375 env.stdout.clear();
376 env.stderr.clear();
377 let restore_args = RestoreArgs {
378 from: snap_path,
379 skip_verify: false,
380 };
381 {
382 let mut out = env.output();
383 run_restore(&db, &restore_args, true, &mut out).unwrap();
384 }
385 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
386 assert_eq!(v["status"].as_str().unwrap(), "restored");
387 }
388
389 #[test]
390 fn test_restore_with_skip_verify_succeeds_without_manifest() {
391 let mut env = TestEnv::fresh();
392 let db = env.db_path.clone();
393 seed_memory(&db, "ns", "t", "c");
394 let backup_dir = db.parent().unwrap().join("backups-x5");
395 let backup_args = BackupArgs {
396 to: backup_dir.clone(),
397 keep: 48,
398 };
399 {
400 let mut out = env.output();
401 run_backup(&db, &backup_args, true, &mut out).unwrap();
402 }
403 let manifest: BackupManifest = serde_json::from_str(env.stdout_str().trim()).unwrap();
404 let snap_path = backup_dir.join(&manifest.snapshot);
405 let manifest_path = backup_dir.join(format!(
407 "{}.manifest.json",
408 snap_path.file_stem().unwrap().to_string_lossy()
409 ));
410 std::fs::remove_file(&manifest_path).unwrap();
411 env.stdout.clear();
412 env.stderr.clear();
413 let restore_args = RestoreArgs {
414 from: snap_path,
415 skip_verify: true,
416 };
417 {
418 let mut out = env.output();
419 run_restore(&db, &restore_args, false, &mut out).unwrap();
420 }
421 assert!(env.stdout_str().contains("Restored"));
422 }
423
424 #[test]
425 fn test_restore_bad_sha256_errors() {
426 let mut env = TestEnv::fresh();
427 let db = env.db_path.clone();
428 seed_memory(&db, "ns", "t", "c");
429 let backup_dir = db.parent().unwrap().join("backups-x6");
430 let backup_args = BackupArgs {
431 to: backup_dir.clone(),
432 keep: 48,
433 };
434 {
435 let mut out = env.output();
436 run_backup(&db, &backup_args, true, &mut out).unwrap();
437 }
438 let manifest: BackupManifest = serde_json::from_str(env.stdout_str().trim()).unwrap();
439 let manifest_path = backup_dir.join(format!(
440 "{}.manifest.json",
441 std::path::Path::new(&manifest.snapshot)
442 .file_stem()
443 .unwrap()
444 .to_string_lossy()
445 ));
446 let mut bad = manifest;
448 bad.sha256 = "0000000000000000000000000000000000000000000000000000000000000000".to_string();
449 std::fs::write(&manifest_path, serde_json::to_string(&bad).unwrap()).unwrap();
450 let snap_path = backup_dir.join(&bad.snapshot);
451 let restore_args = RestoreArgs {
452 from: snap_path,
453 skip_verify: false,
454 };
455 let mut out = env.output();
456 let res = run_restore(&db, &restore_args, false, &mut out);
457 assert!(res.is_err());
458 assert!(res.unwrap_err().to_string().contains("sha256 mismatch"));
459 }
460
461 #[test]
462 fn test_backup_retention_prunes_old_snapshots() {
463 let mut env = TestEnv::fresh();
464 let db = env.db_path.clone();
465 seed_memory(&db, "ns", "t", "c");
466 let backup_dir = db.parent().unwrap().join("backups-x7");
467 for _ in 0..3 {
469 std::thread::sleep(std::time::Duration::from_secs(1));
471 let args = BackupArgs {
472 to: backup_dir.clone(),
473 keep: 1,
474 };
475 let mut out = env.output();
476 run_backup(&db, &args, true, &mut out).unwrap();
477 drop(out);
478 env.stdout.clear();
479 env.stderr.clear();
480 }
481 let snaps: Vec<_> = std::fs::read_dir(&backup_dir)
482 .unwrap()
483 .flatten()
484 .filter(|e| {
485 let name = e.file_name();
486 let s = name.to_string_lossy();
487 s.starts_with("ai-memory-") && s.ends_with(".db")
488 })
489 .collect();
490 assert_eq!(snaps.len(), 1, "retention should keep exactly 1 snapshot");
491 }
492}