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