sqlite_graphrag/commands/
sync_safe_copy.rs1use 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 #[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 #[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 elapsed_ms: u64,
39}
40
41pub fn run(args: SyncSafeCopyArgs) -> Result<(), AppError> {
42 let start = std::time::Instant::now();
43 let _ = args.format; 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 #[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 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}