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, PlanRecord, 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        self.export_plans_snapshot(&mut stats, &now, force)?;
121
122        // Export pending deletions (separate file)
123        self.export_deletions(&mut stats)?;
124
125        // Clear dirty flags after successful export
126        self.clear_all_dirty_flags()?;
127
128        if stats.is_empty() {
129            return Err(SyncError::NothingToExport);
130        }
131
132        Ok(stats)
133    }
134
135    /// Export sessions as a snapshot.
136    fn export_sessions_snapshot(
137        &self,
138        stats: &mut ExportStats,
139        now: &str,
140        force: bool,
141    ) -> SyncResult<()> {
142        let sessions = self
143            .storage
144            .get_sessions_by_project(&self.project_path)
145            .map_err(|e| SyncError::Database(e.to_string()))?;
146
147        if sessions.is_empty() {
148            return Ok(());
149        }
150
151        let path = self.output_dir.join("sessions.jsonl");
152
153        // Safety check: ensure we won't lose records
154        if !force {
155            self.check_for_lost_records(&path, &sessions.iter().map(|s| s.id.clone()).collect())?;
156        }
157
158        // Build records
159        let records: Vec<SyncRecord> = sessions
160            .into_iter()
161            .map(|session| {
162                let hash = content_hash(&session);
163                SyncRecord::Session(SessionRecord {
164                    data: session,
165                    content_hash: hash,
166                    exported_at: now.to_string(),
167                })
168            })
169            .collect();
170
171        stats.sessions = records.len();
172        write_jsonl(&path, &records)?;
173
174        Ok(())
175    }
176
177    /// Export issues as a snapshot.
178    fn export_issues_snapshot(
179        &self,
180        stats: &mut ExportStats,
181        now: &str,
182        force: bool,
183    ) -> SyncResult<()> {
184        let issues = self
185            .storage
186            .get_issues_by_project(&self.project_path)
187            .map_err(|e| SyncError::Database(e.to_string()))?;
188
189        if issues.is_empty() {
190            return Ok(());
191        }
192
193        let path = self.output_dir.join("issues.jsonl");
194
195        // Safety check
196        if !force {
197            self.check_for_lost_records(&path, &issues.iter().map(|i| i.id.clone()).collect())?;
198        }
199
200        let records: Vec<SyncRecord> = issues
201            .into_iter()
202            .map(|issue| {
203                let hash = content_hash(&issue);
204                SyncRecord::Issue(IssueRecord {
205                    data: issue,
206                    content_hash: hash,
207                    exported_at: now.to_string(),
208                })
209            })
210            .collect();
211
212        stats.issues = records.len();
213        write_jsonl(&path, &records)?;
214
215        Ok(())
216    }
217
218    /// Export context items as a snapshot.
219    fn export_context_items_snapshot(
220        &self,
221        stats: &mut ExportStats,
222        now: &str,
223        force: bool,
224    ) -> SyncResult<()> {
225        let items = self
226            .storage
227            .get_context_items_by_project(&self.project_path)
228            .map_err(|e| SyncError::Database(e.to_string()))?;
229
230        if items.is_empty() {
231            return Ok(());
232        }
233
234        let path = self.output_dir.join("context_items.jsonl");
235
236        // Safety check
237        if !force {
238            self.check_for_lost_records(&path, &items.iter().map(|i| i.id.clone()).collect())?;
239        }
240
241        let records: Vec<SyncRecord> = items
242            .into_iter()
243            .map(|item| {
244                let hash = content_hash(&item);
245                SyncRecord::ContextItem(ContextItemRecord {
246                    data: item,
247                    content_hash: hash,
248                    exported_at: now.to_string(),
249                })
250            })
251            .collect();
252
253        stats.context_items = records.len();
254        write_jsonl(&path, &records)?;
255
256        Ok(())
257    }
258
259    /// Export memory items as a snapshot.
260    fn export_memory_snapshot(
261        &self,
262        stats: &mut ExportStats,
263        now: &str,
264        force: bool,
265    ) -> SyncResult<()> {
266        let memories = self
267            .storage
268            .get_memory_by_project(&self.project_path)
269            .map_err(|e| SyncError::Database(e.to_string()))?;
270
271        if memories.is_empty() {
272            return Ok(());
273        }
274
275        let path = self.output_dir.join("memories.jsonl");
276
277        // Safety check
278        if !force {
279            self.check_for_lost_records(&path, &memories.iter().map(|m| m.id.clone()).collect())?;
280        }
281
282        let records: Vec<SyncRecord> = memories
283            .into_iter()
284            .map(|memory| {
285                let hash = content_hash(&memory);
286                SyncRecord::Memory(MemoryRecord {
287                    data: memory,
288                    content_hash: hash,
289                    exported_at: now.to_string(),
290                })
291            })
292            .collect();
293
294        stats.memories = records.len();
295        write_jsonl(&path, &records)?;
296
297        Ok(())
298    }
299
300    /// Export checkpoints as a snapshot.
301    fn export_checkpoints_snapshot(
302        &self,
303        stats: &mut ExportStats,
304        now: &str,
305        force: bool,
306    ) -> SyncResult<()> {
307        let checkpoints = self
308            .storage
309            .get_checkpoints_by_project(&self.project_path)
310            .map_err(|e| SyncError::Database(e.to_string()))?;
311
312        if checkpoints.is_empty() {
313            return Ok(());
314        }
315
316        let path = self.output_dir.join("checkpoints.jsonl");
317
318        // Safety check
319        if !force {
320            self.check_for_lost_records(
321                &path,
322                &checkpoints.iter().map(|c| c.id.clone()).collect(),
323            )?;
324        }
325
326        let records: Vec<SyncRecord> = checkpoints
327            .into_iter()
328            .map(|checkpoint| {
329                let hash = content_hash(&checkpoint);
330                SyncRecord::Checkpoint(CheckpointRecord {
331                    data: checkpoint,
332                    content_hash: hash,
333                    exported_at: now.to_string(),
334                })
335            })
336            .collect();
337
338        stats.checkpoints = records.len();
339        write_jsonl(&path, &records)?;
340
341        Ok(())
342    }
343
344    /// Export plans as a snapshot.
345    fn export_plans_snapshot(
346        &self,
347        stats: &mut ExportStats,
348        now: &str,
349        force: bool,
350    ) -> SyncResult<()> {
351        let plans = self
352            .storage
353            .get_plans_by_project(&self.project_path)
354            .map_err(|e| SyncError::Database(e.to_string()))?;
355
356        if plans.is_empty() {
357            return Ok(());
358        }
359
360        let path = self.output_dir.join("plans.jsonl");
361
362        // Safety check
363        if !force {
364            self.check_for_lost_records(&path, &plans.iter().map(|p| p.id.clone()).collect())?;
365        }
366
367        let records: Vec<SyncRecord> = plans
368            .into_iter()
369            .map(|plan| {
370                let hash = content_hash(&plan);
371                SyncRecord::Plan(PlanRecord {
372                    data: plan,
373                    content_hash: hash,
374                    exported_at: now.to_string(),
375                })
376            })
377            .collect();
378
379        stats.plans = records.len();
380        write_jsonl(&path, &records)?;
381
382        Ok(())
383    }
384
385    /// Export deletions to a separate JSONL file.
386    ///
387    /// Unlike entity exports which use snapshot mode, deletions are **cumulative**:
388    /// the file contains all deletions for the project, not just since last export.
389    /// This ensures any importing machine can apply all deletions regardless of
390    /// when it last synced.
391    ///
392    /// Deletions track an `exported` flag so `sync status` can show pending counts.
393    fn export_deletions(&mut self, stats: &mut ExportStats) -> SyncResult<()> {
394        // Get all deletions for this project (not just pending)
395        let deletions = self
396            .storage
397            .get_all_deletions(&self.project_path)
398            .map_err(|e| SyncError::Database(e.to_string()))?;
399
400        if deletions.is_empty() {
401            return Ok(());
402        }
403
404        let path = self.output_dir.join("deletions.jsonl");
405
406        // Convert to DeletionRecord format
407        let records: Vec<DeletionRecord> = deletions
408            .iter()
409            .map(|del| DeletionRecord {
410                entity_type: del.entity_type.parse::<EntityType>().unwrap_or(EntityType::Session),
411                entity_id: del.entity_id.clone(),
412                project_path: del.project_path.clone(),
413                // Convert milliseconds to seconds for chrono
414                deleted_at: chrono::DateTime::from_timestamp(del.deleted_at / 1000, 0)
415                    .map(|dt| dt.to_rfc3339())
416                    .unwrap_or_else(|| del.deleted_at.to_string()),
417                deleted_by: del.deleted_by.clone(),
418            })
419            .collect();
420
421        // Write as JSONL (one deletion per line)
422        let content: String = records
423            .iter()
424            .map(|r| serde_json::to_string(r).unwrap())
425            .collect::<Vec<_>>()
426            .join("\n");
427
428        crate::sync::file::atomic_write(&path, &format!("{content}\n"))?;
429
430        // Count pending deletions (those not yet exported)
431        let pending_ids: Vec<i64> = self
432            .storage
433            .get_pending_deletions(&self.project_path)
434            .map_err(|e| SyncError::Database(e.to_string()))?
435            .iter()
436            .map(|d| d.id)
437            .collect();
438
439        stats.deletions = pending_ids.len();
440
441        // Mark pending deletions as exported
442        if !pending_ids.is_empty() {
443            self.storage
444                .mark_deletions_exported(&pending_ids)
445                .map_err(|e| SyncError::Database(e.to_string()))?;
446        }
447
448        Ok(())
449    }
450
451    /// Check if export would lose records that exist in JSONL but not in database.
452    fn check_for_lost_records(&self, path: &Path, db_ids: &HashSet<String>) -> SyncResult<()> {
453        if !path.exists() {
454            return Ok(());
455        }
456
457        let existing_records = read_jsonl(path)?;
458        let jsonl_ids: HashSet<String> = existing_records
459            .iter()
460            .map(|r| match r {
461                SyncRecord::Session(rec) => rec.data.id.clone(),
462                SyncRecord::Issue(rec) => rec.data.id.clone(),
463                SyncRecord::ContextItem(rec) => rec.data.id.clone(),
464                SyncRecord::Memory(rec) => rec.data.id.clone(),
465                SyncRecord::Checkpoint(rec) => rec.data.id.clone(),
466                SyncRecord::Plan(rec) => rec.data.id.clone(),
467            })
468            .collect();
469
470        let missing: Vec<_> = jsonl_ids.difference(db_ids).collect();
471
472        if !missing.is_empty() {
473            let preview: Vec<_> = missing.iter().take(5).map(|s| s.as_str()).collect();
474            let more = if missing.len() > 5 {
475                format!(" ... and {} more", missing.len() - 5)
476            } else {
477                String::new()
478            };
479
480            return Err(SyncError::Database(format!(
481                "Export would lose {} record(s) that exist in JSONL but not in database: {}{}\n\
482                 Hint: Run 'sc sync import' first, or use --force to override.",
483                missing.len(),
484                preview.join(", "),
485                more
486            )));
487        }
488
489        Ok(())
490    }
491
492    /// Clear all dirty flags after successful export.
493    fn clear_all_dirty_flags(&mut self) -> SyncResult<()> {
494        let dirty_sessions = self
495            .storage
496            .get_dirty_sessions_by_project(&self.project_path)
497            .map_err(|e| SyncError::Database(e.to_string()))?;
498        let dirty_issues = self
499            .storage
500            .get_dirty_issues_by_project(&self.project_path)
501            .map_err(|e| SyncError::Database(e.to_string()))?;
502        let dirty_items = self
503            .storage
504            .get_dirty_context_items_by_project(&self.project_path)
505            .map_err(|e| SyncError::Database(e.to_string()))?;
506        let dirty_plans = self
507            .storage
508            .get_dirty_plans_by_project(&self.project_path)
509            .map_err(|e| SyncError::Database(e.to_string()))?;
510
511        if !dirty_sessions.is_empty() {
512            self.storage
513                .clear_dirty_sessions(&dirty_sessions)
514                .map_err(|e| SyncError::Database(e.to_string()))?;
515        }
516        if !dirty_issues.is_empty() {
517            self.storage
518                .clear_dirty_issues(&dirty_issues)
519                .map_err(|e| SyncError::Database(e.to_string()))?;
520        }
521        if !dirty_items.is_empty() {
522            self.storage
523                .clear_dirty_context_items(&dirty_items)
524                .map_err(|e| SyncError::Database(e.to_string()))?;
525        }
526        if !dirty_plans.is_empty() {
527            self.storage
528                .clear_dirty_plans(&dirty_plans)
529                .map_err(|e| SyncError::Database(e.to_string()))?;
530        }
531
532        Ok(())
533    }
534}
535
536/// Get the export directory for a project.
537///
538/// Returns `<project_path>/.savecontext/` which is the standard location
539/// for sync files that can be committed to git.
540#[must_use]
541pub fn project_export_dir(project_path: &str) -> PathBuf {
542    PathBuf::from(project_path).join(".savecontext")
543}
544
545/// Get the default export directory for a database.
546///
547/// **Deprecated**: Use `project_export_dir` instead for project-scoped exports.
548///
549/// Returns the parent directory of the database file, which is typically
550/// `~/.savecontext/data/` for the global database.
551#[must_use]
552pub fn default_export_dir(db_path: &Path) -> PathBuf {
553    db_path
554        .parent()
555        .map(Path::to_path_buf)
556        .unwrap_or_else(|| PathBuf::from("."))
557}
558
559#[cfg(test)]
560mod tests {
561    use super::*;
562    use tempfile::TempDir;
563
564    #[test]
565    fn test_export_empty_database() {
566        let temp_dir = TempDir::new().unwrap();
567        let db_path = temp_dir.path().join("test.db");
568        let mut storage = SqliteStorage::open(&db_path).unwrap();
569        let project_path = temp_dir.path().to_string_lossy().to_string();
570
571        let mut exporter = Exporter::with_output_dir(
572            &mut storage,
573            project_path,
574            temp_dir.path().to_path_buf(),
575        );
576        let result = exporter.export(false);
577
578        // Should error because nothing to export
579        assert!(matches!(result, Err(SyncError::NothingToExport)));
580    }
581
582    #[test]
583    fn test_export_with_session() {
584        let temp_dir = TempDir::new().unwrap();
585        let db_path = temp_dir.path().join("test.db");
586        let mut storage = SqliteStorage::open(&db_path).unwrap();
587        let project_path = "/test/project".to_string();
588
589        // Create a session for this project
590        storage
591            .create_session("sess_1", "Test Session", None, Some(&project_path), None, "test")
592            .unwrap();
593
594        let mut exporter = Exporter::with_output_dir(
595            &mut storage,
596            project_path,
597            temp_dir.path().to_path_buf(),
598        );
599        let stats = exporter.export(false).unwrap();
600
601        assert_eq!(stats.sessions, 1);
602        assert!(temp_dir.path().join("sessions.jsonl").exists());
603    }
604
605    #[test]
606    fn test_export_overwrites_not_appends() {
607        let temp_dir = TempDir::new().unwrap();
608        let db_path = temp_dir.path().join("test.db");
609        let mut storage = SqliteStorage::open(&db_path).unwrap();
610        let project_path = "/test/project".to_string();
611
612        // Create a session
613        storage
614            .create_session("sess_1", "Test Session", None, Some(&project_path), None, "test")
615            .unwrap();
616
617        // First export
618        let mut exporter = Exporter::with_output_dir(
619            &mut storage,
620            project_path.clone(),
621            temp_dir.path().to_path_buf(),
622        );
623        exporter.export(false).unwrap();
624
625        // Count lines
626        let content = fs::read_to_string(temp_dir.path().join("sessions.jsonl")).unwrap();
627        let line_count_1 = content.lines().filter(|l| !l.is_empty()).count();
628        assert_eq!(line_count_1, 1);
629
630        // Second export (should overwrite, not append)
631        let mut exporter = Exporter::with_output_dir(
632            &mut storage,
633            project_path,
634            temp_dir.path().to_path_buf(),
635        );
636        exporter.export(true).unwrap(); // force to bypass dirty check
637
638        // Should still be 1 line, not 2
639        let content = fs::read_to_string(temp_dir.path().join("sessions.jsonl")).unwrap();
640        let line_count_2 = content.lines().filter(|l| !l.is_empty()).count();
641        assert_eq!(line_count_2, 1, "Export should overwrite, not append");
642    }
643
644    #[test]
645    fn test_project_export_dir() {
646        assert_eq!(
647            project_export_dir("/home/user/myproject"),
648            PathBuf::from("/home/user/myproject/.savecontext")
649        );
650        assert_eq!(
651            project_export_dir("/Users/shane/code/app"),
652            PathBuf::from("/Users/shane/code/app/.savecontext")
653        );
654    }
655
656    #[test]
657    fn test_safety_check_prevents_data_loss() {
658        let temp_dir = TempDir::new().unwrap();
659        let db_path = temp_dir.path().join("test.db");
660        let mut storage = SqliteStorage::open(&db_path).unwrap();
661        let project_path = "/test/project".to_string();
662
663        // Create session and export
664        storage
665            .create_session("sess_1", "Test Session", None, Some(&project_path), None, "test")
666            .unwrap();
667
668        let mut exporter = Exporter::with_output_dir(
669            &mut storage,
670            project_path.clone(),
671            temp_dir.path().to_path_buf(),
672        );
673        exporter.export(false).unwrap();
674
675        // Now manually add a record to JSONL that doesn't exist in DB
676        let jsonl_path = temp_dir.path().join("sessions.jsonl");
677        let mut content = fs::read_to_string(&jsonl_path).unwrap();
678        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"}"#);
679        content.push('\n');
680        fs::write(&jsonl_path, content).unwrap();
681
682        // Export without force should fail
683        let mut exporter = Exporter::with_output_dir(
684            &mut storage,
685            project_path.clone(),
686            temp_dir.path().to_path_buf(),
687        );
688        let result = exporter.export(false);
689        assert!(result.is_err());
690        assert!(result.unwrap_err().to_string().contains("would lose"));
691
692        // Export with force should succeed
693        let mut exporter = Exporter::with_output_dir(
694            &mut storage,
695            project_path,
696            temp_dir.path().to_path_buf(),
697        );
698        let result = exporter.export(true);
699        assert!(result.is_ok());
700    }
701}