1use 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
35pub struct Exporter<'a> {
41 storage: &'a mut SqliteStorage,
42 project_path: String,
43 output_dir: PathBuf,
44}
45
46impl<'a> Exporter<'a> {
47 #[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 #[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 #[must_use]
85 pub fn output_dir(&self) -> &Path {
86 &self.output_dir
87 }
88
89 pub fn export(&mut self, force: bool) -> SyncResult<ExportStats> {
105 fs::create_dir_all(&self.output_dir)?;
107
108 ensure_gitignore(&self.output_dir)?;
110
111 let mut stats = ExportStats::default();
112 let now = Utc::now().to_rfc3339();
113
114 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 self.export_deletions(&mut stats)?;
125
126 self.clear_all_dirty_flags()?;
128
129 if stats.is_empty() {
130 return Err(SyncError::NothingToExport);
131 }
132
133 Ok(stats)
134 }
135
136 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 if !force {
156 self.check_for_lost_records(&path, &sessions.iter().map(|s| s.id.clone()).collect())?;
157 }
158
159 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 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 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 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 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 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 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 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 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 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 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 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 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 fn export_deletions(&mut self, stats: &mut ExportStats) -> SyncResult<()> {
436 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 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 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 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 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 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 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 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#[must_use]
594pub fn project_export_dir(project_path: &str) -> PathBuf {
595 PathBuf::from(project_path).join(".savecontext")
596}
597
598#[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 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 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 storage
667 .create_session("sess_1", "Test Session", None, Some(&project_path), None, "test")
668 .unwrap();
669
670 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 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 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(); 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 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 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 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 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}