Skip to main content

sc/sync/
export.rs

1//! JSONL export functionality.
2//!
3//! This module handles exporting records to JSONL files for git-based sync.
4//!
5//! # Snapshot Mode
6//!
7//! Exports use **snapshot mode**: the JSONL file represents the current state
8//! of all records, not a log of changes. Git tracks the history.
9//!
10//! # Project Scoping
11//!
12//! Exports are scoped to a specific project path. The JSONL files are written
13//! to `<project>/.savecontext/` so they can be committed to git alongside the
14//! project code.
15//!
16//! # Safety Checks
17//!
18//! Before overwriting, the exporter checks for records that would be "lost"
19//! (exist in JSONL but not in database). Use `--force` to override.
20
21use std::collections::HashSet;
22use std::fs;
23use std::path::{Path, PathBuf};
24
25use chrono::Utc;
26
27use crate::storage::sqlite::SqliteStorage;
28use crate::sync::file::{ensure_gitignore, read_jsonl, write_jsonl};
29use crate::sync::hash::content_hash;
30use crate::sync::types::{
31    CheckpointRecord, ContextItemRecord, DeletionRecord, EntityType, ExportStats, IssueRecord,
32    MemoryRecord, SessionRecord, SyncError, SyncRecord, SyncResult,
33};
34
35/// Exporter for JSONL sync files.
36///
37/// The exporter reads all records from the database for a specific project
38/// and writes them to JSONL files in the project's `.savecontext/` directory.
39/// Uses snapshot mode: files are overwritten with current state (git tracks history).
40pub struct Exporter<'a> {
41    storage: &'a mut SqliteStorage,
42    project_path: String,
43    output_dir: PathBuf,
44}
45
46impl<'a> Exporter<'a> {
47    /// Create a new exporter for a specific project.
48    ///
49    /// # Arguments
50    ///
51    /// * `storage` - Database storage to read from
52    /// * `project_path` - Path to the project (used to filter records)
53    ///
54    /// # Notes
55    ///
56    /// The output directory is automatically set to `<project>/.savecontext/`.
57    #[must_use]
58    pub fn new(storage: &'a mut SqliteStorage, project_path: String) -> Self {
59        let output_dir = project_export_dir(&project_path);
60        Self {
61            storage,
62            project_path,
63            output_dir,
64        }
65    }
66
67    /// Create a new exporter with a custom output directory.
68    ///
69    /// This is primarily for testing purposes.
70    #[must_use]
71    pub fn with_output_dir(
72        storage: &'a mut SqliteStorage,
73        project_path: String,
74        output_dir: PathBuf,
75    ) -> Self {
76        Self {
77            storage,
78            project_path,
79            output_dir,
80        }
81    }
82
83    /// Get the output directory.
84    #[must_use]
85    pub fn output_dir(&self) -> &Path {
86        &self.output_dir
87    }
88
89    /// Export all records to JSONL files (snapshot mode).
90    ///
91    /// This exports all records for the project, overwriting existing files.
92    /// Safety checks prevent accidental data loss unless `force` is true.
93    ///
94    /// # Arguments
95    ///
96    /// * `force` - If true, skip safety checks and overwrite regardless
97    ///
98    /// # Errors
99    ///
100    /// Returns an error if:
101    /// - Database queries fail
102    /// - File writes fail
103    /// - Safety check fails (records would be lost) and force=false
104    pub fn export(&mut self, force: bool) -> SyncResult<ExportStats> {
105        // Ensure output directory exists
106        fs::create_dir_all(&self.output_dir)?;
107
108        // Create .gitignore if it doesn't exist
109        ensure_gitignore(&self.output_dir)?;
110
111        let mut stats = ExportStats::default();
112        let now = Utc::now().to_rfc3339();
113
114        // Export each entity type as a snapshot
115        self.export_sessions_snapshot(&mut stats, &now, force)?;
116        self.export_issues_snapshot(&mut stats, &now, force)?;
117        self.export_context_items_snapshot(&mut stats, &now, force)?;
118        self.export_memory_snapshot(&mut stats, &now, force)?;
119        self.export_checkpoints_snapshot(&mut stats, &now, force)?;
120
121        // Export pending deletions (separate file)
122        self.export_deletions(&mut stats)?;
123
124        // Clear dirty flags after successful export
125        self.clear_all_dirty_flags()?;
126
127        if stats.is_empty() {
128            return Err(SyncError::NothingToExport);
129        }
130
131        Ok(stats)
132    }
133
134    /// Export sessions as a snapshot.
135    fn export_sessions_snapshot(
136        &self,
137        stats: &mut ExportStats,
138        now: &str,
139        force: bool,
140    ) -> SyncResult<()> {
141        let sessions = self
142            .storage
143            .get_sessions_by_project(&self.project_path)
144            .map_err(|e| SyncError::Database(e.to_string()))?;
145
146        if sessions.is_empty() {
147            return Ok(());
148        }
149
150        let path = self.output_dir.join("sessions.jsonl");
151
152        // Safety check: ensure we won't lose records
153        if !force {
154            self.check_for_lost_records(&path, &sessions.iter().map(|s| s.id.clone()).collect())?;
155        }
156
157        // Build records
158        let records: Vec<SyncRecord> = sessions
159            .into_iter()
160            .map(|session| {
161                let hash = content_hash(&session);
162                SyncRecord::Session(SessionRecord {
163                    data: session,
164                    content_hash: hash,
165                    exported_at: now.to_string(),
166                })
167            })
168            .collect();
169
170        stats.sessions = records.len();
171        write_jsonl(&path, &records)?;
172
173        Ok(())
174    }
175
176    /// Export issues as a snapshot.
177    fn export_issues_snapshot(
178        &self,
179        stats: &mut ExportStats,
180        now: &str,
181        force: bool,
182    ) -> SyncResult<()> {
183        let issues = self
184            .storage
185            .get_issues_by_project(&self.project_path)
186            .map_err(|e| SyncError::Database(e.to_string()))?;
187
188        if issues.is_empty() {
189            return Ok(());
190        }
191
192        let path = self.output_dir.join("issues.jsonl");
193
194        // Safety check
195        if !force {
196            self.check_for_lost_records(&path, &issues.iter().map(|i| i.id.clone()).collect())?;
197        }
198
199        let records: Vec<SyncRecord> = issues
200            .into_iter()
201            .map(|issue| {
202                let hash = content_hash(&issue);
203                SyncRecord::Issue(IssueRecord {
204                    data: issue,
205                    content_hash: hash,
206                    exported_at: now.to_string(),
207                })
208            })
209            .collect();
210
211        stats.issues = records.len();
212        write_jsonl(&path, &records)?;
213
214        Ok(())
215    }
216
217    /// Export context items as a snapshot.
218    fn export_context_items_snapshot(
219        &self,
220        stats: &mut ExportStats,
221        now: &str,
222        force: bool,
223    ) -> SyncResult<()> {
224        let items = self
225            .storage
226            .get_context_items_by_project(&self.project_path)
227            .map_err(|e| SyncError::Database(e.to_string()))?;
228
229        if items.is_empty() {
230            return Ok(());
231        }
232
233        let path = self.output_dir.join("context_items.jsonl");
234
235        // Safety check
236        if !force {
237            self.check_for_lost_records(&path, &items.iter().map(|i| i.id.clone()).collect())?;
238        }
239
240        let records: Vec<SyncRecord> = items
241            .into_iter()
242            .map(|item| {
243                let hash = content_hash(&item);
244                SyncRecord::ContextItem(ContextItemRecord {
245                    data: item,
246                    content_hash: hash,
247                    exported_at: now.to_string(),
248                })
249            })
250            .collect();
251
252        stats.context_items = records.len();
253        write_jsonl(&path, &records)?;
254
255        Ok(())
256    }
257
258    /// Export memory items as a snapshot.
259    fn export_memory_snapshot(
260        &self,
261        stats: &mut ExportStats,
262        now: &str,
263        force: bool,
264    ) -> SyncResult<()> {
265        let memories = self
266            .storage
267            .get_memory_by_project(&self.project_path)
268            .map_err(|e| SyncError::Database(e.to_string()))?;
269
270        if memories.is_empty() {
271            return Ok(());
272        }
273
274        let path = self.output_dir.join("memories.jsonl");
275
276        // Safety check
277        if !force {
278            self.check_for_lost_records(&path, &memories.iter().map(|m| m.id.clone()).collect())?;
279        }
280
281        let records: Vec<SyncRecord> = memories
282            .into_iter()
283            .map(|memory| {
284                let hash = content_hash(&memory);
285                SyncRecord::Memory(MemoryRecord {
286                    data: memory,
287                    content_hash: hash,
288                    exported_at: now.to_string(),
289                })
290            })
291            .collect();
292
293        stats.memories = records.len();
294        write_jsonl(&path, &records)?;
295
296        Ok(())
297    }
298
299    /// Export checkpoints as a snapshot.
300    fn export_checkpoints_snapshot(
301        &self,
302        stats: &mut ExportStats,
303        now: &str,
304        force: bool,
305    ) -> SyncResult<()> {
306        let checkpoints = self
307            .storage
308            .get_checkpoints_by_project(&self.project_path)
309            .map_err(|e| SyncError::Database(e.to_string()))?;
310
311        if checkpoints.is_empty() {
312            return Ok(());
313        }
314
315        let path = self.output_dir.join("checkpoints.jsonl");
316
317        // Safety check
318        if !force {
319            self.check_for_lost_records(
320                &path,
321                &checkpoints.iter().map(|c| c.id.clone()).collect(),
322            )?;
323        }
324
325        let records: Vec<SyncRecord> = checkpoints
326            .into_iter()
327            .map(|checkpoint| {
328                let hash = content_hash(&checkpoint);
329                SyncRecord::Checkpoint(CheckpointRecord {
330                    data: checkpoint,
331                    content_hash: hash,
332                    exported_at: now.to_string(),
333                })
334            })
335            .collect();
336
337        stats.checkpoints = records.len();
338        write_jsonl(&path, &records)?;
339
340        Ok(())
341    }
342
343    /// Export deletions to a separate JSONL file.
344    ///
345    /// Unlike entity exports which use snapshot mode, deletions are **cumulative**:
346    /// the file contains all deletions for the project, not just since last export.
347    /// This ensures any importing machine can apply all deletions regardless of
348    /// when it last synced.
349    ///
350    /// Deletions track an `exported` flag so `sync status` can show pending counts.
351    fn export_deletions(&mut self, stats: &mut ExportStats) -> SyncResult<()> {
352        // Get all deletions for this project (not just pending)
353        let deletions = self
354            .storage
355            .get_all_deletions(&self.project_path)
356            .map_err(|e| SyncError::Database(e.to_string()))?;
357
358        if deletions.is_empty() {
359            return Ok(());
360        }
361
362        let path = self.output_dir.join("deletions.jsonl");
363
364        // Convert to DeletionRecord format
365        let records: Vec<DeletionRecord> = deletions
366            .iter()
367            .map(|del| DeletionRecord {
368                entity_type: del.entity_type.parse::<EntityType>().unwrap_or(EntityType::Session),
369                entity_id: del.entity_id.clone(),
370                project_path: del.project_path.clone(),
371                // Convert milliseconds to seconds for chrono
372                deleted_at: chrono::DateTime::from_timestamp(del.deleted_at / 1000, 0)
373                    .map(|dt| dt.to_rfc3339())
374                    .unwrap_or_else(|| del.deleted_at.to_string()),
375                deleted_by: del.deleted_by.clone(),
376            })
377            .collect();
378
379        // Write as JSONL (one deletion per line)
380        let content: String = records
381            .iter()
382            .map(|r| serde_json::to_string(r).unwrap())
383            .collect::<Vec<_>>()
384            .join("\n");
385
386        crate::sync::file::atomic_write(&path, &format!("{content}\n"))?;
387
388        // Count pending deletions (those not yet exported)
389        let pending_ids: Vec<i64> = self
390            .storage
391            .get_pending_deletions(&self.project_path)
392            .map_err(|e| SyncError::Database(e.to_string()))?
393            .iter()
394            .map(|d| d.id)
395            .collect();
396
397        stats.deletions = pending_ids.len();
398
399        // Mark pending deletions as exported
400        if !pending_ids.is_empty() {
401            self.storage
402                .mark_deletions_exported(&pending_ids)
403                .map_err(|e| SyncError::Database(e.to_string()))?;
404        }
405
406        Ok(())
407    }
408
409    /// Check if export would lose records that exist in JSONL but not in database.
410    fn check_for_lost_records(&self, path: &Path, db_ids: &HashSet<String>) -> SyncResult<()> {
411        if !path.exists() {
412            return Ok(());
413        }
414
415        let existing_records = read_jsonl(path)?;
416        let jsonl_ids: HashSet<String> = existing_records
417            .iter()
418            .map(|r| match r {
419                SyncRecord::Session(rec) => rec.data.id.clone(),
420                SyncRecord::Issue(rec) => rec.data.id.clone(),
421                SyncRecord::ContextItem(rec) => rec.data.id.clone(),
422                SyncRecord::Memory(rec) => rec.data.id.clone(),
423                SyncRecord::Checkpoint(rec) => rec.data.id.clone(),
424            })
425            .collect();
426
427        let missing: Vec<_> = jsonl_ids.difference(db_ids).collect();
428
429        if !missing.is_empty() {
430            let preview: Vec<_> = missing.iter().take(5).map(|s| s.as_str()).collect();
431            let more = if missing.len() > 5 {
432                format!(" ... and {} more", missing.len() - 5)
433            } else {
434                String::new()
435            };
436
437            return Err(SyncError::Database(format!(
438                "Export would lose {} record(s) that exist in JSONL but not in database: {}{}\n\
439                 Hint: Run 'sc sync import' first, or use --force to override.",
440                missing.len(),
441                preview.join(", "),
442                more
443            )));
444        }
445
446        Ok(())
447    }
448
449    /// Clear all dirty flags after successful export.
450    fn clear_all_dirty_flags(&mut self) -> SyncResult<()> {
451        let dirty_sessions = self
452            .storage
453            .get_dirty_sessions_by_project(&self.project_path)
454            .map_err(|e| SyncError::Database(e.to_string()))?;
455        let dirty_issues = self
456            .storage
457            .get_dirty_issues_by_project(&self.project_path)
458            .map_err(|e| SyncError::Database(e.to_string()))?;
459        let dirty_items = self
460            .storage
461            .get_dirty_context_items_by_project(&self.project_path)
462            .map_err(|e| SyncError::Database(e.to_string()))?;
463
464        if !dirty_sessions.is_empty() {
465            self.storage
466                .clear_dirty_sessions(&dirty_sessions)
467                .map_err(|e| SyncError::Database(e.to_string()))?;
468        }
469        if !dirty_issues.is_empty() {
470            self.storage
471                .clear_dirty_issues(&dirty_issues)
472                .map_err(|e| SyncError::Database(e.to_string()))?;
473        }
474        if !dirty_items.is_empty() {
475            self.storage
476                .clear_dirty_context_items(&dirty_items)
477                .map_err(|e| SyncError::Database(e.to_string()))?;
478        }
479
480        Ok(())
481    }
482}
483
484/// Get the export directory for a project.
485///
486/// Returns `<project_path>/.savecontext/` which is the standard location
487/// for sync files that can be committed to git.
488#[must_use]
489pub fn project_export_dir(project_path: &str) -> PathBuf {
490    PathBuf::from(project_path).join(".savecontext")
491}
492
493/// Get the default export directory for a database.
494///
495/// **Deprecated**: Use `project_export_dir` instead for project-scoped exports.
496///
497/// Returns the parent directory of the database file, which is typically
498/// `~/.savecontext/data/` for the global database.
499#[must_use]
500pub fn default_export_dir(db_path: &Path) -> PathBuf {
501    db_path
502        .parent()
503        .map(Path::to_path_buf)
504        .unwrap_or_else(|| PathBuf::from("."))
505}
506
507#[cfg(test)]
508mod tests {
509    use super::*;
510    use tempfile::TempDir;
511
512    #[test]
513    fn test_export_empty_database() {
514        let temp_dir = TempDir::new().unwrap();
515        let db_path = temp_dir.path().join("test.db");
516        let mut storage = SqliteStorage::open(&db_path).unwrap();
517        let project_path = temp_dir.path().to_string_lossy().to_string();
518
519        let mut exporter = Exporter::with_output_dir(
520            &mut storage,
521            project_path,
522            temp_dir.path().to_path_buf(),
523        );
524        let result = exporter.export(false);
525
526        // Should error because nothing to export
527        assert!(matches!(result, Err(SyncError::NothingToExport)));
528    }
529
530    #[test]
531    fn test_export_with_session() {
532        let temp_dir = TempDir::new().unwrap();
533        let db_path = temp_dir.path().join("test.db");
534        let mut storage = SqliteStorage::open(&db_path).unwrap();
535        let project_path = "/test/project".to_string();
536
537        // Create a session for this project
538        storage
539            .create_session("sess_1", "Test Session", None, Some(&project_path), None, "test")
540            .unwrap();
541
542        let mut exporter = Exporter::with_output_dir(
543            &mut storage,
544            project_path,
545            temp_dir.path().to_path_buf(),
546        );
547        let stats = exporter.export(false).unwrap();
548
549        assert_eq!(stats.sessions, 1);
550        assert!(temp_dir.path().join("sessions.jsonl").exists());
551    }
552
553    #[test]
554    fn test_export_overwrites_not_appends() {
555        let temp_dir = TempDir::new().unwrap();
556        let db_path = temp_dir.path().join("test.db");
557        let mut storage = SqliteStorage::open(&db_path).unwrap();
558        let project_path = "/test/project".to_string();
559
560        // Create a session
561        storage
562            .create_session("sess_1", "Test Session", None, Some(&project_path), None, "test")
563            .unwrap();
564
565        // First export
566        let mut exporter = Exporter::with_output_dir(
567            &mut storage,
568            project_path.clone(),
569            temp_dir.path().to_path_buf(),
570        );
571        exporter.export(false).unwrap();
572
573        // Count lines
574        let content = fs::read_to_string(temp_dir.path().join("sessions.jsonl")).unwrap();
575        let line_count_1 = content.lines().filter(|l| !l.is_empty()).count();
576        assert_eq!(line_count_1, 1);
577
578        // Second export (should overwrite, not append)
579        let mut exporter = Exporter::with_output_dir(
580            &mut storage,
581            project_path,
582            temp_dir.path().to_path_buf(),
583        );
584        exporter.export(true).unwrap(); // force to bypass dirty check
585
586        // Should still be 1 line, not 2
587        let content = fs::read_to_string(temp_dir.path().join("sessions.jsonl")).unwrap();
588        let line_count_2 = content.lines().filter(|l| !l.is_empty()).count();
589        assert_eq!(line_count_2, 1, "Export should overwrite, not append");
590    }
591
592    #[test]
593    fn test_project_export_dir() {
594        assert_eq!(
595            project_export_dir("/home/user/myproject"),
596            PathBuf::from("/home/user/myproject/.savecontext")
597        );
598        assert_eq!(
599            project_export_dir("/Users/shane/code/app"),
600            PathBuf::from("/Users/shane/code/app/.savecontext")
601        );
602    }
603
604    #[test]
605    fn test_safety_check_prevents_data_loss() {
606        let temp_dir = TempDir::new().unwrap();
607        let db_path = temp_dir.path().join("test.db");
608        let mut storage = SqliteStorage::open(&db_path).unwrap();
609        let project_path = "/test/project".to_string();
610
611        // Create session and export
612        storage
613            .create_session("sess_1", "Test Session", None, Some(&project_path), None, "test")
614            .unwrap();
615
616        let mut exporter = Exporter::with_output_dir(
617            &mut storage,
618            project_path.clone(),
619            temp_dir.path().to_path_buf(),
620        );
621        exporter.export(false).unwrap();
622
623        // Now manually add a record to JSONL that doesn't exist in DB
624        let jsonl_path = temp_dir.path().join("sessions.jsonl");
625        let mut content = fs::read_to_string(&jsonl_path).unwrap();
626        content.push_str(r#"{"type":"session","id":"sess_orphan","name":"Orphan","description":null,"branch":null,"channel":null,"project_path":"/test/project","status":"active","ended_at":null,"created_at":1000,"updated_at":1000,"content_hash":"abc","exported_at":"2025-01-01T00:00:00Z"}"#);
627        content.push('\n');
628        fs::write(&jsonl_path, content).unwrap();
629
630        // Export without force should fail
631        let mut exporter = Exporter::with_output_dir(
632            &mut storage,
633            project_path.clone(),
634            temp_dir.path().to_path_buf(),
635        );
636        let result = exporter.export(false);
637        assert!(result.is_err());
638        assert!(result.unwrap_err().to_string().contains("would lose"));
639
640        // Export with force should succeed
641        let mut exporter = Exporter::with_output_dir(
642            &mut storage,
643            project_path,
644            temp_dir.path().to_path_buf(),
645        );
646        let result = exporter.export(true);
647        assert!(result.is_ok());
648    }
649}