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")]
18/// Sync safe copy args.
19pub struct SyncSafeCopyArgs {
20    /// Snapshot destination path as a positional argument. Alternative to `--dest`.
21    #[arg(
22        value_name = "DEST",
23        conflicts_with = "dest",
24        help = "Snapshot destination path; alternative to --dest"
25    )]
26    pub dest_positional: Option<std::path::PathBuf>,
27    /// Snapshot destination path. Also accepts the aliases `--to` and `--output`.
28    #[arg(long, alias = "to", alias = "output")]
29    pub dest: Option<std::path::PathBuf>,
30    /// Emit machine-readable JSON on stdout.
31    #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
32    pub json: bool,
33    /// Output format: `json` or `text`. JSON is always emitted on stdout regardless of the value.
34    #[arg(long, value_parser = ["json", "text"], hide = true)]
35    pub format: Option<String>,
36    /// Path to the SQLite database file.
37    #[arg(long)]
38    pub db: Option<String>,
39}
40
41#[derive(Serialize)]
42struct SyncSafeCopyResponse {
43    source_db_path: String,
44    dest_path: String,
45    bytes_copied: u64,
46    status: String,
47    /// Total execution time in milliseconds from handler start to serialisation.
48    elapsed_ms: u64,
49}
50
51/// Run.
52pub fn run(args: SyncSafeCopyArgs) -> Result<(), AppError> {
53    let start = std::time::Instant::now();
54    let _ = args.format; // --format is a no-op; JSON is always emitted on stdout
55    let dest = args
56        .dest_positional
57        .clone()
58        .or_else(|| args.dest.clone())
59        .ok_or_else(|| {
60            AppError::Validation(
61                "destination required: pass as positional argument or via --dest/--to/--output"
62                    .to_string(),
63            )
64        })?;
65    let paths = AppPaths::resolve(args.db.as_deref())?;
66
67    crate::storage::connection::ensure_db_ready(&paths)?;
68
69    if dest == paths.db {
70        return Err(AppError::Validation(
71            validation::sync_destination_equals_source(),
72        ));
73    }
74
75    if let Some(parent) = dest.parent() {
76        std::fs::create_dir_all(parent)?;
77    }
78
79    let conn = open_rw(&paths.db)?;
80    conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
81    drop(conn);
82
83    let bytes_copied = std::fs::copy(&paths.db, &dest)?;
84
85    // Applies 0600 permissions on the snapshot on Unix to avoid leakage on Dropbox/shared NFS.
86    // On Windows, NTFS DACL default is private-to-user; no explicit permission setter required.
87    #[cfg(unix)]
88    {
89        use std::os::unix::fs::PermissionsExt;
90        let mut perms = std::fs::metadata(&dest)?.permissions();
91        perms.set_mode(0o600);
92        std::fs::set_permissions(&dest, perms)?;
93    }
94    #[cfg(windows)]
95    {
96        tracing::debug!(target: "sync_safe_copy",
97            path = %dest.display(),
98            "skipping Unix mode 0o600 on Windows; NTFS DACL default is private-to-user"
99        );
100    }
101
102    output::emit_json(&SyncSafeCopyResponse {
103        source_db_path: paths.db.display().to_string(),
104        dest_path: dest.display().to_string(),
105        bytes_copied,
106        status: "ok".to_string(),
107        elapsed_ms: start.elapsed().as_millis() as u64,
108    })?;
109
110    Ok(())
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116
117    #[test]
118    fn sync_safe_copy_response_serializes_all_fields() {
119        let resp = SyncSafeCopyResponse {
120            source_db_path: "/home/user/.local/share/sqlite-graphrag/db.sqlite".to_string(),
121            dest_path: "/tmp/backup.sqlite".to_string(),
122            bytes_copied: 16384,
123            status: "ok".to_string(),
124            elapsed_ms: 12,
125        };
126        let json = serde_json::to_value(&resp).expect("serialization failed");
127        assert_eq!(
128            json["source_db_path"],
129            "/home/user/.local/share/sqlite-graphrag/db.sqlite"
130        );
131        assert_eq!(json["dest_path"], "/tmp/backup.sqlite");
132        assert_eq!(json["bytes_copied"], 16384u64);
133        assert_eq!(json["status"], "ok");
134        assert_eq!(json["elapsed_ms"], 12u64);
135    }
136
137    #[test]
138    fn sync_safe_copy_rejects_dest_equal_to_source() {
139        let db_path = std::path::PathBuf::from("/tmp/same.sqlite");
140        let args = SyncSafeCopyArgs {
141            dest_positional: None,
142            dest: Some(db_path.clone()),
143            json: false,
144            format: None,
145            db: Some("/tmp/same.sqlite".to_string()),
146        };
147        // Simulates manual path resolution — validates rejection logic
148        let resolved_dest = args
149            .dest_positional
150            .clone()
151            .or_else(|| args.dest.clone())
152            .expect("test must pass dest");
153        let default_db = args.db.as_deref().unwrap_or("");
154        let result = if resolved_dest.as_path() == std::path::Path::new(default_db) {
155            Err(AppError::Validation(
156                "destination path must differ from the source database path".to_string(),
157            ))
158        } else {
159            Ok(())
160        };
161        assert!(result.is_err(), "must reject dest equal to source");
162        if let Err(AppError::Validation(msg)) = result {
163            assert!(msg.contains("destination path must differ"));
164        }
165    }
166
167    #[test]
168    fn sync_safe_copy_response_status_ok() {
169        let resp = SyncSafeCopyResponse {
170            source_db_path: "/data/db.sqlite".to_string(),
171            dest_path: "/backup/db.sqlite".to_string(),
172            bytes_copied: 0,
173            status: "ok".to_string(),
174            elapsed_ms: 0,
175        };
176        let json = serde_json::to_value(&resp).expect("serialization failed");
177        assert_eq!(json["status"], "ok");
178    }
179
180    #[test]
181    fn sync_safe_copy_response_bytes_copied_zero_valid() {
182        let resp = SyncSafeCopyResponse {
183            source_db_path: "/data/db.sqlite".to_string(),
184            dest_path: "/backup/db.sqlite".to_string(),
185            bytes_copied: 0,
186            status: "ok".to_string(),
187            elapsed_ms: 1,
188        };
189        let json = serde_json::to_value(&resp).expect("serialization failed");
190        assert_eq!(json["bytes_copied"], 0u64);
191        assert_eq!(json["elapsed_ms"], 1u64);
192    }
193}