Skip to main content

sqlite_graphrag/commands/
sync_safe_copy.rs

1//! Handler for the `sync-safe-copy` CLI subcommand.
2
3use crate::errors::AppError;
4use crate::i18n::validation;
5use crate::output;
6use crate::paths::AppPaths;
7use crate::storage::connection::open_rw;
8use serde::Serialize;
9
10#[derive(clap::Args)]
11#[command(after_long_help = "EXAMPLES:\n  \
12    # Create a checkpointed snapshot safe for cloud sync\n  \
13    sqlite-graphrag sync-safe-copy --dest /backup/graphrag-snapshot.sqlite\n\n  \
14    # Use the --to alias\n  \
15    sqlite-graphrag sync-safe-copy --to /backup/graphrag-snapshot.sqlite\n\n  \
16    # Snapshot a custom source database\n  \
17    sqlite-graphrag sync-safe-copy --db /data/graphrag.sqlite --dest /backup/snapshot.sqlite")]
18pub struct SyncSafeCopyArgs {
19    /// Snapshot destination path. Also accepts the aliases `--to` and `--output`.
20    #[arg(long, alias = "to", alias = "output")]
21    pub dest: std::path::PathBuf,
22    #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
23    pub json: bool,
24    /// Output format: `json` or `text`. JSON is always emitted on stdout regardless of the value.
25    #[arg(long, value_parser = ["json", "text"], hide = true)]
26    pub format: Option<String>,
27    #[arg(long, env = "SQLITE_GRAPHRAG_DB_PATH")]
28    pub db: Option<String>,
29}
30
31#[derive(Serialize)]
32struct SyncSafeCopyResponse {
33    source_db_path: String,
34    dest_path: String,
35    bytes_copied: u64,
36    status: String,
37    /// Total execution time in milliseconds from handler start to serialisation.
38    elapsed_ms: u64,
39}
40
41pub fn run(args: SyncSafeCopyArgs) -> Result<(), AppError> {
42    let start = std::time::Instant::now();
43    let _ = args.format; // --format is a no-op; JSON is always emitted on stdout
44    let paths = AppPaths::resolve(args.db.as_deref())?;
45
46    crate::storage::connection::ensure_db_ready(&paths)?;
47
48    if args.dest == paths.db {
49        return Err(AppError::Validation(
50            validation::sync_destination_equals_source(),
51        ));
52    }
53
54    if let Some(parent) = args.dest.parent() {
55        std::fs::create_dir_all(parent)?;
56    }
57
58    let conn = open_rw(&paths.db)?;
59    conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
60    drop(conn);
61
62    let bytes_copied = std::fs::copy(&paths.db, &args.dest)?;
63
64    // Applies 0600 permissions on the snapshot on Unix to avoid leakage on Dropbox/shared NFS.
65    #[cfg(unix)]
66    {
67        use std::os::unix::fs::PermissionsExt;
68        let mut perms = std::fs::metadata(&args.dest)?.permissions();
69        perms.set_mode(0o600);
70        std::fs::set_permissions(&args.dest, perms)?;
71    }
72
73    output::emit_json(&SyncSafeCopyResponse {
74        source_db_path: paths.db.display().to_string(),
75        dest_path: args.dest.display().to_string(),
76        bytes_copied,
77        status: "ok".to_string(),
78        elapsed_ms: start.elapsed().as_millis() as u64,
79    })?;
80
81    Ok(())
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87
88    #[test]
89    fn sync_safe_copy_response_serializes_all_fields() {
90        let resp = SyncSafeCopyResponse {
91            source_db_path: "/home/user/.local/share/sqlite-graphrag/db.sqlite".to_string(),
92            dest_path: "/tmp/backup.sqlite".to_string(),
93            bytes_copied: 16384,
94            status: "ok".to_string(),
95            elapsed_ms: 12,
96        };
97        let json = serde_json::to_value(&resp).expect("serialization failed");
98        assert_eq!(
99            json["source_db_path"],
100            "/home/user/.local/share/sqlite-graphrag/db.sqlite"
101        );
102        assert_eq!(json["dest_path"], "/tmp/backup.sqlite");
103        assert_eq!(json["bytes_copied"], 16384u64);
104        assert_eq!(json["status"], "ok");
105        assert_eq!(json["elapsed_ms"], 12u64);
106    }
107
108    #[test]
109    fn sync_safe_copy_rejects_dest_equal_to_source() {
110        let db_path = std::path::PathBuf::from("/tmp/same.sqlite");
111        let args = SyncSafeCopyArgs {
112            dest: db_path.clone(),
113            json: false,
114            format: None,
115            db: Some("/tmp/same.sqlite".to_string()),
116        };
117        // Simulates manual path resolution — validates rejection logic
118        let result = if args.dest == std::path::PathBuf::from(args.db.as_deref().unwrap_or("")) {
119            Err(AppError::Validation(
120                "destination path must differ from the source database path".to_string(),
121            ))
122        } else {
123            Ok(())
124        };
125        assert!(result.is_err(), "must reject dest equal to source");
126        if let Err(AppError::Validation(msg)) = result {
127            assert!(msg.contains("destination path must differ"));
128        }
129    }
130
131    #[test]
132    fn sync_safe_copy_response_status_ok() {
133        let resp = SyncSafeCopyResponse {
134            source_db_path: "/data/db.sqlite".to_string(),
135            dest_path: "/backup/db.sqlite".to_string(),
136            bytes_copied: 0,
137            status: "ok".to_string(),
138            elapsed_ms: 0,
139        };
140        let json = serde_json::to_value(&resp).expect("serialization failed");
141        assert_eq!(json["status"], "ok");
142    }
143
144    #[test]
145    fn sync_safe_copy_response_bytes_copied_zero_valid() {
146        let resp = SyncSafeCopyResponse {
147            source_db_path: "/data/db.sqlite".to_string(),
148            dest_path: "/backup/db.sqlite".to_string(),
149            bytes_copied: 0,
150            status: "ok".to_string(),
151            elapsed_ms: 1,
152        };
153        let json = serde_json::to_value(&resp).expect("serialization failed");
154        assert_eq!(json["bytes_copied"], 0u64);
155        assert_eq!(json["elapsed_ms"], 1u64);
156    }
157}