1use crate::cli::SyncCommands;
8use crate::cli::commands::config::{
9 build_scp_base_args, build_ssh_base_args, load_remote_config, shell_quote, RemoteConfig,
10};
11use crate::config::resolve_db_path;
12use crate::error::{Error, Result};
13use crate::storage::SqliteStorage;
14use crate::sync::{project_export_dir, Exporter, Importer, MergeStrategy};
15use std::env;
16use std::path::PathBuf;
17use std::process::Command;
18use tracing::debug;
19
20pub fn execute(command: &SyncCommands, db_path: Option<&PathBuf>, json: bool) -> Result<()> {
22 match command {
23 SyncCommands::Export { force } => export(*force, db_path, json),
24 SyncCommands::Import { force } => import(*force, db_path, json),
25 SyncCommands::Status => status(db_path, json),
26 SyncCommands::Push {
27 force,
28 remote_path,
29 full,
30 } => {
31 if *full {
32 push_full(db_path, json)
33 } else {
34 push(*force, remote_path.as_deref(), db_path, json)
35 }
36 }
37 SyncCommands::Pull {
38 force,
39 remote_path,
40 full,
41 } => {
42 if *full {
43 pull_full(db_path, json)
44 } else {
45 pull(*force, remote_path.as_deref(), db_path, json)
46 }
47 }
48 SyncCommands::Backup { output } => backup(output.as_deref(), db_path, json),
49 }
50}
51
52fn get_project_path() -> Result<String> {
54 env::current_dir()
55 .map_err(|e| Error::Other(format!("Failed to get current directory: {e}")))?
56 .to_str()
57 .map(String::from)
58 .ok_or_else(|| Error::Other("Current directory path is not valid UTF-8".to_string()))
59}
60
61fn export(force: bool, db_path: Option<&PathBuf>, json: bool) -> Result<()> {
62 let db_path =
63 resolve_db_path(db_path.map(|p| p.as_path())).ok_or(Error::NotInitialized)?;
64
65 if !db_path.exists() {
66 return Err(Error::NotInitialized);
67 }
68
69 let project_path = get_project_path()?;
70 let mut storage = SqliteStorage::open(&db_path)?;
71 let output_dir = project_export_dir(&project_path);
72
73 let mut exporter = Exporter::new(&mut storage, project_path.clone());
74
75 match exporter.export(force) {
76 Ok(stats) => {
77 if json {
78 let output = serde_json::json!({
79 "success": true,
80 "project": project_path,
81 "output_dir": output_dir.display().to_string(),
82 "stats": stats,
83 });
84 println!("{}", serde_json::to_string(&output)?);
85 } else if stats.is_empty() {
86 println!("No records exported.");
87 } else {
88 println!("Export complete for: {project_path}");
89 println!();
90 if stats.sessions > 0 {
91 println!(" Sessions: {}", stats.sessions);
92 }
93 if stats.issues > 0 {
94 println!(" Issues: {}", stats.issues);
95 }
96 if stats.context_items > 0 {
97 println!(" Context Items: {}", stats.context_items);
98 }
99 if stats.memories > 0 {
100 println!(" Memories: {}", stats.memories);
101 }
102 if stats.checkpoints > 0 {
103 println!(" Checkpoints: {}", stats.checkpoints);
104 }
105 println!();
106 println!(" Total: {} records", stats.total());
107 println!(" Location: {}", output_dir.display());
108 }
109 Ok(())
110 }
111 Err(crate::sync::SyncError::NothingToExport) => {
112 if json {
113 let output = serde_json::json!({
114 "error": "nothing_to_export",
115 "project": project_path,
116 "message": "No dirty records to export for this project. Use --force to export all records."
117 });
118 println!("{output}");
119 } else {
120 println!("No dirty records to export for: {project_path}");
121 println!("Use --force to export all records regardless of dirty state.");
122 }
123 Ok(())
124 }
125 Err(e) => Err(Error::Other(e.to_string())),
126 }
127}
128
129fn import(force: bool, db_path: Option<&PathBuf>, json: bool) -> Result<()> {
130 let db_path =
131 resolve_db_path(db_path.map(|p| p.as_path())).ok_or(Error::NotInitialized)?;
132
133 if !db_path.exists() {
134 return Err(Error::NotInitialized);
135 }
136
137 let project_path = get_project_path()?;
138 let mut storage = SqliteStorage::open(&db_path)?;
139 let import_dir = project_export_dir(&project_path);
140
141 let strategy = if force {
143 MergeStrategy::PreferExternal
144 } else {
145 MergeStrategy::PreferNewer
146 };
147
148 let mut importer = Importer::new(&mut storage, strategy);
149
150 match importer.import_all(&import_dir) {
151 Ok(stats) => {
152 let total = stats.total_processed();
153 if json {
154 let output = serde_json::json!({
155 "success": true,
156 "project": project_path,
157 "import_dir": import_dir.display().to_string(),
158 "stats": stats,
159 });
160 println!("{}", serde_json::to_string(&output)?);
161 } else if total == 0 {
162 println!("No records to import for: {project_path}");
163 println!("Export files not found in: {}", import_dir.display());
164 } else {
165 println!("Import complete for: {project_path}");
166 println!();
167 print_entity_stats("Sessions", &stats.sessions);
168 print_entity_stats("Issues", &stats.issues);
169 print_entity_stats("Context Items", &stats.context_items);
170 print_entity_stats("Memories", &stats.memories);
171 print_entity_stats("Checkpoints", &stats.checkpoints);
172 println!();
173 println!(
174 "Total: {} created, {} updated, {} skipped",
175 stats.total_created(),
176 stats.total_updated(),
177 total - stats.total_created() - stats.total_updated()
178 );
179 }
180 Ok(())
181 }
182 Err(crate::sync::SyncError::FileNotFound(path)) => {
183 if json {
184 let output = serde_json::json!({
185 "error": "file_not_found",
186 "project": project_path,
187 "path": path
188 });
189 println!("{output}");
190 } else {
191 println!("Import file not found: {path}");
192 println!("Run 'sc sync export' first to create JSONL files.");
193 }
194 Ok(())
195 }
196 Err(e) => Err(Error::Other(e.to_string())),
197 }
198}
199
200fn print_entity_stats(name: &str, stats: &crate::sync::EntityStats) {
201 let total = stats.total();
202 if total > 0 {
203 println!(
204 " {}: {} created, {} updated, {} skipped",
205 name, stats.created, stats.updated, stats.skipped
206 );
207 }
208}
209
210fn status(db_path: Option<&PathBuf>, json: bool) -> Result<()> {
211 let db_path =
212 resolve_db_path(db_path.map(|p| p.as_path())).ok_or(Error::NotInitialized)?;
213
214 if !db_path.exists() {
215 return Err(Error::NotInitialized);
216 }
217
218 let project_path = get_project_path()?;
219 let storage = SqliteStorage::open(&db_path)?;
220 let export_dir = project_export_dir(&project_path);
221
222 let sync_status = crate::sync::get_sync_status(&storage, &export_dir, &project_path)
223 .map_err(|e| Error::Other(e.to_string()))?;
224
225 if json {
226 let output = serde_json::json!({
227 "project": project_path,
228 "export_dir": export_dir.display().to_string(),
229 "status": sync_status,
230 });
231 println!("{}", serde_json::to_string(&output)?);
232 } else {
233 println!("Sync status for: {project_path}");
234 println!("Export directory: {}", export_dir.display());
235 println!();
236 crate::sync::print_status(&sync_status);
237 }
238
239 Ok(())
240}
241
242const JSONL_FILES: &[&str] = &[
246 "sessions.jsonl",
247 "issues.jsonl",
248 "context_items.jsonl",
249 "memories.jsonl",
250 "checkpoints.jsonl",
251 "plans.jsonl",
252 "time_entries.jsonl",
253 "deletions.jsonl",
254];
255
256fn push(
261 force: bool,
262 remote_path: Option<&str>,
263 db_path: Option<&PathBuf>,
264 json: bool,
265) -> Result<()> {
266 let config = load_remote_config()?;
267
268 let db = resolve_db_path(db_path.map(|p| p.as_path())).ok_or(Error::NotInitialized)?;
270 if !db.exists() {
271 return Err(Error::NotInitialized);
272 }
273 let project_path = get_project_path()?;
274 let local_export_dir = project_export_dir(&project_path);
275 {
276 let mut storage = SqliteStorage::open(&db)?;
277 let mut exporter = Exporter::new(&mut storage, project_path.clone());
278 match exporter.export(force) {
280 Ok(stats) => {
281 if !json {
282 let total = stats.total();
283 if total > 0 {
284 println!("Exported {total} records locally.");
285 }
286 }
287 }
288 Err(crate::sync::SyncError::NothingToExport) => {
289 if !json {
290 println!("No new records to export (pushing existing files).");
291 }
292 }
293 Err(e) => return Err(Error::Other(e.to_string())),
294 }
295 }
296
297 let remote_project = resolve_remote_project(&config, remote_path)?;
299 let remote_export_dir = format!("{remote_project}/.savecontext/");
300
301 debug!(local = %local_export_dir.display(), remote = %remote_export_dir, "Push paths");
302
303 ssh_exec(
305 &config,
306 &format!("mkdir -p {}", shell_quote(&remote_export_dir)),
307 )?;
308
309 let files_to_push = collect_jsonl_files(&local_export_dir);
311 if files_to_push.is_empty() {
312 if json {
313 let output = serde_json::json!({
314 "success": true,
315 "message": "No JSONL files to push",
316 "project": project_path,
317 });
318 println!("{}", serde_json::to_string(&output)?);
319 } else {
320 println!("No JSONL files to push.");
321 }
322 return Ok(());
323 }
324
325 scp_to_remote(&files_to_push, &remote_export_dir, &config)?;
326
327 let force_flag = if force { " --force" } else { "" };
329 let sc_path = config.remote_sc_path.as_deref().unwrap_or("sc");
330 let import_cmd = format!(
331 "cd {} && {} sync import{}",
332 shell_quote(&remote_project),
333 shell_quote(sc_path),
334 force_flag,
335 );
336 ssh_exec(&config, &import_cmd)?;
337
338 if json {
339 let output = serde_json::json!({
340 "success": true,
341 "files_pushed": files_to_push.len(),
342 "local_project": project_path,
343 "remote_project": remote_project,
344 });
345 println!("{}", serde_json::to_string(&output)?);
346 } else {
347 println!(
348 "Push complete: {} files -> {}@{}:{}",
349 files_to_push.len(),
350 config.user,
351 config.host,
352 remote_export_dir
353 );
354 }
355
356 Ok(())
357}
358
359fn pull(
364 force: bool,
365 remote_path: Option<&str>,
366 db_path: Option<&PathBuf>,
367 json: bool,
368) -> Result<()> {
369 let config = load_remote_config()?;
370
371 let project_path = get_project_path()?;
373 let local_export_dir = project_export_dir(&project_path);
374 let remote_project = resolve_remote_project(&config, remote_path)?;
375 let remote_export_dir = format!("{remote_project}/.savecontext/");
376
377 debug!(local = %local_export_dir.display(), remote = %remote_export_dir, "Pull paths");
378
379 let force_flag = if force { " --force" } else { "" };
381 let sc_path = config.remote_sc_path.as_deref().unwrap_or("sc");
382 let export_cmd = format!(
383 "cd {} && {} sync export{}",
384 shell_quote(&remote_project),
385 shell_quote(sc_path),
386 force_flag,
387 );
388 ssh_exec(&config, &export_cmd)?;
389
390 std::fs::create_dir_all(&local_export_dir).map_err(|e| {
392 Error::Other(format!(
393 "Failed to create local export directory {}: {e}",
394 local_export_dir.display()
395 ))
396 })?;
397
398 let remote_glob = format!("{}*.jsonl", remote_export_dir);
401 scp_from_remote_glob(&remote_glob, &local_export_dir, &config)?;
402
403 let db = resolve_db_path(db_path.map(|p| p.as_path())).ok_or(Error::NotInitialized)?;
405 if !db.exists() {
406 return Err(Error::NotInitialized);
407 }
408 let strategy = if force {
409 MergeStrategy::PreferExternal
410 } else {
411 MergeStrategy::PreferNewer
412 };
413 let import_stats = {
414 let mut storage = SqliteStorage::open(&db)?;
415 let mut importer = Importer::new(&mut storage, strategy);
416 importer
417 .import_all(&local_export_dir)
418 .map_err(|e| Error::Other(e.to_string()))?
419 };
420
421 if json {
422 let output = serde_json::json!({
423 "success": true,
424 "remote_project": remote_project,
425 "local_project": project_path,
426 "import_stats": import_stats,
427 });
428 println!("{}", serde_json::to_string(&output)?);
429 } else {
430 let total = import_stats.total_processed();
431 println!(
432 "Pull complete: {}@{}:{} -> local",
433 config.user, config.host, remote_export_dir
434 );
435 if total > 0 {
436 println!(
437 " {} created, {} updated, {} skipped",
438 import_stats.total_created(),
439 import_stats.total_updated(),
440 total - import_stats.total_created() - import_stats.total_updated()
441 );
442 }
443 }
444
445 Ok(())
446}
447
448fn resolve_remote_project(config: &RemoteConfig, override_path: Option<&str>) -> Result<String> {
452 if let Some(path) = override_path {
453 return Ok(path.to_string());
454 }
455 if let Some(ref path) = config.remote_project_path {
456 return Ok(path.clone());
457 }
458 get_project_path()
460}
461
462fn ssh_exec(config: &RemoteConfig, remote_cmd: &str) -> Result<()> {
464 let mut args = build_ssh_base_args(config);
465 args.push(remote_cmd.to_string());
466
467 debug!(ssh_args = ?args, "SSH exec");
468
469 let output = Command::new("ssh")
470 .args(&args)
471 .output()
472 .map_err(|e| {
473 Error::Remote(format!("Failed to execute ssh: {e}. Is ssh installed?"))
474 })?;
475
476 if !output.stderr.is_empty() {
477 let stderr = String::from_utf8_lossy(&output.stderr);
478 debug!(stderr = %stderr, "SSH stderr");
479 }
480
481 if !output.status.success() {
482 let code = output.status.code().unwrap_or(1);
483 let stderr = String::from_utf8_lossy(&output.stderr);
484 return Err(Error::Remote(format!(
485 "Remote command failed (exit {code}): {stderr}"
486 )));
487 }
488
489 Ok(())
490}
491
492fn collect_jsonl_files(dir: &std::path::Path) -> Vec<PathBuf> {
494 JSONL_FILES
495 .iter()
496 .map(|f| dir.join(f))
497 .filter(|p| p.exists())
498 .collect()
499}
500
501fn scp_to_remote(
503 local_files: &[PathBuf],
504 remote_dir: &str,
505 config: &RemoteConfig,
506) -> Result<()> {
507 let mut args = build_scp_base_args(config);
508
509 for file in local_files {
511 args.push(file.display().to_string());
512 }
513
514 args.push(format!("{}@{}:{}", config.user, config.host, remote_dir));
516
517 debug!(scp_args = ?args, "SCP to remote");
518
519 let output = Command::new("scp")
520 .args(&args)
521 .output()
522 .map_err(|e| {
523 Error::Remote(format!("Failed to execute scp: {e}. Is scp installed?"))
524 })?;
525
526 if !output.status.success() {
527 let code = output.status.code().unwrap_or(1);
528 let stderr = String::from_utf8_lossy(&output.stderr);
529 return Err(Error::Remote(format!(
530 "SCP push failed (exit {code}): {stderr}"
531 )));
532 }
533
534 Ok(())
535}
536
537fn scp_from_remote_glob(
542 remote_glob: &str,
543 local_dir: &std::path::Path,
544 config: &RemoteConfig,
545) -> Result<()> {
546 let mut args = build_scp_base_args(config);
547
548 args.push(format!("{}@{}:{}", config.user, config.host, remote_glob));
550
551 args.push(local_dir.display().to_string());
553
554 debug!(scp_args = ?args, "SCP from remote (glob)");
555
556 let output = Command::new("scp")
557 .args(&args)
558 .output()
559 .map_err(|e| {
560 Error::Remote(format!("Failed to execute scp: {e}. Is scp installed?"))
561 })?;
562
563 if !output.status.success() {
564 let code = output.status.code().unwrap_or(1);
565 let stderr = String::from_utf8_lossy(&output.stderr);
566 let stderr_lower = stderr.to_lowercase();
568 if stderr_lower.contains("no match") || stderr_lower.contains("no such file") {
569 debug!("SCP glob found no files on remote — this is OK for first pull");
570 return Ok(());
571 }
572 return Err(Error::Remote(format!(
573 "SCP pull failed (exit {code}): {stderr}"
574 )));
575 }
576
577 Ok(())
578}
579
580fn scp_from_remote(
582 remote_path: &str,
583 local_path: &std::path::Path,
584 config: &RemoteConfig,
585) -> Result<()> {
586 let mut args = build_scp_base_args(config);
587 args.push(format!(
588 "{}@{}:{}",
589 config.user, config.host, remote_path
590 ));
591 args.push(local_path.display().to_string());
592
593 debug!(scp_args = ?args, "SCP from remote (single file)");
594
595 let output = Command::new("scp")
596 .args(&args)
597 .output()
598 .map_err(|e| Error::Remote(format!("Failed to execute scp: {e}")))?;
599
600 if !output.status.success() {
601 let code = output.status.code().unwrap_or(1);
602 let stderr = String::from_utf8_lossy(&output.stderr);
603 return Err(Error::Remote(format!(
604 "SCP pull failed (exit {code}): {stderr}"
605 )));
606 }
607
608 Ok(())
609}
610
611fn ssh_exec_output(config: &RemoteConfig, remote_cmd: &str) -> Result<String> {
613 let mut args = build_ssh_base_args(config);
614 args.push(remote_cmd.to_string());
615
616 debug!(ssh_args = ?args, "SSH exec (capture output)");
617
618 let output = Command::new("ssh")
619 .args(&args)
620 .output()
621 .map_err(|e| Error::Remote(format!("Failed to execute ssh: {e}")))?;
622
623 if !output.status.success() {
624 let code = output.status.code().unwrap_or(1);
625 let stderr = String::from_utf8_lossy(&output.stderr);
626 return Err(Error::Remote(format!(
627 "Remote command failed (exit {code}): {stderr}"
628 )));
629 }
630
631 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
632}
633
634fn file_sha256(path: &std::path::Path) -> Result<String> {
636 use sha2::{Digest, Sha256};
637 use std::io::Read;
638
639 let mut file = std::fs::File::open(path)
640 .map_err(|e| Error::Other(format!("Failed to open {}: {e}", path.display())))?;
641 let mut hasher = Sha256::new();
642 let mut buf = [0u8; 8192];
643 loop {
644 let n = file
645 .read(&mut buf)
646 .map_err(|e| Error::Other(format!("Failed to read {}: {e}", path.display())))?;
647 if n == 0 {
648 break;
649 }
650 hasher.update(&buf[..n]);
651 }
652 Ok(format!("{:x}", hasher.finalize()))
653}
654
655const DEFAULT_REMOTE_DB: &str = "~/.savecontext/data/savecontext.db";
659
660fn resolve_remote_db(config: &RemoteConfig) -> String {
662 config
663 .remote_db_path
664 .clone()
665 .unwrap_or_else(|| DEFAULT_REMOTE_DB.to_string())
666}
667
668fn backup(output: Option<&str>, db_path: Option<&PathBuf>, json: bool) -> Result<()> {
673 let db = resolve_db_path(db_path.map(|p| p.as_path())).ok_or(Error::NotInitialized)?;
674 if !db.exists() {
675 return Err(Error::NotInitialized);
676 }
677
678 let dest = match output {
679 Some(p) => PathBuf::from(p),
680 None => {
681 let mut p = db.clone();
682 p.set_extension("db.backup");
683 p
684 }
685 };
686
687 let storage = SqliteStorage::open(&db)?;
688 storage.backup_to(&dest)?;
689
690 let file_size = std::fs::metadata(&dest)
691 .map(|m| m.len())
692 .unwrap_or(0);
693
694 if json {
695 let out = serde_json::json!({
696 "success": true,
697 "source": db.display().to_string(),
698 "backup": dest.display().to_string(),
699 "size_bytes": file_size,
700 });
701 println!("{}", serde_json::to_string(&out)?);
702 } else {
703 println!("Backup created: {}", dest.display());
704 println!(" Source: {}", db.display());
705 println!(" Size: {:.2} MB", file_size as f64 / 1_048_576.0);
706 }
707
708 Ok(())
709}
710
711fn push_full(db_path: Option<&PathBuf>, json: bool) -> Result<()> {
721 let config = load_remote_config()?;
722 let db = resolve_db_path(db_path.map(|p| p.as_path())).ok_or(Error::NotInitialized)?;
723 if !db.exists() {
724 return Err(Error::NotInitialized);
725 }
726
727 let remote_db = resolve_remote_db(&config);
728
729 let temp_backup = db.with_extension("db.push-tmp");
731 let storage = SqliteStorage::open(&db)?;
732 storage.backup_to(&temp_backup)?;
733 drop(storage);
735
736 let file_size = std::fs::metadata(&temp_backup)
737 .map(|m| m.len())
738 .unwrap_or(0);
739
740 if !json {
741 println!(
742 "Created local backup ({:.2} MB)",
743 file_size as f64 / 1_048_576.0
744 );
745 }
746
747 let remote_staging = format!("{remote_db}.new");
749 let remote_dir_cmd = if remote_db.starts_with('~') {
751 format!(
753 "mkdir -p $(dirname {})",
754 shell_quote(&remote_db)
755 )
756 } else {
757 format!(
758 "mkdir -p $(dirname {})",
759 shell_quote(&remote_db)
760 )
761 };
762 ssh_exec(&config, &remote_dir_cmd)?;
763
764 scp_to_remote(&[temp_backup.clone()], &remote_staging, &config)?;
765
766 if !json {
767 println!("Uploaded to {}@{}:{}", config.user, config.host, remote_staging);
768 }
769
770 let local_hash = file_sha256(&temp_backup)?;
772 let hash_cmd = format!("sha256sum {} | cut -d' ' -f1", shell_quote(&remote_staging));
773 let remote_hash = ssh_exec_output(&config, &hash_cmd)?;
774 if local_hash != remote_hash {
775 let _ = ssh_exec(
776 &config,
777 &format!("rm -f {}", shell_quote(&remote_staging)),
778 );
779 let _ = std::fs::remove_file(&temp_backup);
780 return Err(Error::Remote(format!(
781 "Checksum mismatch: local={local_hash}, remote={remote_hash}"
782 )));
783 }
784
785 if !json {
786 println!("Checksum verified: {local_hash}");
787 }
788
789 let timestamp = chrono::Local::now().format("%Y%m%d-%H%M%S");
791 let pre_push_backup = format!("{remote_db}.pre-push-{timestamp}");
792 let backup_cmd = format!(
793 "test -f {} && cp {} {} || true",
794 shell_quote(&remote_db),
795 shell_quote(&remote_db),
796 shell_quote(&pre_push_backup),
797 );
798 ssh_exec(&config, &backup_cmd)?;
799
800 let replace_cmd = format!(
802 "mv {} {} && rm -f {}-wal {}-shm",
803 shell_quote(&remote_staging),
804 shell_quote(&remote_db),
805 shell_quote(&remote_db),
806 shell_quote(&remote_db),
807 );
808 ssh_exec(&config, &replace_cmd)?;
809
810 let _ = std::fs::remove_file(&temp_backup);
812
813 if json {
814 let out = serde_json::json!({
815 "success": true,
816 "mode": "full",
817 "size_bytes": file_size,
818 "remote_db": remote_db,
819 "remote_host": format!("{}@{}:{}", config.user, config.host, config.port),
820 "pre_push_backup": pre_push_backup,
821 });
822 println!("{}", serde_json::to_string(&out)?);
823 } else {
824 println!(
825 "Full push complete: {:.2} MB -> {}@{}:{}",
826 file_size as f64 / 1_048_576.0,
827 config.user,
828 config.host,
829 remote_db
830 );
831 println!(" Remote backup: {pre_push_backup}");
832 }
833
834 Ok(())
835}
836
837fn pull_full(db_path: Option<&PathBuf>, json: bool) -> Result<()> {
846 let config = load_remote_config()?;
847 let db = resolve_db_path(db_path.map(|p| p.as_path())).ok_or(Error::NotInitialized)?;
848
849 let remote_db = resolve_remote_db(&config);
850 let sc_path = config.remote_sc_path.as_deref().unwrap_or("sc");
851
852 let backup_path = format!("{remote_db}.backup");
854 let backup_cmd = format!(
855 "{} --db {} sync backup --output {} --json",
856 shell_quote(sc_path),
857 shell_quote(&remote_db),
858 shell_quote(&backup_path),
859 );
860
861 if !json {
862 println!("Creating backup on remote...");
863 }
864 ssh_exec(&config, &backup_cmd)?;
865
866 let local_temp = db.with_extension("db.pull-tmp");
868 scp_from_remote(&backup_path, &local_temp, &config)?;
869
870 let file_size = std::fs::metadata(&local_temp)
871 .map(|m| m.len())
872 .unwrap_or(0);
873
874 if !json {
875 println!(
876 "Downloaded {:.2} MB from {}@{}",
877 file_size as f64 / 1_048_576.0,
878 config.user,
879 config.host
880 );
881 }
882
883 {
885 let check_storage = SqliteStorage::open(&local_temp)?;
886 let result: String = check_storage
887 .conn()
888 .query_row("PRAGMA integrity_check", [], |row| row.get(0))?;
889 if result != "ok" {
890 let _ = std::fs::remove_file(&local_temp);
891 return Err(Error::Other(format!(
892 "Downloaded database failed integrity check: {result}"
893 )));
894 }
895 }
896
897 if !json {
898 println!("Local integrity check passed.");
899 }
900
901 let timestamp = chrono::Local::now().format("%Y%m%d-%H%M%S");
903 let pre_pull_backup = db.with_extension(format!("db.pre-pull-{timestamp}"));
904 if db.exists() {
905 std::fs::copy(&db, &pre_pull_backup).map_err(|e| {
906 Error::Other(format!(
907 "Failed to create local backup at {}: {e}",
908 pre_pull_backup.display()
909 ))
910 })?;
911 }
912
913 std::fs::rename(&local_temp, &db).map_err(|e| {
915 Error::Other(format!("Failed to replace local database: {e}"))
916 })?;
917 let wal = db.with_extension("db-wal");
919 let shm = db.with_extension("db-shm");
920 let _ = std::fs::remove_file(&wal);
921 let _ = std::fs::remove_file(&shm);
922
923 let cleanup_cmd = format!("rm -f {}", shell_quote(&backup_path));
925 let _ = ssh_exec(&config, &cleanup_cmd);
926
927 if json {
928 let out = serde_json::json!({
929 "success": true,
930 "mode": "full",
931 "size_bytes": file_size,
932 "remote_db": remote_db,
933 "remote_host": format!("{}@{}:{}", config.user, config.host, config.port),
934 "local_backup": pre_pull_backup.display().to_string(),
935 });
936 println!("{}", serde_json::to_string(&out)?);
937 } else {
938 println!(
939 "Full pull complete: {}@{}:{} -> {}",
940 config.user,
941 config.host,
942 remote_db,
943 db.display()
944 );
945 println!(" Size: {:.2} MB", file_size as f64 / 1_048_576.0);
946 if pre_pull_backup.exists() {
947 println!(" Local backup: {}", pre_pull_backup.display());
948 }
949 }
950
951 Ok(())
952}