1use crate::ui::time_parser::parse_time_input;
2use anyhow::{Context, Result, bail};
3use chrono::{DateTime, Utc};
4use clap::ValueEnum;
5use frankensqlite::compat::{ConnectionExt, ParamValue, RowExt, TransactionExt};
6use frankensqlite::{Connection, Row as FrankenRow, params};
7use serde_json::Value;
8use sha2::{Digest, Sha256};
9use std::path::{Path, PathBuf};
10use std::sync::Arc;
11use std::sync::atomic::{AtomicBool, Ordering};
12
13#[derive(Debug, Clone)]
14pub struct ExportFilter {
15 pub agents: Option<Vec<String>>,
16 pub workspaces: Option<Vec<PathBuf>>,
17 pub since: Option<DateTime<Utc>>,
18 pub until: Option<DateTime<Utc>>,
19 pub path_mode: PathMode,
20}
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
23pub enum PathMode {
24 Relative,
25 Basename,
26 Full,
27 Hash,
28}
29
30pub struct ExportEngine {
31 source_db_path: PathBuf,
32 output_path: PathBuf,
33 filter: ExportFilter,
34}
35
36pub struct ExportStats {
37 pub conversations_processed: usize,
38 pub messages_processed: usize,
39}
40
41type SnippetExportRow = (
42 Option<String>,
43 Option<i64>,
44 Option<i64>,
45 Option<String>,
46 String,
47);
48
49impl ExportEngine {
50 pub fn new(source_db_path: &Path, output_path: &Path, filter: ExportFilter) -> Self {
51 Self {
52 source_db_path: source_db_path.to_path_buf(),
53 output_path: output_path.to_path_buf(),
54 filter,
55 }
56 }
57
58 pub fn execute<F>(&self, progress: F, running: Option<Arc<AtomicBool>>) -> Result<ExportStats>
59 where
60 F: Fn(usize, usize),
61 {
62 let src_canon = std::fs::canonicalize(&self.source_db_path)
63 .unwrap_or_else(|_| self.source_db_path.clone());
64 let out_canon =
65 std::fs::canonicalize(&self.output_path).unwrap_or_else(|_| self.output_path.clone());
66 if src_canon == out_canon {
67 bail!("output path must be different from source database path");
68 }
69
70 if self.output_path.exists() && self.output_path.is_dir() {
71 bail!(
72 "output path points to a directory, expected a file: {}",
73 self.output_path.display()
74 );
75 }
76
77 if let Some(parent) = self.output_path.parent()
78 && !parent.as_os_str().is_empty()
79 {
80 std::fs::create_dir_all(parent).with_context(|| {
81 format!(
82 "Failed to create export output directory {}",
83 parent.display()
84 )
85 })?;
86 }
87
88 let src = super::open_existing_sqlite_db(&self.source_db_path)
90 .context("Failed to open source database")?;
91
92 let temp_output_path =
95 unique_atomic_sidecar_path(&self.output_path, "tmp", "pages_export.db");
96 let mut replace_attempted = false;
97 let result = (|| -> Result<ExportStats> {
98 let output_path = temp_output_path.to_string_lossy().to_string();
99 let dest =
100 Connection::open(&output_path).context("Failed to create output database")?;
101
102 dest.execute_batch(
103 "PRAGMA journal_mode = 'delete';
107 PRAGMA synchronous = NORMAL;
108 PRAGMA busy_timeout = 5000;
109 PRAGMA foreign_keys = ON;",
110 )
111 .context("Failed to set destination database PRAGMAs")?;
112
113 let (processed, msg_processed) = {
114 let mut tx = dest.transaction()?;
115
116 tx.execute(
118 "CREATE TABLE conversations (
119 id INTEGER PRIMARY KEY,
120 agent TEXT NOT NULL,
121 workspace TEXT,
122 title TEXT,
123 source_path TEXT NOT NULL,
124 started_at INTEGER,
125 ended_at INTEGER,
126 message_count INTEGER,
127 metadata_json TEXT
128 )",
129 )
130 .context("Failed to create conversations table")?;
131
132 tx.execute(
133 "CREATE TABLE messages (
134 id INTEGER PRIMARY KEY,
135 conversation_id INTEGER NOT NULL,
136 idx INTEGER NOT NULL,
137 role TEXT NOT NULL,
138 content TEXT NOT NULL,
139 created_at INTEGER,
140 updated_at INTEGER,
141 model TEXT,
142 attachment_refs TEXT,
143 FOREIGN KEY (conversation_id) REFERENCES conversations(id)
144 )",
145 )
146 .context("Failed to create messages table")?;
147
148 tx.execute(
149 "CREATE TABLE snippets (
150 id INTEGER PRIMARY KEY,
151 message_id INTEGER NOT NULL,
152 file_path TEXT,
153 start_line INTEGER,
154 end_line INTEGER,
155 language TEXT,
156 snippet_text TEXT,
157 FOREIGN KEY (message_id) REFERENCES messages(id)
158 )",
159 )
160 .context("Failed to create snippets table")?;
161
162 tx.execute(
163 "CREATE TABLE export_meta (
164 key TEXT PRIMARY KEY,
165 value TEXT
166 )",
167 )
168 .context("Failed to create export_meta table")?;
169
170 tx.execute(
171 "CREATE VIRTUAL TABLE messages_fts USING fts5(
172 content,
173 tokenize='porter unicode61 remove_diacritics 2'
174 )",
175 )
176 .context("Failed to create messages_fts table")?;
177
178 tx.execute(
179 r#"CREATE VIRTUAL TABLE messages_code_fts USING fts5(
180 content,
181 tokenize="unicode61 tokenchars '-_./:@#$%\\'"
182 )"#,
183 )
184 .context("Failed to create messages_code_fts table")?;
185
186 let mut from_where = String::from(
192 " FROM conversations c
193 LEFT JOIN agents a ON c.agent_id = a.id
194 LEFT JOIN workspaces w ON c.workspace_id = w.id
195 WHERE 1=1",
196 );
197 let mut params: Vec<ParamValue> = Vec::new();
198
199 if let Some(agents) = &self.filter.agents {
200 if agents.is_empty() {
201 from_where.push_str(" AND 1=0");
202 } else {
203 from_where.push_str(" AND EXISTS (SELECT 1 FROM agents a2 WHERE a2.id = c.agent_id AND a2.slug IN (");
204 for (i, agent) in agents.iter().enumerate() {
205 if i > 0 {
206 from_where.push_str(", ");
207 }
208 from_where.push('?');
209 params.push(ParamValue::from(agent.clone()));
210 }
211 from_where.push_str("))");
212 }
213 }
214
215 if let Some(workspaces) = &self.filter.workspaces {
218 if workspaces.is_empty() {
219 from_where.push_str(" AND 1=0");
220 } else {
221 from_where.push_str(" AND w.path IN (");
222 for (i, ws) in workspaces.iter().enumerate() {
223 if i > 0 {
224 from_where.push_str(", ");
225 }
226 from_where.push('?');
227 params.push(ParamValue::from(ws.to_string_lossy().to_string()));
228 }
229 from_where.push(')');
230 }
231 }
232
233 if let Some(since) = self.filter.since {
234 from_where.push_str(" AND c.started_at >= ?");
235 params.push(ParamValue::from(since.timestamp_millis()));
236 }
237
238 if let Some(until) = self.filter.until {
239 from_where.push_str(" AND c.started_at <= ?");
240 params.push(ParamValue::from(until.timestamp_millis()));
241 }
242
243 let query = format!(
244 "SELECT c.id, COALESCE(a.slug, 'unknown') as agent, w.path as workspace, c.title, c.source_path, c.started_at, c.ended_at,
245 (SELECT COUNT(*) FROM messages m WHERE m.conversation_id = c.id) as message_count,
246 c.metadata_json
247 {from_where}"
248 );
249
250 let mut count_query = String::from("SELECT COUNT(*)");
251 count_query.push_str(&from_where);
252 let total_convs: usize =
253 src.query_row_map(&count_query, ¶ms, |row: &FrankenRow| {
254 row.get_typed::<i64>(0).map(|v| v as usize)
255 })?;
256
257 type ConversationExportRow = (
259 i64,
260 String,
261 Option<String>,
262 Option<String>,
263 String,
264 Option<i64>,
265 Option<i64>,
266 i64,
267 Option<String>,
268 );
269 let conv_rows: Vec<ConversationExportRow> =
270 src.query_map_collect(&query, ¶ms, |row: &FrankenRow| {
271 Ok((
272 row.get_typed::<i64>(0)?,
273 row.get_typed::<String>(1)?,
274 row.get_typed::<Option<String>>(2)?,
275 row.get_typed::<Option<String>>(3)?,
276 row.get_typed::<String>(4)?,
277 row.get_typed::<Option<i64>>(5)?,
278 row.get_typed::<Option<i64>>(6)?,
279 row.get_typed::<i64>(7)?,
280 row.get_typed::<Option<String>>(8)?,
281 ))
282 })?;
283
284 let mut processed = 0;
285 let mut msg_processed = 0;
286 let message_cols = table_columns(&src, "messages")?;
287 let has_snippets_table = table_exists(&src, "snippets");
288 let msg_query = build_message_export_query(&message_cols);
289
290 for (
291 id,
292 agent,
293 workspace,
294 title,
295 source_path,
296 started_at,
297 ended_at,
298 message_count,
299 metadata_json,
300 ) in &conv_rows
301 {
302 if let Some(r) = &running
303 && !r.load(Ordering::Relaxed)
304 {
305 return Err(anyhow::anyhow!("Export cancelled"));
306 }
307
308 let transformed_path = self.transform_path(source_path, workspace);
310
311 tx.execute_compat(
312 "INSERT INTO conversations (id, agent, workspace, title, source_path, started_at, ended_at, message_count, metadata_json)
313 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
314 params![
315 *id,
316 agent.as_str(),
317 workspace.as_deref(),
318 title.as_deref(),
319 transformed_path.as_str(),
320 *started_at,
321 *ended_at,
322 *message_count,
323 metadata_json.as_deref()
324 ],
325 )?;
326
327 let msg_rows: Vec<MessageExportRow> = src.query_map_collect(
329 &msg_query,
330 frankensqlite::params![*id],
331 |row: &FrankenRow| {
332 Ok((
333 row.get_typed::<i64>(0)?,
334 row.get_typed::<String>(1)?,
335 row.get_typed::<String>(2)?,
336 row.get_typed::<Option<i64>>(3)?,
337 row.get_typed::<i64>(4)?,
338 row.get_typed::<Option<i64>>(5)?,
339 row.get_typed::<Option<String>>(6)?,
340 row.get_typed::<Option<String>>(7)?,
341 row.get_typed::<Option<String>>(8)?,
342 ))
343 },
344 )?;
345
346 for (
347 source_message_id,
348 role,
349 content,
350 created_at,
351 idx,
352 updated_at,
353 model,
354 attachment_refs,
355 extra_json,
356 ) in &msg_rows
357 {
358 let resolved_model = normalize_optional_text(model.clone())
359 .or_else(|| derive_message_model(extra_json.as_deref()));
360 let resolved_attachment_refs =
361 normalize_optional_text(attachment_refs.clone())
362 .or_else(|| derive_attachment_refs(extra_json.as_deref()));
363
364 tx.execute_compat(
365 "INSERT INTO messages (id, conversation_id, idx, role, content, created_at, updated_at, model, attachment_refs)
366 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
367 params![
368 *source_message_id,
369 *id,
370 *idx,
371 role.as_str(),
372 content.as_str(),
373 *created_at,
374 *updated_at,
375 resolved_model.as_deref(),
376 resolved_attachment_refs.as_deref()
377 ],
378 )?;
379
380 tx.execute_compat(
382 "INSERT INTO messages_fts (rowid, content) VALUES (?1, ?2)",
383 params![*source_message_id, content.as_str()],
384 )?;
385 tx.execute_compat(
386 "INSERT INTO messages_code_fts (rowid, content) VALUES (?1, ?2)",
387 params![*source_message_id, content.as_str()],
388 )?;
389
390 let snip_rows: Vec<SnippetExportRow> = if has_snippets_table {
392 src.query_map_collect(
393 "SELECT file_path, start_line, end_line, language, snippet_text FROM snippets WHERE message_id = ?1",
394 params![*source_message_id],
395 |row: &FrankenRow| {
396 Ok((
397 row.get_typed::<Option<String>>(0)?,
398 row.get_typed::<Option<i64>>(1)?,
399 row.get_typed::<Option<i64>>(2)?,
400 row.get_typed::<Option<String>>(3)?,
401 row.get_typed::<String>(4)?,
402 ))
403 },
404 )?
405 } else {
406 Vec::new()
407 };
408
409 for (fpath, start, end, lang, stext) in snip_rows {
410 tx.execute_compat(
411 "INSERT INTO snippets (message_id, file_path, start_line, end_line, language, snippet_text)
412 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
413 params![*source_message_id, fpath, start, end, lang, stext.as_str()],
414 )?;
415 }
416
417 msg_processed += 1;
418 }
419
420 processed += 1;
421 progress(processed, total_convs);
422 }
423
424 tx.execute("INSERT INTO export_meta (key, value) VALUES ('schema_version', '1')")?;
426 let exported_at = Utc::now().to_rfc3339();
427 tx.execute_compat(
428 "INSERT INTO export_meta (key, value) VALUES ('exported_at', ?1)",
429 params![exported_at.as_str()],
430 )?;
431
432 tx.commit()?;
433 (processed, msg_processed)
434 };
435 drop(dest);
436
437 replace_attempted = true;
438 replace_file_from_temp(&temp_output_path, &self.output_path)
439 .context("Failed to install completed export database")?;
440
441 Ok(ExportStats {
442 conversations_processed: processed,
443 messages_processed: msg_processed,
444 })
445 })();
446
447 if result.is_err() && !replace_attempted {
448 cleanup_sqlite_temp_artifacts(&temp_output_path);
449 }
450
451 result
452 }
453
454 fn transform_path(&self, path: &str, workspace: &Option<String>) -> String {
455 match self.filter.path_mode {
456 PathMode::Relative => {
457 if let Some(ws) = workspace {
458 let ws_path = Path::new(ws);
459 let path_obj = Path::new(path);
460 if let Ok(stripped) = path_obj.strip_prefix(ws_path) {
461 return stripped
462 .to_string_lossy()
463 .trim_start_matches(['/', '\\'])
464 .to_string();
465 }
466 }
467 path.to_string()
468 }
469 PathMode::Basename => Path::new(path)
470 .file_name()
471 .map(|s| s.to_string_lossy().to_string())
472 .unwrap_or_else(|| path.to_string()),
473 PathMode::Full => path.to_string(),
474 PathMode::Hash => {
475 let mut hasher = Sha256::new();
476 hasher.update(path.as_bytes());
477 hex::encode(hasher.finalize())[..16].to_string()
480 }
481 }
482 }
483}
484
485type MessageExportRow = (
486 i64,
487 String,
488 String,
489 Option<i64>,
490 i64,
491 Option<i64>,
492 Option<String>,
493 Option<String>,
494 Option<String>,
495);
496
497fn table_columns(conn: &Connection, table_name: &str) -> Result<Vec<String>> {
498 let pragma = format!("PRAGMA table_info({table_name})");
499 conn.query_map_collect(&pragma, params![], |row: &FrankenRow| {
500 row.get_typed::<String>(1)
501 })
502 .context("Failed to inspect source table schema")
503}
504
505fn table_exists(conn: &Connection, table_name: &str) -> bool {
506 if !table_name
507 .chars()
508 .all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
509 {
510 return false;
511 }
512
513 table_columns(conn, table_name)
514 .map(|columns| !columns.is_empty())
515 .unwrap_or(false)
516}
517
518fn build_message_export_query(columns: &[String]) -> String {
519 let has_updated_at = columns.iter().any(|col| col == "updated_at");
520 let has_model = columns.iter().any(|col| col == "model");
521 let has_attachment_refs = columns.iter().any(|col| col == "attachment_refs");
522 let has_extra_json = columns.iter().any(|col| col == "extra_json");
523
524 format!(
525 "SELECT id, role, content, created_at, idx, {}, {}, {}, {}
526 FROM messages
527 WHERE conversation_id = ?1
528 ORDER BY idx ASC",
529 if has_updated_at {
530 "updated_at"
531 } else {
532 "NULL AS updated_at"
533 },
534 if has_model { "model" } else { "NULL AS model" },
535 if has_attachment_refs {
536 "attachment_refs"
537 } else {
538 "NULL AS attachment_refs"
539 },
540 if has_extra_json {
541 "extra_json"
542 } else {
543 "NULL AS extra_json"
544 }
545 )
546}
547
548fn normalize_optional_text(value: Option<String>) -> Option<String> {
549 value.and_then(|text| {
550 let trimmed = text.trim();
551 if trimmed.is_empty() {
552 None
553 } else {
554 Some(trimmed.to_string())
555 }
556 })
557}
558
559fn derive_message_model(extra_json: Option<&str>) -> Option<String> {
560 let value: Value = serde_json::from_str(extra_json?).ok()?;
561
562 [
563 value.pointer("/model"),
564 value.pointer("/cass/model"),
565 value.pointer("/model_id"),
566 value.pointer("/message/model"),
567 value.pointer("/message/model_id"),
568 value.pointer("/metadata/model"),
569 ]
570 .into_iter()
571 .flatten()
572 .find_map(|candidate| candidate.as_str())
573 .map(str::trim)
574 .filter(|candidate| !candidate.is_empty())
575 .map(ToOwned::to_owned)
576}
577
578fn derive_attachment_refs(extra_json: Option<&str>) -> Option<String> {
579 let value: Value = serde_json::from_str(extra_json?).ok()?;
580
581 [
582 value.pointer("/attachment_refs"),
583 value.pointer("/attachments"),
584 value.pointer("/cass/attachment_refs"),
585 value.pointer("/cass/attachments"),
586 value.pointer("/attachmentRefs"),
587 value.pointer("/message/attachment_refs"),
588 value.pointer("/message/attachments"),
589 value.pointer("/metadata/attachment_refs"),
590 value.pointer("/metadata/attachments"),
591 ]
592 .into_iter()
593 .flatten()
594 .find_map(|candidate| {
595 if candidate.is_null() {
596 None
597 } else {
598 serde_json::to_string(candidate).ok()
599 }
600 })
601}
602
603#[cfg(any(windows, test))]
604fn unique_replace_backup_path(path: &Path) -> PathBuf {
605 unique_atomic_sidecar_path(path, "bak", "pages_export.db")
606}
607
608fn unique_atomic_sidecar_path(path: &Path, suffix: &str, fallback_name: &str) -> PathBuf {
609 static NEXT_NONCE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
610
611 let timestamp = std::time::SystemTime::now()
612 .duration_since(std::time::UNIX_EPOCH)
613 .unwrap_or_default()
614 .as_nanos();
615 let nonce = NEXT_NONCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
616 let file_name = path
617 .file_name()
618 .and_then(|name| name.to_str())
619 .unwrap_or(fallback_name);
620
621 path.with_file_name(format!(
622 ".{file_name}.{suffix}.{}.{}.{}",
623 std::process::id(),
624 timestamp,
625 nonce
626 ))
627}
628
629fn cleanup_sqlite_temp_artifacts(path: &Path) {
630 let _ = std::fs::remove_file(path);
631 let _ = std::fs::remove_file(sidecar_path(path, "-wal"));
632 let _ = std::fs::remove_file(sidecar_path(path, "-shm"));
633}
634
635fn sidecar_path(path: &Path, suffix: &str) -> PathBuf {
636 let file_name = path
637 .file_name()
638 .map(|name| name.to_string_lossy().to_string())
639 .unwrap_or_else(|| "pages_export.db".to_string());
640 path.with_file_name(format!("{file_name}{suffix}"))
641}
642
643#[cfg(any(windows, test))]
644fn replacement_path_entry_exists(path: &Path) -> Result<bool> {
645 match std::fs::symlink_metadata(path) {
646 Ok(_) => Ok(true),
647 Err(err) if matches!(err.kind(), std::io::ErrorKind::NotFound) => Ok(false),
648 Err(err) => {
649 Err(err).with_context(|| format!("failed inspecting export path {}", path.display()))
650 }
651 }
652}
653
654#[cfg(any(windows, test))]
655fn replace_file_from_temp_via_backup(
656 temp_path: &Path,
657 final_path: &Path,
658 first_err: &std::io::Error,
659) -> Result<()> {
660 let backup_path = unique_replace_backup_path(final_path);
661 std::fs::rename(final_path, &backup_path).with_context(|| {
662 let _ = std::fs::remove_file(temp_path);
663 format!(
664 "failed preparing backup {} before replacing {} after initial rename error: {}",
665 backup_path.display(),
666 final_path.display(),
667 first_err
668 )
669 })?;
670
671 match std::fs::rename(temp_path, final_path) {
672 Ok(()) => {
673 let _ = std::fs::remove_file(&backup_path);
674 sync_parent_directory(final_path)?;
675 Ok(())
676 }
677 Err(second_err) => match std::fs::rename(&backup_path, final_path) {
678 Ok(()) => {
679 let _ = std::fs::remove_file(temp_path);
680 sync_parent_directory(final_path)?;
681 bail!(
682 "failed replacing {} with {}: first error: {}; second error: {}; restored original file",
683 final_path.display(),
684 temp_path.display(),
685 first_err,
686 second_err
687 );
688 }
689 Err(restore_err) => {
690 bail!(
691 "failed replacing {} with {}: first error: {}; second error: {}; restore error: {}; temp file retained at {}",
692 final_path.display(),
693 temp_path.display(),
694 first_err,
695 second_err,
696 restore_err,
697 temp_path.display()
698 );
699 }
700 },
701 }
702}
703
704fn replace_file_from_temp(temp_path: &Path, final_path: &Path) -> Result<()> {
705 #[cfg(windows)]
706 {
707 match std::fs::rename(temp_path, final_path) {
708 Ok(()) => {
709 sync_parent_directory(final_path)?;
710 Ok(())
711 }
712 Err(first_err)
713 if matches!(
714 first_err.kind(),
715 std::io::ErrorKind::AlreadyExists | std::io::ErrorKind::PermissionDenied
716 ) =>
717 {
718 if replacement_path_entry_exists(final_path)? {
719 replace_file_from_temp_via_backup(temp_path, final_path, &first_err)
720 } else {
721 Err(first_err).with_context(|| {
722 format!(
723 "failed renaming completed export {} into place at {}",
724 temp_path.display(),
725 final_path.display()
726 )
727 })
728 }
729 }
730 Err(rename_err) => Err(rename_err).with_context(|| {
731 format!(
732 "failed renaming completed export {} into place at {}",
733 temp_path.display(),
734 final_path.display()
735 )
736 }),
737 }
738 }
739
740 #[cfg(not(windows))]
741 {
742 std::fs::rename(temp_path, final_path).with_context(|| {
743 format!(
744 "failed renaming completed export {} into place at {}",
745 temp_path.display(),
746 final_path.display()
747 )
748 })?;
749 sync_parent_directory(final_path)
750 }
751}
752
753#[cfg(not(windows))]
754fn sync_parent_directory(path: &Path) -> Result<()> {
755 let Some(parent) = path.parent() else {
756 return Ok(());
757 };
758 std::fs::File::open(parent)
759 .with_context(|| format!("failed opening parent directory {}", parent.display()))?
760 .sync_all()
761 .with_context(|| format!("failed syncing parent directory {}", parent.display()))
762}
763
764#[cfg(windows)]
765fn sync_parent_directory(_path: &Path) -> Result<()> {
766 Ok(())
767}
768
769#[allow(clippy::too_many_arguments)]
770pub fn run_pages_export(
771 db_path: Option<PathBuf>,
772 output_path: PathBuf,
773 agents: Option<Vec<String>>,
774 workspaces: Option<Vec<String>>,
775 since: Option<String>,
776 until: Option<String>,
777 path_mode: PathMode,
778 dry_run: bool,
779) -> Result<()> {
780 if dry_run {
781 println!("Dry run: would export to {:?}", output_path);
782 return Ok(());
783 }
784
785 let db_path = db_path.unwrap_or_else(crate::default_db_path);
786
787 let since_dt = parse_export_time_arg("--since", since.as_deref())?;
788 let until_dt = parse_export_time_arg("--until", until.as_deref())?;
789
790 if let (Some(since_dt), Some(until_dt)) = (since_dt, until_dt)
791 && since_dt > until_dt
792 {
793 bail!(
794 "Invalid time range: --since ({}) is after --until ({})",
795 since_dt.to_rfc3339(),
796 until_dt.to_rfc3339()
797 );
798 }
799
800 let workspaces_path = workspaces.map(|ws| ws.into_iter().map(PathBuf::from).collect());
801
802 let filter = ExportFilter {
803 agents,
804 workspaces: workspaces_path,
805 since: since_dt,
806 until: until_dt,
807 path_mode,
808 };
809
810 let engine = ExportEngine::new(&db_path, &output_path, filter);
811
812 println!("Exporting to {:?}...", output_path);
813 let stats = engine.execute(
814 |current, total| {
815 if total > 0 && current % 100 == 0 {
816 use std::io::Write;
817 print!("\rProcessed {}/{} conversations...", current, total);
818 std::io::stdout().flush().ok();
819 }
820 },
821 None,
822 )?;
823 println!(
824 "\rExport complete! Processed {} conversations, {} messages.",
825 stats.conversations_processed, stats.messages_processed
826 );
827
828 Ok(())
829}
830
831fn parse_export_time_arg(
832 flag_name: &str,
833 raw_value: Option<&str>,
834) -> Result<Option<DateTime<Utc>>> {
835 let Some(raw_value) = raw_value else {
836 return Ok(None);
837 };
838
839 let timestamp = parse_time_input(raw_value)
840 .ok_or_else(|| anyhow::anyhow!("Invalid {flag_name} value: {raw_value}"))?;
841 let parsed = DateTime::from_timestamp_millis(timestamp)
842 .ok_or_else(|| anyhow::anyhow!("{flag_name} value is out of range: {raw_value}"))?;
843 Ok(Some(parsed))
844}
845
846#[cfg(test)]
847mod tests {
848 use super::*;
849 use chrono::{Datelike, TimeZone};
850 use std::path::Path;
851 use tempfile::TempDir;
852
853 #[test]
856 fn test_export_filter_default_values() {
857 let filter = ExportFilter {
858 agents: None,
859 workspaces: None,
860 since: None,
861 until: None,
862 path_mode: PathMode::Full,
863 };
864
865 assert!(filter.agents.is_none());
866 assert!(filter.workspaces.is_none());
867 assert!(filter.since.is_none());
868 assert!(filter.until.is_none());
869 assert_eq!(filter.path_mode, PathMode::Full);
870 }
871
872 #[test]
873 fn test_export_filter_with_agents() {
874 let filter = ExportFilter {
875 agents: Some(vec!["claude".to_string(), "codex".to_string()]),
876 workspaces: None,
877 since: None,
878 until: None,
879 path_mode: PathMode::Relative,
880 };
881
882 let agents = filter.agents.as_ref().unwrap();
883 assert_eq!(agents.len(), 2);
884 assert!(agents.contains(&"claude".to_string()));
885 assert!(agents.contains(&"codex".to_string()));
886 }
887
888 #[test]
889 fn test_export_filter_with_workspaces() {
890 let filter = ExportFilter {
891 agents: None,
892 workspaces: Some(vec![
893 PathBuf::from("/home/user/project1"),
894 PathBuf::from("/home/user/project2"),
895 ]),
896 since: None,
897 until: None,
898 path_mode: PathMode::Basename,
899 };
900
901 let workspaces = filter.workspaces.as_ref().unwrap();
902 assert_eq!(workspaces.len(), 2);
903 }
904
905 #[test]
906 fn test_export_filter_with_time_range() {
907 let since = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
908 let until = Utc.with_ymd_and_hms(2025, 12, 31, 23, 59, 59).unwrap();
909
910 let filter = ExportFilter {
911 agents: None,
912 workspaces: None,
913 since: Some(since),
914 until: Some(until),
915 path_mode: PathMode::Hash,
916 };
917
918 assert_eq!(filter.since.unwrap().year(), 2025);
919 assert_eq!(filter.until.unwrap().month(), 12);
920 }
921
922 #[test]
923 fn test_export_filter_clone() {
924 let filter = ExportFilter {
925 agents: Some(vec!["gemini".to_string()]),
926 workspaces: Some(vec![PathBuf::from("/tmp/test")]),
927 since: None,
928 until: None,
929 path_mode: PathMode::Full,
930 };
931
932 let cloned = filter.clone();
933 assert_eq!(cloned.agents, filter.agents);
934 assert_eq!(cloned.workspaces, filter.workspaces);
935 assert_eq!(cloned.path_mode, filter.path_mode);
936 }
937
938 #[test]
941 fn test_path_mode_equality() {
942 assert_eq!(PathMode::Relative, PathMode::Relative);
943 assert_eq!(PathMode::Basename, PathMode::Basename);
944 assert_eq!(PathMode::Full, PathMode::Full);
945 assert_eq!(PathMode::Hash, PathMode::Hash);
946 }
947
948 #[test]
949 fn test_path_mode_inequality() {
950 assert_ne!(PathMode::Relative, PathMode::Full);
951 assert_ne!(PathMode::Basename, PathMode::Hash);
952 assert_ne!(PathMode::Full, PathMode::Relative);
953 }
954
955 #[test]
956 fn test_path_mode_clone() {
957 let mode = PathMode::Hash;
958 let cloned = mode;
959 assert_eq!(mode, cloned);
960 }
961
962 #[test]
963 fn test_path_mode_copy() {
964 let mode = PathMode::Relative;
965 let copied: PathMode = mode;
966 assert_eq!(copied, PathMode::Relative);
967 }
968
969 #[test]
970 fn test_path_mode_debug() {
971 let debug_str = format!("{:?}", PathMode::Full);
972 assert!(debug_str.contains("Full"));
973 }
974
975 #[test]
978 fn test_export_engine_new_stores_paths() {
979 let source = Path::new("/tmp/source.db");
980 let output = Path::new("/tmp/output.db");
981 let filter = ExportFilter {
982 agents: None,
983 workspaces: None,
984 since: None,
985 until: None,
986 path_mode: PathMode::Full,
987 };
988
989 let engine = ExportEngine::new(source, output, filter);
990
991 assert_eq!(engine.source_db_path, PathBuf::from("/tmp/source.db"));
992 assert_eq!(engine.output_path, PathBuf::from("/tmp/output.db"));
993 }
994
995 #[test]
996 fn test_export_engine_new_with_relative_paths() {
997 let source = Path::new("relative/source.db");
998 let output = Path::new("relative/output.db");
999 let filter = ExportFilter {
1000 agents: None,
1001 workspaces: None,
1002 since: None,
1003 until: None,
1004 path_mode: PathMode::Basename,
1005 };
1006
1007 let engine = ExportEngine::new(source, output, filter);
1008
1009 assert_eq!(engine.source_db_path, PathBuf::from("relative/source.db"));
1010 assert_eq!(engine.output_path, PathBuf::from("relative/output.db"));
1011 }
1012
1013 #[test]
1016 fn test_transform_path_full_mode() {
1017 let filter = ExportFilter {
1018 agents: None,
1019 workspaces: None,
1020 since: None,
1021 until: None,
1022 path_mode: PathMode::Full,
1023 };
1024 let engine = ExportEngine::new(Path::new("/tmp/s.db"), Path::new("/tmp/o.db"), filter);
1025
1026 let result = engine.transform_path("/home/user/project/file.rs", &None);
1027 assert_eq!(result, "/home/user/project/file.rs");
1028 }
1029
1030 #[test]
1031 fn test_transform_path_full_mode_with_workspace() {
1032 let filter = ExportFilter {
1033 agents: None,
1034 workspaces: None,
1035 since: None,
1036 until: None,
1037 path_mode: PathMode::Full,
1038 };
1039 let engine = ExportEngine::new(Path::new("/tmp/s.db"), Path::new("/tmp/o.db"), filter);
1040
1041 let workspace = Some("/home/user/project".to_string());
1042 let result = engine.transform_path("/home/user/project/src/main.rs", &workspace);
1043 assert_eq!(result, "/home/user/project/src/main.rs");
1045 }
1046
1047 #[test]
1048 fn test_transform_path_basename_mode() {
1049 let filter = ExportFilter {
1050 agents: None,
1051 workspaces: None,
1052 since: None,
1053 until: None,
1054 path_mode: PathMode::Basename,
1055 };
1056 let engine = ExportEngine::new(Path::new("/tmp/s.db"), Path::new("/tmp/o.db"), filter);
1057
1058 let result = engine.transform_path("/home/user/project/src/main.rs", &None);
1059 assert_eq!(result, "main.rs");
1060 }
1061
1062 #[test]
1063 fn test_transform_path_basename_mode_nested() {
1064 let filter = ExportFilter {
1065 agents: None,
1066 workspaces: None,
1067 since: None,
1068 until: None,
1069 path_mode: PathMode::Basename,
1070 };
1071 let engine = ExportEngine::new(Path::new("/tmp/s.db"), Path::new("/tmp/o.db"), filter);
1072
1073 let result = engine.transform_path("/very/deep/nested/path/to/file.txt", &None);
1074 assert_eq!(result, "file.txt");
1075 }
1076
1077 #[test]
1078 fn test_transform_path_basename_mode_no_extension() {
1079 let filter = ExportFilter {
1080 agents: None,
1081 workspaces: None,
1082 since: None,
1083 until: None,
1084 path_mode: PathMode::Basename,
1085 };
1086 let engine = ExportEngine::new(Path::new("/tmp/s.db"), Path::new("/tmp/o.db"), filter);
1087
1088 let result = engine.transform_path("/usr/bin/cargo", &None);
1089 assert_eq!(result, "cargo");
1090 }
1091
1092 #[test]
1093 fn test_transform_path_relative_mode_with_workspace() {
1094 let filter = ExportFilter {
1095 agents: None,
1096 workspaces: None,
1097 since: None,
1098 until: None,
1099 path_mode: PathMode::Relative,
1100 };
1101 let engine = ExportEngine::new(Path::new("/tmp/s.db"), Path::new("/tmp/o.db"), filter);
1102
1103 let workspace = Some("/home/user/project".to_string());
1104 let result = engine.transform_path("/home/user/project/src/main.rs", &workspace);
1105 assert_eq!(result, "src/main.rs");
1106 }
1107
1108 #[test]
1109 fn test_transform_path_relative_mode_without_workspace() {
1110 let filter = ExportFilter {
1111 agents: None,
1112 workspaces: None,
1113 since: None,
1114 until: None,
1115 path_mode: PathMode::Relative,
1116 };
1117 let engine = ExportEngine::new(Path::new("/tmp/s.db"), Path::new("/tmp/o.db"), filter);
1118
1119 let result = engine.transform_path("/home/user/project/src/main.rs", &None);
1120 assert_eq!(result, "/home/user/project/src/main.rs");
1122 }
1123
1124 #[test]
1125 fn test_transform_path_relative_mode_path_not_under_workspace() {
1126 let filter = ExportFilter {
1127 agents: None,
1128 workspaces: None,
1129 since: None,
1130 until: None,
1131 path_mode: PathMode::Relative,
1132 };
1133 let engine = ExportEngine::new(Path::new("/tmp/s.db"), Path::new("/tmp/o.db"), filter);
1134
1135 let workspace = Some("/home/user/project".to_string());
1136 let result = engine.transform_path("/other/path/file.rs", &workspace);
1137 assert_eq!(result, "/other/path/file.rs");
1139 }
1140
1141 #[test]
1142 fn test_transform_path_relative_mode_strips_leading_slash() {
1143 let filter = ExportFilter {
1144 agents: None,
1145 workspaces: None,
1146 since: None,
1147 until: None,
1148 path_mode: PathMode::Relative,
1149 };
1150 let engine = ExportEngine::new(Path::new("/tmp/s.db"), Path::new("/tmp/o.db"), filter);
1151
1152 let workspace = Some("/home/user".to_string());
1153 let result = engine.transform_path("/home/user/file.rs", &workspace);
1154 assert_eq!(result, "file.rs");
1155 }
1156
1157 #[test]
1158 fn test_transform_path_hash_mode() {
1159 let filter = ExportFilter {
1160 agents: None,
1161 workspaces: None,
1162 since: None,
1163 until: None,
1164 path_mode: PathMode::Hash,
1165 };
1166 let engine = ExportEngine::new(Path::new("/tmp/s.db"), Path::new("/tmp/o.db"), filter);
1167
1168 let result = engine.transform_path("/home/user/project/file.rs", &None);
1169 assert_eq!(result.len(), 16);
1171 assert!(result.chars().all(|c| c.is_ascii_hexdigit()));
1172 }
1173
1174 #[test]
1175 fn test_transform_path_hash_mode_deterministic() {
1176 let filter1 = ExportFilter {
1177 agents: None,
1178 workspaces: None,
1179 since: None,
1180 until: None,
1181 path_mode: PathMode::Hash,
1182 };
1183 let engine1 = ExportEngine::new(Path::new("/tmp/s.db"), Path::new("/tmp/o.db"), filter1);
1184
1185 let filter2 = ExportFilter {
1186 agents: None,
1187 workspaces: None,
1188 since: None,
1189 until: None,
1190 path_mode: PathMode::Hash,
1191 };
1192 let engine2 = ExportEngine::new(Path::new("/tmp/s.db"), Path::new("/tmp/o.db"), filter2);
1193
1194 let path = "/home/user/project/file.rs";
1195 let result1 = engine1.transform_path(path, &None);
1196 let result2 = engine2.transform_path(path, &None);
1197
1198 assert_eq!(result1, result2);
1199 }
1200
1201 #[test]
1202 fn test_transform_path_hash_mode_different_paths_different_hashes() {
1203 let filter = ExportFilter {
1204 agents: None,
1205 workspaces: None,
1206 since: None,
1207 until: None,
1208 path_mode: PathMode::Hash,
1209 };
1210 let engine = ExportEngine::new(Path::new("/tmp/s.db"), Path::new("/tmp/o.db"), filter);
1211
1212 let result1 = engine.transform_path("/path/one/file.rs", &None);
1213 let result2 = engine.transform_path("/path/two/file.rs", &None);
1214
1215 assert_ne!(result1, result2);
1216 }
1217
1218 #[test]
1221 fn test_export_stats_default_values() {
1222 let stats = ExportStats {
1223 conversations_processed: 0,
1224 messages_processed: 0,
1225 };
1226
1227 assert_eq!(stats.conversations_processed, 0);
1228 assert_eq!(stats.messages_processed, 0);
1229 }
1230
1231 #[test]
1232 fn test_export_stats_with_values() {
1233 let stats = ExportStats {
1234 conversations_processed: 100,
1235 messages_processed: 5000,
1236 };
1237
1238 assert_eq!(stats.conversations_processed, 100);
1239 assert_eq!(stats.messages_processed, 5000);
1240 }
1241
1242 #[test]
1245 fn test_transform_path_empty_path() {
1246 let filter = ExportFilter {
1247 agents: None,
1248 workspaces: None,
1249 since: None,
1250 until: None,
1251 path_mode: PathMode::Full,
1252 };
1253 let engine = ExportEngine::new(Path::new("/tmp/s.db"), Path::new("/tmp/o.db"), filter);
1254
1255 let result = engine.transform_path("", &None);
1256 assert_eq!(result, "");
1257 }
1258
1259 #[test]
1260 fn test_transform_path_basename_empty_returns_original() {
1261 let filter = ExportFilter {
1262 agents: None,
1263 workspaces: None,
1264 since: None,
1265 until: None,
1266 path_mode: PathMode::Basename,
1267 };
1268 let engine = ExportEngine::new(Path::new("/tmp/s.db"), Path::new("/tmp/o.db"), filter);
1269
1270 let result = engine.transform_path("", &None);
1272 assert_eq!(result, "");
1273 }
1274
1275 #[test]
1276 fn test_transform_path_with_special_characters() {
1277 let filter = ExportFilter {
1278 agents: None,
1279 workspaces: None,
1280 since: None,
1281 until: None,
1282 path_mode: PathMode::Basename,
1283 };
1284 let engine = ExportEngine::new(Path::new("/tmp/s.db"), Path::new("/tmp/o.db"), filter);
1285
1286 let result = engine.transform_path("/path/to/file with spaces.rs", &None);
1287 assert_eq!(result, "file with spaces.rs");
1288 }
1289
1290 #[test]
1291 fn test_transform_path_hash_with_unicode() {
1292 let filter = ExportFilter {
1293 agents: None,
1294 workspaces: None,
1295 since: None,
1296 until: None,
1297 path_mode: PathMode::Hash,
1298 };
1299 let engine = ExportEngine::new(Path::new("/tmp/s.db"), Path::new("/tmp/o.db"), filter);
1300
1301 let result = engine.transform_path("/path/to/файл.rs", &None);
1302 assert_eq!(result.len(), 16);
1304 assert!(result.chars().all(|c| c.is_ascii_hexdigit()));
1305 }
1306
1307 #[test]
1308 fn test_export_filter_empty_agents_list() {
1309 let filter = ExportFilter {
1310 agents: Some(vec![]),
1311 workspaces: None,
1312 since: None,
1313 until: None,
1314 path_mode: PathMode::Full,
1315 };
1316
1317 assert!(filter.agents.as_ref().unwrap().is_empty());
1318 }
1319
1320 #[test]
1321 fn test_export_filter_empty_workspaces_list() {
1322 let filter = ExportFilter {
1323 agents: None,
1324 workspaces: Some(vec![]),
1325 since: None,
1326 until: None,
1327 path_mode: PathMode::Full,
1328 };
1329
1330 assert!(filter.workspaces.as_ref().unwrap().is_empty());
1331 }
1332
1333 #[test]
1336 fn test_export_engine_new_with_tempdir() {
1337 let temp_dir = TempDir::new().expect("create temp dir");
1338 let source = temp_dir.path().join("source.db");
1339 let output = temp_dir.path().join("output.db");
1340
1341 let filter = ExportFilter {
1342 agents: None,
1343 workspaces: None,
1344 since: None,
1345 until: None,
1346 path_mode: PathMode::Full,
1347 };
1348
1349 let engine = ExportEngine::new(&source, &output, filter);
1350
1351 assert!(engine.source_db_path.starts_with(temp_dir.path()));
1352 assert!(engine.output_path.starts_with(temp_dir.path()));
1353 }
1354
1355 #[cfg(unix)]
1356 #[test]
1357 fn replacement_path_entry_exists_detects_dangling_symlink() -> Result<()> {
1358 use std::os::unix::fs::symlink;
1359
1360 let temp_dir = TempDir::new()?;
1361 let link_path = temp_dir.path().join("export.db");
1362 let missing_target = temp_dir.path().join("missing-export.db");
1363
1364 symlink(&missing_target, &link_path)?;
1365
1366 if link_path.exists() {
1367 return Err(anyhow::anyhow!(
1368 "Path::exists stopped following the missing target"
1369 ));
1370 }
1371 if !replacement_path_entry_exists(&link_path)? {
1372 return Err(anyhow::anyhow!(
1373 "replacement path helper missed a dangling symlink entry"
1374 ));
1375 }
1376
1377 Ok(())
1378 }
1379
1380 #[test]
1381 fn unique_replace_backup_path_is_not_reused() -> Result<()> {
1382 let temp_dir = TempDir::new()?;
1383 let final_path = temp_dir.path().join("export.db");
1384 let first = unique_replace_backup_path(&final_path);
1385 let second = unique_replace_backup_path(&final_path);
1386
1387 if first == second {
1388 return Err(anyhow::anyhow!(
1389 "export replacement backup path was reused: {}",
1390 first.display()
1391 ));
1392 }
1393
1394 Ok(())
1395 }
1396
1397 #[test]
1398 fn replace_file_from_temp_via_backup_overwrites_existing_file() -> Result<()> {
1399 let temp_dir = TempDir::new()?;
1400 let final_path = temp_dir.path().join("export.db");
1401 let temp_path = temp_dir.path().join("export.tmp");
1402 let first_err = std::io::Error::from(std::io::ErrorKind::AlreadyExists);
1403
1404 std::fs::write(&final_path, b"old export")?;
1405 std::fs::write(&temp_path, b"new export")?;
1406
1407 replace_file_from_temp_via_backup(&temp_path, &final_path, &first_err)?;
1408
1409 if !matches!(
1410 std::fs::read(&final_path)?.as_slice().cmp(b"new export"),
1411 std::cmp::Ordering::Equal
1412 ) {
1413 return Err(anyhow::anyhow!(
1414 "backup replacement did not publish temp bytes"
1415 ));
1416 }
1417 if temp_path.exists() {
1418 return Err(anyhow::anyhow!("export temp path was not consumed"));
1419 }
1420
1421 Ok(())
1422 }
1423
1424 #[test]
1425 fn test_replace_file_from_temp_overwrites_existing_file() {
1426 let temp_dir = TempDir::new().expect("create temp dir");
1427 let final_path = temp_dir.path().join("export.db");
1428 let first_tmp = temp_dir.path().join("first.tmp");
1429 let second_tmp = temp_dir.path().join("second.tmp");
1430
1431 std::fs::write(&first_tmp, b"first").expect("write first temp");
1432 replace_file_from_temp(&first_tmp, &final_path).expect("initial replace");
1433 assert_eq!(
1434 std::fs::read(&final_path).expect("read first final"),
1435 b"first"
1436 );
1437
1438 std::fs::write(&second_tmp, b"second").expect("write second temp");
1439 replace_file_from_temp(&second_tmp, &final_path).expect("overwrite replace");
1440 assert_eq!(
1441 std::fs::read(&final_path).expect("read second final"),
1442 b"second"
1443 );
1444 }
1445}