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(windows)]
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
643fn replace_file_from_temp(temp_path: &Path, final_path: &Path) -> Result<()> {
644 #[cfg(windows)]
645 {
646 match std::fs::rename(temp_path, final_path) {
647 Ok(()) => {
648 sync_parent_directory(final_path)?;
649 Ok(())
650 }
651 Err(first_err)
652 if final_path.exists()
653 && matches!(
654 first_err.kind(),
655 std::io::ErrorKind::AlreadyExists | std::io::ErrorKind::PermissionDenied
656 ) =>
657 {
658 let backup_path = unique_replace_backup_path(final_path);
659 std::fs::rename(final_path, &backup_path).with_context(|| {
660 let _ = std::fs::remove_file(temp_path);
661 format!(
662 "failed preparing backup {} before replacing {} after initial rename error: {}",
663 backup_path.display(),
664 final_path.display(),
665 first_err
666 )
667 })?;
668
669 match std::fs::rename(temp_path, final_path) {
670 Ok(()) => {
671 let _ = std::fs::remove_file(&backup_path);
672 sync_parent_directory(final_path)?;
673 Ok(())
674 }
675 Err(second_err) => match std::fs::rename(&backup_path, final_path) {
676 Ok(()) => {
677 let _ = std::fs::remove_file(temp_path);
678 sync_parent_directory(final_path)?;
679 bail!(
680 "failed replacing {} with {}: first error: {}; second error: {}; restored original file",
681 final_path.display(),
682 temp_path.display(),
683 first_err,
684 second_err
685 );
686 }
687 Err(restore_err) => {
688 bail!(
689 "failed replacing {} with {}: first error: {}; second error: {}; restore error: {}; temp file retained at {}",
690 final_path.display(),
691 temp_path.display(),
692 first_err,
693 second_err,
694 restore_err,
695 temp_path.display()
696 );
697 }
698 },
699 }
700 }
701 Err(rename_err) => Err(rename_err).with_context(|| {
702 format!(
703 "failed renaming completed export {} into place at {}",
704 temp_path.display(),
705 final_path.display()
706 )
707 }),
708 }
709 }
710
711 #[cfg(not(windows))]
712 {
713 std::fs::rename(temp_path, final_path).with_context(|| {
714 format!(
715 "failed renaming completed export {} into place at {}",
716 temp_path.display(),
717 final_path.display()
718 )
719 })?;
720 sync_parent_directory(final_path)
721 }
722}
723
724#[cfg(not(windows))]
725fn sync_parent_directory(path: &Path) -> Result<()> {
726 let Some(parent) = path.parent() else {
727 return Ok(());
728 };
729 std::fs::File::open(parent)
730 .with_context(|| format!("failed opening parent directory {}", parent.display()))?
731 .sync_all()
732 .with_context(|| format!("failed syncing parent directory {}", parent.display()))
733}
734
735#[cfg(windows)]
736fn sync_parent_directory(_path: &Path) -> Result<()> {
737 Ok(())
738}
739
740#[allow(clippy::too_many_arguments)]
741pub fn run_pages_export(
742 db_path: Option<PathBuf>,
743 output_path: PathBuf,
744 agents: Option<Vec<String>>,
745 workspaces: Option<Vec<String>>,
746 since: Option<String>,
747 until: Option<String>,
748 path_mode: PathMode,
749 dry_run: bool,
750) -> Result<()> {
751 if dry_run {
752 println!("Dry run: would export to {:?}", output_path);
753 return Ok(());
754 }
755
756 let db_path = db_path.unwrap_or_else(crate::default_db_path);
757
758 let since_dt = parse_export_time_arg("--since", since.as_deref())?;
759 let until_dt = parse_export_time_arg("--until", until.as_deref())?;
760
761 if let (Some(since_dt), Some(until_dt)) = (since_dt, until_dt)
762 && since_dt > until_dt
763 {
764 bail!(
765 "Invalid time range: --since ({}) is after --until ({})",
766 since_dt.to_rfc3339(),
767 until_dt.to_rfc3339()
768 );
769 }
770
771 let workspaces_path = workspaces.map(|ws| ws.into_iter().map(PathBuf::from).collect());
772
773 let filter = ExportFilter {
774 agents,
775 workspaces: workspaces_path,
776 since: since_dt,
777 until: until_dt,
778 path_mode,
779 };
780
781 let engine = ExportEngine::new(&db_path, &output_path, filter);
782
783 println!("Exporting to {:?}...", output_path);
784 let stats = engine.execute(
785 |current, total| {
786 if total > 0 && current % 100 == 0 {
787 use std::io::Write;
788 print!("\rProcessed {}/{} conversations...", current, total);
789 std::io::stdout().flush().ok();
790 }
791 },
792 None,
793 )?;
794 println!(
795 "\rExport complete! Processed {} conversations, {} messages.",
796 stats.conversations_processed, stats.messages_processed
797 );
798
799 Ok(())
800}
801
802fn parse_export_time_arg(
803 flag_name: &str,
804 raw_value: Option<&str>,
805) -> Result<Option<DateTime<Utc>>> {
806 let Some(raw_value) = raw_value else {
807 return Ok(None);
808 };
809
810 let timestamp = parse_time_input(raw_value)
811 .ok_or_else(|| anyhow::anyhow!("Invalid {flag_name} value: {raw_value}"))?;
812 let parsed = DateTime::from_timestamp_millis(timestamp)
813 .ok_or_else(|| anyhow::anyhow!("{flag_name} value is out of range: {raw_value}"))?;
814 Ok(Some(parsed))
815}
816
817#[cfg(test)]
818mod tests {
819 use super::*;
820 use chrono::{Datelike, TimeZone};
821 use std::path::Path;
822 use tempfile::TempDir;
823
824 #[test]
827 fn test_export_filter_default_values() {
828 let filter = ExportFilter {
829 agents: None,
830 workspaces: None,
831 since: None,
832 until: None,
833 path_mode: PathMode::Full,
834 };
835
836 assert!(filter.agents.is_none());
837 assert!(filter.workspaces.is_none());
838 assert!(filter.since.is_none());
839 assert!(filter.until.is_none());
840 assert_eq!(filter.path_mode, PathMode::Full);
841 }
842
843 #[test]
844 fn test_export_filter_with_agents() {
845 let filter = ExportFilter {
846 agents: Some(vec!["claude".to_string(), "codex".to_string()]),
847 workspaces: None,
848 since: None,
849 until: None,
850 path_mode: PathMode::Relative,
851 };
852
853 let agents = filter.agents.as_ref().unwrap();
854 assert_eq!(agents.len(), 2);
855 assert!(agents.contains(&"claude".to_string()));
856 assert!(agents.contains(&"codex".to_string()));
857 }
858
859 #[test]
860 fn test_export_filter_with_workspaces() {
861 let filter = ExportFilter {
862 agents: None,
863 workspaces: Some(vec![
864 PathBuf::from("/home/user/project1"),
865 PathBuf::from("/home/user/project2"),
866 ]),
867 since: None,
868 until: None,
869 path_mode: PathMode::Basename,
870 };
871
872 let workspaces = filter.workspaces.as_ref().unwrap();
873 assert_eq!(workspaces.len(), 2);
874 }
875
876 #[test]
877 fn test_export_filter_with_time_range() {
878 let since = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
879 let until = Utc.with_ymd_and_hms(2025, 12, 31, 23, 59, 59).unwrap();
880
881 let filter = ExportFilter {
882 agents: None,
883 workspaces: None,
884 since: Some(since),
885 until: Some(until),
886 path_mode: PathMode::Hash,
887 };
888
889 assert_eq!(filter.since.unwrap().year(), 2025);
890 assert_eq!(filter.until.unwrap().month(), 12);
891 }
892
893 #[test]
894 fn test_export_filter_clone() {
895 let filter = ExportFilter {
896 agents: Some(vec!["gemini".to_string()]),
897 workspaces: Some(vec![PathBuf::from("/tmp/test")]),
898 since: None,
899 until: None,
900 path_mode: PathMode::Full,
901 };
902
903 let cloned = filter.clone();
904 assert_eq!(cloned.agents, filter.agents);
905 assert_eq!(cloned.workspaces, filter.workspaces);
906 assert_eq!(cloned.path_mode, filter.path_mode);
907 }
908
909 #[test]
912 fn test_path_mode_equality() {
913 assert_eq!(PathMode::Relative, PathMode::Relative);
914 assert_eq!(PathMode::Basename, PathMode::Basename);
915 assert_eq!(PathMode::Full, PathMode::Full);
916 assert_eq!(PathMode::Hash, PathMode::Hash);
917 }
918
919 #[test]
920 fn test_path_mode_inequality() {
921 assert_ne!(PathMode::Relative, PathMode::Full);
922 assert_ne!(PathMode::Basename, PathMode::Hash);
923 assert_ne!(PathMode::Full, PathMode::Relative);
924 }
925
926 #[test]
927 fn test_path_mode_clone() {
928 let mode = PathMode::Hash;
929 let cloned = mode;
930 assert_eq!(mode, cloned);
931 }
932
933 #[test]
934 fn test_path_mode_copy() {
935 let mode = PathMode::Relative;
936 let copied: PathMode = mode;
937 assert_eq!(copied, PathMode::Relative);
938 }
939
940 #[test]
941 fn test_path_mode_debug() {
942 let debug_str = format!("{:?}", PathMode::Full);
943 assert!(debug_str.contains("Full"));
944 }
945
946 #[test]
949 fn test_export_engine_new_stores_paths() {
950 let source = Path::new("/tmp/source.db");
951 let output = Path::new("/tmp/output.db");
952 let filter = ExportFilter {
953 agents: None,
954 workspaces: None,
955 since: None,
956 until: None,
957 path_mode: PathMode::Full,
958 };
959
960 let engine = ExportEngine::new(source, output, filter);
961
962 assert_eq!(engine.source_db_path, PathBuf::from("/tmp/source.db"));
963 assert_eq!(engine.output_path, PathBuf::from("/tmp/output.db"));
964 }
965
966 #[test]
967 fn test_export_engine_new_with_relative_paths() {
968 let source = Path::new("relative/source.db");
969 let output = Path::new("relative/output.db");
970 let filter = ExportFilter {
971 agents: None,
972 workspaces: None,
973 since: None,
974 until: None,
975 path_mode: PathMode::Basename,
976 };
977
978 let engine = ExportEngine::new(source, output, filter);
979
980 assert_eq!(engine.source_db_path, PathBuf::from("relative/source.db"));
981 assert_eq!(engine.output_path, PathBuf::from("relative/output.db"));
982 }
983
984 #[test]
987 fn test_transform_path_full_mode() {
988 let filter = ExportFilter {
989 agents: None,
990 workspaces: None,
991 since: None,
992 until: None,
993 path_mode: PathMode::Full,
994 };
995 let engine = ExportEngine::new(Path::new("/tmp/s.db"), Path::new("/tmp/o.db"), filter);
996
997 let result = engine.transform_path("/home/user/project/file.rs", &None);
998 assert_eq!(result, "/home/user/project/file.rs");
999 }
1000
1001 #[test]
1002 fn test_transform_path_full_mode_with_workspace() {
1003 let filter = ExportFilter {
1004 agents: None,
1005 workspaces: None,
1006 since: None,
1007 until: None,
1008 path_mode: PathMode::Full,
1009 };
1010 let engine = ExportEngine::new(Path::new("/tmp/s.db"), Path::new("/tmp/o.db"), filter);
1011
1012 let workspace = Some("/home/user/project".to_string());
1013 let result = engine.transform_path("/home/user/project/src/main.rs", &workspace);
1014 assert_eq!(result, "/home/user/project/src/main.rs");
1016 }
1017
1018 #[test]
1019 fn test_transform_path_basename_mode() {
1020 let filter = ExportFilter {
1021 agents: None,
1022 workspaces: None,
1023 since: None,
1024 until: None,
1025 path_mode: PathMode::Basename,
1026 };
1027 let engine = ExportEngine::new(Path::new("/tmp/s.db"), Path::new("/tmp/o.db"), filter);
1028
1029 let result = engine.transform_path("/home/user/project/src/main.rs", &None);
1030 assert_eq!(result, "main.rs");
1031 }
1032
1033 #[test]
1034 fn test_transform_path_basename_mode_nested() {
1035 let filter = ExportFilter {
1036 agents: None,
1037 workspaces: None,
1038 since: None,
1039 until: None,
1040 path_mode: PathMode::Basename,
1041 };
1042 let engine = ExportEngine::new(Path::new("/tmp/s.db"), Path::new("/tmp/o.db"), filter);
1043
1044 let result = engine.transform_path("/very/deep/nested/path/to/file.txt", &None);
1045 assert_eq!(result, "file.txt");
1046 }
1047
1048 #[test]
1049 fn test_transform_path_basename_mode_no_extension() {
1050 let filter = ExportFilter {
1051 agents: None,
1052 workspaces: None,
1053 since: None,
1054 until: None,
1055 path_mode: PathMode::Basename,
1056 };
1057 let engine = ExportEngine::new(Path::new("/tmp/s.db"), Path::new("/tmp/o.db"), filter);
1058
1059 let result = engine.transform_path("/usr/bin/cargo", &None);
1060 assert_eq!(result, "cargo");
1061 }
1062
1063 #[test]
1064 fn test_transform_path_relative_mode_with_workspace() {
1065 let filter = ExportFilter {
1066 agents: None,
1067 workspaces: None,
1068 since: None,
1069 until: None,
1070 path_mode: PathMode::Relative,
1071 };
1072 let engine = ExportEngine::new(Path::new("/tmp/s.db"), Path::new("/tmp/o.db"), filter);
1073
1074 let workspace = Some("/home/user/project".to_string());
1075 let result = engine.transform_path("/home/user/project/src/main.rs", &workspace);
1076 assert_eq!(result, "src/main.rs");
1077 }
1078
1079 #[test]
1080 fn test_transform_path_relative_mode_without_workspace() {
1081 let filter = ExportFilter {
1082 agents: None,
1083 workspaces: None,
1084 since: None,
1085 until: None,
1086 path_mode: PathMode::Relative,
1087 };
1088 let engine = ExportEngine::new(Path::new("/tmp/s.db"), Path::new("/tmp/o.db"), filter);
1089
1090 let result = engine.transform_path("/home/user/project/src/main.rs", &None);
1091 assert_eq!(result, "/home/user/project/src/main.rs");
1093 }
1094
1095 #[test]
1096 fn test_transform_path_relative_mode_path_not_under_workspace() {
1097 let filter = ExportFilter {
1098 agents: None,
1099 workspaces: None,
1100 since: None,
1101 until: None,
1102 path_mode: PathMode::Relative,
1103 };
1104 let engine = ExportEngine::new(Path::new("/tmp/s.db"), Path::new("/tmp/o.db"), filter);
1105
1106 let workspace = Some("/home/user/project".to_string());
1107 let result = engine.transform_path("/other/path/file.rs", &workspace);
1108 assert_eq!(result, "/other/path/file.rs");
1110 }
1111
1112 #[test]
1113 fn test_transform_path_relative_mode_strips_leading_slash() {
1114 let filter = ExportFilter {
1115 agents: None,
1116 workspaces: None,
1117 since: None,
1118 until: None,
1119 path_mode: PathMode::Relative,
1120 };
1121 let engine = ExportEngine::new(Path::new("/tmp/s.db"), Path::new("/tmp/o.db"), filter);
1122
1123 let workspace = Some("/home/user".to_string());
1124 let result = engine.transform_path("/home/user/file.rs", &workspace);
1125 assert_eq!(result, "file.rs");
1126 }
1127
1128 #[test]
1129 fn test_transform_path_hash_mode() {
1130 let filter = ExportFilter {
1131 agents: None,
1132 workspaces: None,
1133 since: None,
1134 until: None,
1135 path_mode: PathMode::Hash,
1136 };
1137 let engine = ExportEngine::new(Path::new("/tmp/s.db"), Path::new("/tmp/o.db"), filter);
1138
1139 let result = engine.transform_path("/home/user/project/file.rs", &None);
1140 assert_eq!(result.len(), 16);
1142 assert!(result.chars().all(|c| c.is_ascii_hexdigit()));
1143 }
1144
1145 #[test]
1146 fn test_transform_path_hash_mode_deterministic() {
1147 let filter1 = ExportFilter {
1148 agents: None,
1149 workspaces: None,
1150 since: None,
1151 until: None,
1152 path_mode: PathMode::Hash,
1153 };
1154 let engine1 = ExportEngine::new(Path::new("/tmp/s.db"), Path::new("/tmp/o.db"), filter1);
1155
1156 let filter2 = ExportFilter {
1157 agents: None,
1158 workspaces: None,
1159 since: None,
1160 until: None,
1161 path_mode: PathMode::Hash,
1162 };
1163 let engine2 = ExportEngine::new(Path::new("/tmp/s.db"), Path::new("/tmp/o.db"), filter2);
1164
1165 let path = "/home/user/project/file.rs";
1166 let result1 = engine1.transform_path(path, &None);
1167 let result2 = engine2.transform_path(path, &None);
1168
1169 assert_eq!(result1, result2);
1170 }
1171
1172 #[test]
1173 fn test_transform_path_hash_mode_different_paths_different_hashes() {
1174 let filter = ExportFilter {
1175 agents: None,
1176 workspaces: None,
1177 since: None,
1178 until: None,
1179 path_mode: PathMode::Hash,
1180 };
1181 let engine = ExportEngine::new(Path::new("/tmp/s.db"), Path::new("/tmp/o.db"), filter);
1182
1183 let result1 = engine.transform_path("/path/one/file.rs", &None);
1184 let result2 = engine.transform_path("/path/two/file.rs", &None);
1185
1186 assert_ne!(result1, result2);
1187 }
1188
1189 #[test]
1192 fn test_export_stats_default_values() {
1193 let stats = ExportStats {
1194 conversations_processed: 0,
1195 messages_processed: 0,
1196 };
1197
1198 assert_eq!(stats.conversations_processed, 0);
1199 assert_eq!(stats.messages_processed, 0);
1200 }
1201
1202 #[test]
1203 fn test_export_stats_with_values() {
1204 let stats = ExportStats {
1205 conversations_processed: 100,
1206 messages_processed: 5000,
1207 };
1208
1209 assert_eq!(stats.conversations_processed, 100);
1210 assert_eq!(stats.messages_processed, 5000);
1211 }
1212
1213 #[test]
1216 fn test_transform_path_empty_path() {
1217 let filter = ExportFilter {
1218 agents: None,
1219 workspaces: None,
1220 since: None,
1221 until: None,
1222 path_mode: PathMode::Full,
1223 };
1224 let engine = ExportEngine::new(Path::new("/tmp/s.db"), Path::new("/tmp/o.db"), filter);
1225
1226 let result = engine.transform_path("", &None);
1227 assert_eq!(result, "");
1228 }
1229
1230 #[test]
1231 fn test_transform_path_basename_empty_returns_original() {
1232 let filter = ExportFilter {
1233 agents: None,
1234 workspaces: None,
1235 since: None,
1236 until: None,
1237 path_mode: PathMode::Basename,
1238 };
1239 let engine = ExportEngine::new(Path::new("/tmp/s.db"), Path::new("/tmp/o.db"), filter);
1240
1241 let result = engine.transform_path("", &None);
1243 assert_eq!(result, "");
1244 }
1245
1246 #[test]
1247 fn test_transform_path_with_special_characters() {
1248 let filter = ExportFilter {
1249 agents: None,
1250 workspaces: None,
1251 since: None,
1252 until: None,
1253 path_mode: PathMode::Basename,
1254 };
1255 let engine = ExportEngine::new(Path::new("/tmp/s.db"), Path::new("/tmp/o.db"), filter);
1256
1257 let result = engine.transform_path("/path/to/file with spaces.rs", &None);
1258 assert_eq!(result, "file with spaces.rs");
1259 }
1260
1261 #[test]
1262 fn test_transform_path_hash_with_unicode() {
1263 let filter = ExportFilter {
1264 agents: None,
1265 workspaces: None,
1266 since: None,
1267 until: None,
1268 path_mode: PathMode::Hash,
1269 };
1270 let engine = ExportEngine::new(Path::new("/tmp/s.db"), Path::new("/tmp/o.db"), filter);
1271
1272 let result = engine.transform_path("/path/to/файл.rs", &None);
1273 assert_eq!(result.len(), 16);
1275 assert!(result.chars().all(|c| c.is_ascii_hexdigit()));
1276 }
1277
1278 #[test]
1279 fn test_export_filter_empty_agents_list() {
1280 let filter = ExportFilter {
1281 agents: Some(vec![]),
1282 workspaces: None,
1283 since: None,
1284 until: None,
1285 path_mode: PathMode::Full,
1286 };
1287
1288 assert!(filter.agents.as_ref().unwrap().is_empty());
1289 }
1290
1291 #[test]
1292 fn test_export_filter_empty_workspaces_list() {
1293 let filter = ExportFilter {
1294 agents: None,
1295 workspaces: Some(vec![]),
1296 since: None,
1297 until: None,
1298 path_mode: PathMode::Full,
1299 };
1300
1301 assert!(filter.workspaces.as_ref().unwrap().is_empty());
1302 }
1303
1304 #[test]
1307 fn test_export_engine_new_with_tempdir() {
1308 let temp_dir = TempDir::new().expect("create temp dir");
1309 let source = temp_dir.path().join("source.db");
1310 let output = temp_dir.path().join("output.db");
1311
1312 let filter = ExportFilter {
1313 agents: None,
1314 workspaces: None,
1315 since: None,
1316 until: None,
1317 path_mode: PathMode::Full,
1318 };
1319
1320 let engine = ExportEngine::new(&source, &output, filter);
1321
1322 assert!(engine.source_db_path.starts_with(temp_dir.path()));
1323 assert!(engine.output_path.starts_with(temp_dir.path()));
1324 }
1325
1326 #[test]
1327 fn test_replace_file_from_temp_overwrites_existing_file() {
1328 let temp_dir = TempDir::new().expect("create temp dir");
1329 let final_path = temp_dir.path().join("export.db");
1330 let first_tmp = temp_dir.path().join("first.tmp");
1331 let second_tmp = temp_dir.path().join("second.tmp");
1332
1333 std::fs::write(&first_tmp, b"first").expect("write first temp");
1334 replace_file_from_temp(&first_tmp, &final_path).expect("initial replace");
1335 assert_eq!(
1336 std::fs::read(&final_path).expect("read first final"),
1337 b"first"
1338 );
1339
1340 std::fs::write(&second_tmp, b"second").expect("write second temp");
1341 replace_file_from_temp(&second_tmp, &final_path).expect("overwrite replace");
1342 assert_eq!(
1343 std::fs::read(&final_path).expect("read second final"),
1344 b"second"
1345 );
1346 }
1347}