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, 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
121 self.export_deletions(&mut stats)?;
123
124 self.clear_all_dirty_flags()?;
126
127 if stats.is_empty() {
128 return Err(SyncError::NothingToExport);
129 }
130
131 Ok(stats)
132 }
133
134 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 if !force {
154 self.check_for_lost_records(&path, &sessions.iter().map(|s| s.id.clone()).collect())?;
155 }
156
157 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 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 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 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 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 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 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 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 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 fn export_deletions(&mut self, stats: &mut ExportStats) -> SyncResult<()> {
352 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 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 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 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 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 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 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 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#[must_use]
489pub fn project_export_dir(project_path: &str) -> PathBuf {
490 PathBuf::from(project_path).join(".savecontext")
491}
492
493#[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 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 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 storage
562 .create_session("sess_1", "Test Session", None, Some(&project_path), None, "test")
563 .unwrap();
564
565 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 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 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(); 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 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 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 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 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}