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