Skip to main content

coding_agent_search/pages/
export.rs

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        // 1. Open source DB
89        let src = super::open_existing_sqlite_db(&self.source_db_path)
90            .context("Failed to open source database")?;
91
92        // 2. Build the export into a unique temp database, then atomically
93        // replace the final output only after a successful commit.
94        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                // 3. Create Schema (Split into individual statements)
114                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                // 4. Query Source.  LEFT JOIN + COALESCE on agents so the
184                // export path includes legacy NULL-agent conversations
185                // (otherwise the exported archive silently omits them).
186                // Agent filter becomes an EXISTS guard against the agents
187                // table so it works correctly without the joined column.
188                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                // Note: Workspace filtering in source DB might be string matching if paths aren't normalized consistently.
216                // Assuming strict matching for now.
217                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                // Count total for progress
244                let count_query = format!("SELECT COUNT(*) FROM ({})", query);
245                let total_convs: usize =
246                    src.query_row_map(&count_query, &params, |row: &FrankenRow| {
247                        row.get_typed::<i64>(0).map(|v| v as usize)
248                    })?;
249
250                // Execute Main Query - collect all conversation rows
251                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, &params, |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                    // Transform Path
302                    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                    // Fetch messages for this conversation
321                    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                        // Populate FTS
374                        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                        // 5. Migrate Snippets for this message (bd-4x92)
384                        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                // Metadata
418                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                // sha2 ≥ 0.11 dropped `LowerHex` on the digest output;
471                // `hex::encode` gives the same lowercase-hex string.
472                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    // ==================== ExportFilter tests ====================
818
819    #[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    // ==================== PathMode tests ====================
903
904    #[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    // ==================== ExportEngine::new() tests ====================
940
941    #[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    // ==================== ExportEngine::transform_path() tests ====================
978
979    #[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        // Full mode ignores workspace
1008        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        // Without workspace, returns full path
1085        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        // Path not under workspace, returns full path
1102        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        // Hash should be 16 hex characters
1134        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    // ==================== ExportStats tests ====================
1183
1184    #[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    // ==================== Edge case tests ====================
1207
1208    #[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        // Empty path has no file_name
1235        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        // Should still produce valid 16-char hex hash
1267        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    // ==================== Integration-style tests (with real temp files) ====================
1298
1299    #[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}