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::{errors_msg, validation};
5use crate::output;
6use crate::paths::AppPaths;
7use crate::storage::connection::open_rw;
8use serde::Serialize;
9
10#[derive(clap::Args)]
11pub struct SyncSafeCopyArgs {
12    /// Snapshot destination path. Also accepts the aliases `--to` and `--output`.
13    #[arg(long, alias = "to", alias = "output")]
14    pub dest: std::path::PathBuf,
15    #[arg(long, help = "No-op; JSON is always emitted on stdout")]
16    pub json: bool,
17    /// Output format: `json` or `text`. JSON is always emitted on stdout regardless of the value.
18    #[arg(long, value_parser = ["json", "text"], hide = true)]
19    pub format: Option<String>,
20    #[arg(long, env = "SQLITE_GRAPHRAG_DB_PATH")]
21    pub db: Option<String>,
22}
23
24#[derive(Serialize)]
25struct SyncSafeCopyResponse {
26    source_db_path: String,
27    dest_path: String,
28    bytes_copied: u64,
29    status: String,
30    /// Total execution time in milliseconds from handler start to serialisation.
31    elapsed_ms: u64,
32}
33
34pub fn run(args: SyncSafeCopyArgs) -> Result<(), AppError> {
35    let inicio = std::time::Instant::now();
36    let _ = args.format; // --format é no-op; JSON sempre emitido no stdout
37    let paths = AppPaths::resolve(args.db.as_deref())?;
38
39    if !paths.db.exists() {
40        return Err(AppError::NotFound(errors_msg::database_not_found(
41            &paths.db.display().to_string(),
42        )));
43    }
44
45    if args.dest == paths.db {
46        return Err(AppError::Validation(
47            validation::sync_destination_equals_source(),
48        ));
49    }
50
51    if let Some(parent) = args.dest.parent() {
52        std::fs::create_dir_all(parent)?;
53    }
54
55    let conn = open_rw(&paths.db)?;
56    conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
57    drop(conn);
58
59    let bytes_copied = std::fs::copy(&paths.db, &args.dest)?;
60
61    // Aplica permissões 600 no snapshot em Unix para evitar vazamento em Dropbox/NFS compartilhado.
62    #[cfg(unix)]
63    {
64        use std::os::unix::fs::PermissionsExt;
65        let mut perms = std::fs::metadata(&args.dest)?.permissions();
66        perms.set_mode(0o600);
67        std::fs::set_permissions(&args.dest, perms)?;
68    }
69
70    output::emit_json(&SyncSafeCopyResponse {
71        source_db_path: paths.db.display().to_string(),
72        dest_path: args.dest.display().to_string(),
73        bytes_copied,
74        status: "ok".to_string(),
75        elapsed_ms: inicio.elapsed().as_millis() as u64,
76    })?;
77
78    Ok(())
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84
85    #[test]
86    fn sync_safe_copy_response_serializa_todos_campos() {
87        let resp = SyncSafeCopyResponse {
88            source_db_path: "/home/user/.local/share/sqlite-graphrag/db.sqlite".to_string(),
89            dest_path: "/tmp/backup.sqlite".to_string(),
90            bytes_copied: 16384,
91            status: "ok".to_string(),
92            elapsed_ms: 12,
93        };
94        let json = serde_json::to_value(&resp).expect("serialização falhou");
95        assert_eq!(
96            json["source_db_path"],
97            "/home/user/.local/share/sqlite-graphrag/db.sqlite"
98        );
99        assert_eq!(json["dest_path"], "/tmp/backup.sqlite");
100        assert_eq!(json["bytes_copied"], 16384u64);
101        assert_eq!(json["status"], "ok");
102        assert_eq!(json["elapsed_ms"], 12u64);
103    }
104
105    #[test]
106    fn sync_safe_copy_rejeita_dest_igual_source() {
107        let db_path = std::path::PathBuf::from("/tmp/mesmo.sqlite");
108        let args = SyncSafeCopyArgs {
109            dest: db_path.clone(),
110            json: false,
111            format: None,
112            db: Some("/tmp/mesmo.sqlite".to_string()),
113        };
114        // Simula resolução manual do caminho — valida lógica de rejeição
115        let resultado = if args.dest == std::path::PathBuf::from(args.db.as_deref().unwrap_or("")) {
116            Err(AppError::Validation(
117                "destination path must differ from the source database path".to_string(),
118            ))
119        } else {
120            Ok(())
121        };
122        assert!(resultado.is_err(), "deve rejeitar dest igual ao source");
123        if let Err(AppError::Validation(msg)) = resultado {
124            assert!(msg.contains("destination path must differ"));
125        }
126    }
127
128    #[test]
129    fn sync_safe_copy_response_status_ok() {
130        let resp = SyncSafeCopyResponse {
131            source_db_path: "/data/db.sqlite".to_string(),
132            dest_path: "/backup/db.sqlite".to_string(),
133            bytes_copied: 0,
134            status: "ok".to_string(),
135            elapsed_ms: 0,
136        };
137        let json = serde_json::to_value(&resp).expect("serialização falhou");
138        assert_eq!(json["status"], "ok");
139    }
140
141    #[test]
142    fn sync_safe_copy_response_bytes_copied_zero_valido() {
143        let resp = SyncSafeCopyResponse {
144            source_db_path: "/data/db.sqlite".to_string(),
145            dest_path: "/backup/db.sqlite".to_string(),
146            bytes_copied: 0,
147            status: "ok".to_string(),
148            elapsed_ms: 1,
149        };
150        let json = serde_json::to_value(&resp).expect("serialização falhou");
151        assert_eq!(json["bytes_copied"], 0u64);
152        assert_eq!(json["elapsed_ms"], 1u64);
153    }
154}