Skip to main content

ai_memory/cli/
backup.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! `cmd_backup` and `cmd_restore` migrations. See `cli::store` for the
5//! design pattern.
6
7use crate::cli::CliOutput;
8use crate::db;
9use anyhow::{Context, Result};
10use clap::Args;
11use std::path::{Path, PathBuf};
12
13/// Timestamp format used for snapshot filenames. RFC3339-compatible but
14/// filesystem-safe: no colons, no slashes.
15const BACKUP_TS_FMT: &str = "%Y-%m-%dT%H%M%SZ";
16
17#[derive(Args)]
18pub struct BackupArgs {
19    /// Directory where the snapshot and manifest are written. Created if
20    /// missing.
21    #[arg(long, default_value = "./backups")]
22    pub to: PathBuf,
23    /// Retention: after writing a new snapshot, delete the oldest
24    /// snapshots so that at most this many remain. 0 disables rotation.
25    #[arg(long, default_value_t = 48)]
26    pub keep: usize,
27}
28
29#[derive(Args)]
30pub struct RestoreArgs {
31    /// Path to a snapshot file OR a backup directory. When a directory is
32    /// supplied, the most recent snapshot is used.
33    #[arg(long)]
34    pub from: PathBuf,
35    /// Skip sha256 verification against the manifest. Not recommended.
36    #[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
50/// `backup` handler.
51pub 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    // SQLite VACUUM INTO is hot-backup-safe and produces a defragmented
61    // file. Equivalent to `sqlite3 source '.backup dest'` in effect but
62    // runs in-process via our existing connection.
63    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    // Rotation — newest-first listing, drop everything past `keep`.
109    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
124/// Enumerate existing `ai-memory-*.db` snapshot files newest-first and
125/// delete everything past `keep`. Also deletes the matching manifest
126/// for each removed snapshot.
127fn 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        // Matching manifest (same stem, .manifest.json extension pattern)
149        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
157/// `restore` handler.
158pub 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        // Pick the newest snapshot in the directory.
167        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        // File path supplied directly.
195        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    // SHA-256 verification against manifest.
207    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    // Move current DB aside as a safety net (only if it exists).
241    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        // At least one snapshot + manifest must exist.
295        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); // hex sha256
330    }
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        // Delete manifest file so verification would fail; skip_verify = true should still pass.
406        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        // Corrupt sha in manifest.
447        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        // Take a few backups in succession; with `keep=1` only the newest must remain.
468        for _ in 0..3 {
469            // Sleep 1 second to avoid filename collision (BACKUP_TS_FMT is per-second).
470            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}