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::config::resolve_db_path;
9use crate::error::{Error, Result};
10use crate::storage::SqliteStorage;
11use crate::sync::{project_export_dir, Exporter, Importer, MergeStrategy};
12use std::env;
13use std::path::PathBuf;
14
15/// Execute sync commands.
16pub fn execute(command: &SyncCommands, db_path: Option<&PathBuf>, json: bool) -> Result<()> {
17    match command {
18        SyncCommands::Export { force } => export(*force, db_path, json),
19        SyncCommands::Import { force } => import(*force, db_path, json),
20        SyncCommands::Status => status(db_path, json),
21    }
22}
23
24/// Get the current project path from the working directory.
25fn get_project_path() -> Result<String> {
26    env::current_dir()
27        .map_err(|e| Error::Other(format!("Failed to get current directory: {e}")))?
28        .to_str()
29        .map(String::from)
30        .ok_or_else(|| Error::Other("Current directory path is not valid UTF-8".to_string()))
31}
32
33fn export(force: bool, db_path: Option<&PathBuf>, json: bool) -> Result<()> {
34    let db_path =
35        resolve_db_path(db_path.map(|p| p.as_path())).ok_or(Error::NotInitialized)?;
36
37    if !db_path.exists() {
38        return Err(Error::NotInitialized);
39    }
40
41    let project_path = get_project_path()?;
42    let mut storage = SqliteStorage::open(&db_path)?;
43    let output_dir = project_export_dir(&project_path);
44
45    let mut exporter = Exporter::new(&mut storage, project_path.clone());
46
47    match exporter.export(force) {
48        Ok(stats) => {
49            if json {
50                let output = serde_json::json!({
51                    "success": true,
52                    "project": project_path,
53                    "output_dir": output_dir.display().to_string(),
54                    "stats": stats,
55                });
56                println!("{}", serde_json::to_string(&output)?);
57            } else if stats.is_empty() {
58                println!("No records exported.");
59            } else {
60                println!("Export complete for: {project_path}");
61                println!();
62                if stats.sessions > 0 {
63                    println!("  Sessions:      {}", stats.sessions);
64                }
65                if stats.issues > 0 {
66                    println!("  Issues:        {}", stats.issues);
67                }
68                if stats.context_items > 0 {
69                    println!("  Context Items: {}", stats.context_items);
70                }
71                if stats.memories > 0 {
72                    println!("  Memories:      {}", stats.memories);
73                }
74                if stats.checkpoints > 0 {
75                    println!("  Checkpoints:   {}", stats.checkpoints);
76                }
77                println!();
78                println!("  Total: {} records", stats.total());
79                println!("  Location: {}", output_dir.display());
80            }
81            Ok(())
82        }
83        Err(crate::sync::SyncError::NothingToExport) => {
84            if json {
85                let output = serde_json::json!({
86                    "error": "nothing_to_export",
87                    "project": project_path,
88                    "message": "No dirty records to export for this project. Use --force to export all records."
89                });
90                println!("{output}");
91            } else {
92                println!("No dirty records to export for: {project_path}");
93                println!("Use --force to export all records regardless of dirty state.");
94            }
95            Ok(())
96        }
97        Err(e) => Err(Error::Other(e.to_string())),
98    }
99}
100
101fn import(force: bool, db_path: Option<&PathBuf>, json: bool) -> Result<()> {
102    let db_path =
103        resolve_db_path(db_path.map(|p| p.as_path())).ok_or(Error::NotInitialized)?;
104
105    if !db_path.exists() {
106        return Err(Error::NotInitialized);
107    }
108
109    let project_path = get_project_path()?;
110    let mut storage = SqliteStorage::open(&db_path)?;
111    let import_dir = project_export_dir(&project_path);
112
113    // Choose merge strategy based on --force flag
114    let strategy = if force {
115        MergeStrategy::PreferExternal
116    } else {
117        MergeStrategy::PreferNewer
118    };
119
120    let mut importer = Importer::new(&mut storage, strategy);
121
122    match importer.import_all(&import_dir) {
123        Ok(stats) => {
124            let total = stats.total_processed();
125            if json {
126                let output = serde_json::json!({
127                    "success": true,
128                    "project": project_path,
129                    "import_dir": import_dir.display().to_string(),
130                    "stats": stats,
131                });
132                println!("{}", serde_json::to_string(&output)?);
133            } else if total == 0 {
134                println!("No records to import for: {project_path}");
135                println!("Export files not found in: {}", import_dir.display());
136            } else {
137                println!("Import complete for: {project_path}");
138                println!();
139                print_entity_stats("Sessions", &stats.sessions);
140                print_entity_stats("Issues", &stats.issues);
141                print_entity_stats("Context Items", &stats.context_items);
142                print_entity_stats("Memories", &stats.memories);
143                print_entity_stats("Checkpoints", &stats.checkpoints);
144                println!();
145                println!(
146                    "Total: {} created, {} updated, {} skipped",
147                    stats.total_created(),
148                    stats.total_updated(),
149                    total - stats.total_created() - stats.total_updated()
150                );
151            }
152            Ok(())
153        }
154        Err(crate::sync::SyncError::FileNotFound(path)) => {
155            if json {
156                let output = serde_json::json!({
157                    "error": "file_not_found",
158                    "project": project_path,
159                    "path": path
160                });
161                println!("{output}");
162            } else {
163                println!("Import file not found: {path}");
164                println!("Run 'sc sync export' first to create JSONL files.");
165            }
166            Ok(())
167        }
168        Err(e) => Err(Error::Other(e.to_string())),
169    }
170}
171
172fn print_entity_stats(name: &str, stats: &crate::sync::EntityStats) {
173    let total = stats.total();
174    if total > 0 {
175        println!(
176            "  {}: {} created, {} updated, {} skipped",
177            name, stats.created, stats.updated, stats.skipped
178        );
179    }
180}
181
182fn status(db_path: Option<&PathBuf>, json: bool) -> Result<()> {
183    let db_path =
184        resolve_db_path(db_path.map(|p| p.as_path())).ok_or(Error::NotInitialized)?;
185
186    if !db_path.exists() {
187        return Err(Error::NotInitialized);
188    }
189
190    let project_path = get_project_path()?;
191    let storage = SqliteStorage::open(&db_path)?;
192    let export_dir = project_export_dir(&project_path);
193
194    let sync_status = crate::sync::get_sync_status(&storage, &export_dir, &project_path)
195        .map_err(|e| Error::Other(e.to_string()))?;
196
197    if json {
198        let output = serde_json::json!({
199            "project": project_path,
200            "export_dir": export_dir.display().to_string(),
201            "status": sync_status,
202        });
203        println!("{}", serde_json::to_string(&output)?);
204    } else {
205        println!("Sync status for: {project_path}");
206        println!("Export directory: {}", export_dir.display());
207        println!();
208        crate::sync::print_status(&sync_status);
209    }
210
211    Ok(())
212}