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