Skip to main content

sc/cli/commands/
sync.rs

1//! Sync command implementations (JSONL export/import).
2//!
3//! Sync operations are project-scoped, using the current working directory
4//! as the project path. JSONL files are written to `<project>/.savecontext/`
5//! so they can be committed to git alongside the project code.
6
7use 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
20/// Execute sync commands.
21pub 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
52/// Get the current project path from the working directory.
53fn 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    // Choose merge strategy based on --force flag
142    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
242// ── Push / Pull ─────────────────────────────────────────────
243
244/// JSONL files produced by sync export.
245const 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
256/// Push local JSONL data to remote host via SCP + SSH import.
257///
258/// Flow: local export (silent) → SCP files to remote → SSH `sc sync import`.
259/// Produces a single JSON output object (no double-output from inner export).
260fn 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    // Step 1: Run local export silently (directly via Exporter, no stdout)
269    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        // Ignore NothingToExport — we'll push whatever files exist
279        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    // Step 2: Determine paths
298    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    // Step 3: Ensure remote directory exists (shell-quoted)
304    ssh_exec(
305        &config,
306        &format!("mkdir -p {}", shell_quote(&remote_export_dir)),
307    )?;
308
309    // Step 4: SCP local JSONL files to remote
310    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    // Step 5: Run import on remote (shell-quoted)
328    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
359/// Pull remote JSONL data from remote host via SSH export + SCP.
360///
361/// Flow: SSH `sc sync export` on remote → SCP `*.jsonl` from remote → local import (silent).
362/// Uses wildcard glob for SCP to tolerate missing JSONL files on remote.
363fn 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    // Step 1: Determine paths
372    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    // Step 2: Run export on remote (shell-quoted)
380    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    // Step 3: Ensure local directory exists
391    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    // Step 4: SCP remote JSONL files to local using wildcard glob.
399    // This tolerates missing files (e.g., no plans.jsonl on remote).
400    let remote_glob = format!("{}*.jsonl", remote_export_dir);
401    scp_from_remote_glob(&remote_glob, &local_export_dir, &config)?;
402
403    // Step 5: Run local import silently (directly via Importer, no stdout)
404    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
448// ── SSH/SCP Helpers ─────────────────────────────────────────
449
450/// Resolve the remote project path from config or per-command override.
451fn 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    // Default: use the same path as local CWD
459    get_project_path()
460}
461
462/// Execute a command on the remote host via SSH.
463fn 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
492/// Collect existing JSONL files from the local export directory.
493fn 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
501/// SCP local files to a remote directory.
502fn 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    // Add local file paths
510    for file in local_files {
511        args.push(file.display().to_string());
512    }
513
514    // Remote destination
515    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
537/// SCP remote files to a local directory using a glob pattern.
538///
539/// Uses a single `user@host:pattern` source to let the remote shell expand
540/// the glob, which tolerates missing files (only transfers what exists).
541fn 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    // Remote source with glob (the remote shell expands the wildcard)
549    args.push(format!("{}@{}:{}", config.user, config.host, remote_glob));
550
551    // Local destination
552    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        // Tolerate "no match" errors — remote may have no JSONL files yet
567        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
580/// SCP a single file from the remote host to a local path.
581fn 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
611/// Execute a command on the remote host and capture stdout.
612fn 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
634/// Compute SHA256 hash of a file, returned as a lowercase hex string.
635fn 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
655// ── Full DB Sync ────────────────────────────────────────────
656
657/// Default remote database path (standard SaveContext location).
658const DEFAULT_REMOTE_DB: &str = "~/.savecontext/data/savecontext.db";
659
660/// Resolve the remote database path from config or use default.
661fn 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
668/// Create a local database backup file.
669///
670/// Uses SQLite's Backup API to produce a consistent snapshot.
671/// Output defaults to `{db_path}.backup` if no path specified.
672fn 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
711/// Push full database to remote host via SQLite backup + SCP.
712///
713/// Flow:
714/// 1. Create local backup via Backup API
715/// 2. SCP backup to remote as `{remote_db}.new`
716/// 3. SSH: integrity check on remote
717/// 4. SSH: timestamped backup of existing remote DB
718/// 5. SSH: atomic replace (mv + remove WAL/SHM)
719/// 6. Clean up local temp file
720fn 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    // Step 1: Create local backup
730    let temp_backup = db.with_extension("db.push-tmp");
731    let storage = SqliteStorage::open(&db)?;
732    storage.backup_to(&temp_backup)?;
733    // Drop storage to release the connection
734    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    // Step 2: SCP backup to remote as {remote_db}.new
748    let remote_staging = format!("{remote_db}.new");
749    // Ensure remote directory exists
750    let remote_dir_cmd = if remote_db.starts_with('~') {
751        // For paths starting with ~, extract the directory part
752        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    // Step 3: Verify transfer via SHA256 checksum
771    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    // Step 4: Timestamped backup of existing remote DB (if it exists)
790    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    // Step 5: Atomic replace
801    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    // Step 6: Clean up local temp file
811    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
837/// Pull full database from remote host via SSH backup + SCP.
838///
839/// Flow:
840/// 1. SSH: create backup on remote via `sc sync backup`
841/// 2. SCP backup file to local temp path
842/// 3. Validate locally (integrity check)
843/// 4. Timestamped backup of existing local DB
844/// 5. Atomic replace (mv + remove WAL/SHM)
845fn 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    // Step 1: Create backup on remote
853    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    // Step 2: SCP backup to local temp
867    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    // Step 3: Validate locally — open and run integrity check
884    {
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    // Step 4: Timestamped backup of existing local DB (if it exists)
902    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    // Step 5: Atomic replace
914    std::fs::rename(&local_temp, &db).map_err(|e| {
915        Error::Other(format!("Failed to replace local database: {e}"))
916    })?;
917    // Remove stale WAL/SHM files
918    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    // Step 6: Clean up remote backup
924    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}