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,
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
122 self.export_deletions(&mut stats)?;
124
125 self.clear_all_dirty_flags()?;
127
128 if stats.is_empty() {
129 return Err(SyncError::NothingToExport);
130 }
131
132 Ok(stats)
133 }
134
135 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 if !force {
155 self.check_for_lost_records(&path, &sessions.iter().map(|s| s.id.clone()).collect())?;
156 }
157
158 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 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 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 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 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 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 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 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 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 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 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 fn export_deletions(&mut self, stats: &mut ExportStats) -> SyncResult<()> {
394 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 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 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 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 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 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 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 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#[must_use]
541pub fn project_export_dir(project_path: &str) -> PathBuf {
542 PathBuf::from(project_path).join(".savecontext")
543}
544
545#[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 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 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 storage
614 .create_session("sess_1", "Test Session", None, Some(&project_path), None, "test")
615 .unwrap();
616
617 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 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 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(); 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 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 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 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 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}