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 {
20 #[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 #[arg(long, alias = "to", alias = "output")]
29 pub dest: Option<std::path::PathBuf>,
30 #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
32 pub json: bool,
33 #[arg(long, value_parser = ["json", "text"], hide = true)]
35 pub format: Option<String>,
36 #[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 elapsed_ms: u64,
49}
50
51pub fn run(args: SyncSafeCopyArgs) -> Result<(), AppError> {
53 let start = std::time::Instant::now();
54 let _ = args.format; 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 #[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 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}