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                // Pages exports are encrypted/copied as one portable SQLite file.
104                // WAL would allow committed schema/data to remain in a sidecar
105                // that is not part of the encrypted payload.
106                "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                // 3. Create Schema (Split into individual statements)
117                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                // 4. Query Source.  LEFT JOIN + COALESCE on agents so the
187                // export path includes legacy NULL-agent conversations
188                // (otherwise the exported archive silently omits them).
189                // Agent filter becomes an EXISTS guard against the agents
190                // table so it works correctly without the joined column.
191                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                // 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                        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, &params, |row: &FrankenRow| {
254                        row.get_typed::<i64>(0).map(|v| v as usize)
255                    })?;
256
257                // Execute Main Query - collect all conversation rows
258                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, &params, |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                    // Transform Path
309                    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                    // Fetch messages for this conversation
328                    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                        // Populate FTS
381                        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                        // 5. Migrate Snippets for this message (bd-4x92)
391                        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                // Metadata
425                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                // sha2 ≥ 0.11 dropped `LowerHex` on the digest output;
478                // `hex::encode` gives the same lowercase-hex string.
479                hex::encode(hasher.finalize())[..16].to_string()
480            }
481        }
482    }
483}
484
485type MessageExportRow = (
486    i64,
487    String,
488    String,
489    Option<i64>,
490    i64,
491    Option<i64>,
492    Option<String>,
493    Option<String>,
494    Option<String>,
495);
496
497fn table_columns(conn: &Connection, table_name: &str) -> Result<Vec<String>> {
498    let pragma = format!("PRAGMA table_info({table_name})");
499    conn.query_map_collect(&pragma, params![], |row: &FrankenRow| {
500        row.get_typed::<String>(1)
501    })
502    .context("Failed to inspect source table schema")
503}
504
505fn table_exists(conn: &Connection, table_name: &str) -> bool {
506    if !table_name
507        .chars()
508        .all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
509    {
510        return false;
511    }
512
513    table_columns(conn, table_name)
514        .map(|columns| !columns.is_empty())
515        .unwrap_or(false)
516}
517
518fn build_message_export_query(columns: &[String]) -> String {
519    let has_updated_at = columns.iter().any(|col| col == "updated_at");
520    let has_model = columns.iter().any(|col| col == "model");
521    let has_attachment_refs = columns.iter().any(|col| col == "attachment_refs");
522    let has_extra_json = columns.iter().any(|col| col == "extra_json");
523
524    format!(
525        "SELECT id, role, content, created_at, idx, {}, {}, {}, {}
526         FROM messages
527         WHERE conversation_id = ?1
528         ORDER BY idx ASC",
529        if has_updated_at {
530            "updated_at"
531        } else {
532            "NULL AS updated_at"
533        },
534        if has_model { "model" } else { "NULL AS model" },
535        if has_attachment_refs {
536            "attachment_refs"
537        } else {
538            "NULL AS attachment_refs"
539        },
540        if has_extra_json {
541            "extra_json"
542        } else {
543            "NULL AS extra_json"
544        }
545    )
546}
547
548fn normalize_optional_text(value: Option<String>) -> Option<String> {
549    value.and_then(|text| {
550        let trimmed = text.trim();
551        if trimmed.is_empty() {
552            None
553        } else {
554            Some(trimmed.to_string())
555        }
556    })
557}
558
559fn derive_message_model(extra_json: Option<&str>) -> Option<String> {
560    let value: Value = serde_json::from_str(extra_json?).ok()?;
561
562    [
563        value.pointer("/model"),
564        value.pointer("/cass/model"),
565        value.pointer("/model_id"),
566        value.pointer("/message/model"),
567        value.pointer("/message/model_id"),
568        value.pointer("/metadata/model"),
569    ]
570    .into_iter()
571    .flatten()
572    .find_map(|candidate| candidate.as_str())
573    .map(str::trim)
574    .filter(|candidate| !candidate.is_empty())
575    .map(ToOwned::to_owned)
576}
577
578fn derive_attachment_refs(extra_json: Option<&str>) -> Option<String> {
579    let value: Value = serde_json::from_str(extra_json?).ok()?;
580
581    [
582        value.pointer("/attachment_refs"),
583        value.pointer("/attachments"),
584        value.pointer("/cass/attachment_refs"),
585        value.pointer("/cass/attachments"),
586        value.pointer("/attachmentRefs"),
587        value.pointer("/message/attachment_refs"),
588        value.pointer("/message/attachments"),
589        value.pointer("/metadata/attachment_refs"),
590        value.pointer("/metadata/attachments"),
591    ]
592    .into_iter()
593    .flatten()
594    .find_map(|candidate| {
595        if candidate.is_null() {
596            None
597        } else {
598            serde_json::to_string(candidate).ok()
599        }
600    })
601}
602
603#[cfg(any(windows, test))]
604fn unique_replace_backup_path(path: &Path) -> PathBuf {
605    unique_atomic_sidecar_path(path, "bak", "pages_export.db")
606}
607
608fn unique_atomic_sidecar_path(path: &Path, suffix: &str, fallback_name: &str) -> PathBuf {
609    static NEXT_NONCE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
610
611    let timestamp = std::time::SystemTime::now()
612        .duration_since(std::time::UNIX_EPOCH)
613        .unwrap_or_default()
614        .as_nanos();
615    let nonce = NEXT_NONCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
616    let file_name = path
617        .file_name()
618        .and_then(|name| name.to_str())
619        .unwrap_or(fallback_name);
620
621    path.with_file_name(format!(
622        ".{file_name}.{suffix}.{}.{}.{}",
623        std::process::id(),
624        timestamp,
625        nonce
626    ))
627}
628
629fn cleanup_sqlite_temp_artifacts(path: &Path) {
630    let _ = std::fs::remove_file(path);
631    let _ = std::fs::remove_file(sidecar_path(path, "-wal"));
632    let _ = std::fs::remove_file(sidecar_path(path, "-shm"));
633}
634
635fn sidecar_path(path: &Path, suffix: &str) -> PathBuf {
636    let file_name = path
637        .file_name()
638        .map(|name| name.to_string_lossy().to_string())
639        .unwrap_or_else(|| "pages_export.db".to_string());
640    path.with_file_name(format!("{file_name}{suffix}"))
641}
642
643#[cfg(any(windows, test))]
644fn replacement_path_entry_exists(path: &Path) -> Result<bool> {
645    match std::fs::symlink_metadata(path) {
646        Ok(_) => Ok(true),
647        Err(err) if matches!(err.kind(), std::io::ErrorKind::NotFound) => Ok(false),
648        Err(err) => {
649            Err(err).with_context(|| format!("failed inspecting export path {}", path.display()))
650        }
651    }
652}
653
654#[cfg(any(windows, test))]
655fn replace_file_from_temp_via_backup(
656    temp_path: &Path,
657    final_path: &Path,
658    first_err: &std::io::Error,
659) -> Result<()> {
660    let backup_path = unique_replace_backup_path(final_path);
661    std::fs::rename(final_path, &backup_path).with_context(|| {
662        let _ = std::fs::remove_file(temp_path);
663        format!(
664            "failed preparing backup {} before replacing {} after initial rename error: {}",
665            backup_path.display(),
666            final_path.display(),
667            first_err
668        )
669    })?;
670
671    match std::fs::rename(temp_path, final_path) {
672        Ok(()) => {
673            let _ = std::fs::remove_file(&backup_path);
674            sync_parent_directory(final_path)?;
675            Ok(())
676        }
677        Err(second_err) => match std::fs::rename(&backup_path, final_path) {
678            Ok(()) => {
679                let _ = std::fs::remove_file(temp_path);
680                sync_parent_directory(final_path)?;
681                bail!(
682                    "failed replacing {} with {}: first error: {}; second error: {}; restored original file",
683                    final_path.display(),
684                    temp_path.display(),
685                    first_err,
686                    second_err
687                );
688            }
689            Err(restore_err) => {
690                bail!(
691                    "failed replacing {} with {}: first error: {}; second error: {}; restore error: {}; temp file retained at {}",
692                    final_path.display(),
693                    temp_path.display(),
694                    first_err,
695                    second_err,
696                    restore_err,
697                    temp_path.display()
698                );
699            }
700        },
701    }
702}
703
704fn replace_file_from_temp(temp_path: &Path, final_path: &Path) -> Result<()> {
705    #[cfg(windows)]
706    {
707        match std::fs::rename(temp_path, final_path) {
708            Ok(()) => {
709                sync_parent_directory(final_path)?;
710                Ok(())
711            }
712            Err(first_err)
713                if matches!(
714                    first_err.kind(),
715                    std::io::ErrorKind::AlreadyExists | std::io::ErrorKind::PermissionDenied
716                ) =>
717            {
718                if replacement_path_entry_exists(final_path)? {
719                    replace_file_from_temp_via_backup(temp_path, final_path, &first_err)
720                } else {
721                    Err(first_err).with_context(|| {
722                        format!(
723                            "failed renaming completed export {} into place at {}",
724                            temp_path.display(),
725                            final_path.display()
726                        )
727                    })
728                }
729            }
730            Err(rename_err) => Err(rename_err).with_context(|| {
731                format!(
732                    "failed renaming completed export {} into place at {}",
733                    temp_path.display(),
734                    final_path.display()
735                )
736            }),
737        }
738    }
739
740    #[cfg(not(windows))]
741    {
742        std::fs::rename(temp_path, final_path).with_context(|| {
743            format!(
744                "failed renaming completed export {} into place at {}",
745                temp_path.display(),
746                final_path.display()
747            )
748        })?;
749        sync_parent_directory(final_path)
750    }
751}
752
753#[cfg(not(windows))]
754fn sync_parent_directory(path: &Path) -> Result<()> {
755    let Some(parent) = path.parent() else {
756        return Ok(());
757    };
758    std::fs::File::open(parent)
759        .with_context(|| format!("failed opening parent directory {}", parent.display()))?
760        .sync_all()
761        .with_context(|| format!("failed syncing parent directory {}", parent.display()))
762}
763
764#[cfg(windows)]
765fn sync_parent_directory(_path: &Path) -> Result<()> {
766    Ok(())
767}
768
769#[allow(clippy::too_many_arguments)]
770pub fn run_pages_export(
771    db_path: Option<PathBuf>,
772    output_path: PathBuf,
773    agents: Option<Vec<String>>,
774    workspaces: Option<Vec<String>>,
775    since: Option<String>,
776    until: Option<String>,
777    path_mode: PathMode,
778    dry_run: bool,
779) -> Result<()> {
780    if dry_run {
781        println!("Dry run: would export to {:?}", output_path);
782        return Ok(());
783    }
784
785    let db_path = db_path.unwrap_or_else(crate::default_db_path);
786
787    let since_dt = parse_export_time_arg("--since", since.as_deref())?;
788    let until_dt = parse_export_time_arg("--until", until.as_deref())?;
789
790    if let (Some(since_dt), Some(until_dt)) = (since_dt, until_dt)
791        && since_dt > until_dt
792    {
793        bail!(
794            "Invalid time range: --since ({}) is after --until ({})",
795            since_dt.to_rfc3339(),
796            until_dt.to_rfc3339()
797        );
798    }
799
800    let workspaces_path = workspaces.map(|ws| ws.into_iter().map(PathBuf::from).collect());
801
802    let filter = ExportFilter {
803        agents,
804        workspaces: workspaces_path,
805        since: since_dt,
806        until: until_dt,
807        path_mode,
808    };
809
810    let engine = ExportEngine::new(&db_path, &output_path, filter);
811
812    println!("Exporting to {:?}...", output_path);
813    let stats = engine.execute(
814        |current, total| {
815            if total > 0 && current % 100 == 0 {
816                use std::io::Write;
817                print!("\rProcessed {}/{} conversations...", current, total);
818                std::io::stdout().flush().ok();
819            }
820        },
821        None,
822    )?;
823    println!(
824        "\rExport complete! Processed {} conversations, {} messages.",
825        stats.conversations_processed, stats.messages_processed
826    );
827
828    Ok(())
829}
830
831fn parse_export_time_arg(
832    flag_name: &str,
833    raw_value: Option<&str>,
834) -> Result<Option<DateTime<Utc>>> {
835    let Some(raw_value) = raw_value else {
836        return Ok(None);
837    };
838
839    let timestamp = parse_time_input(raw_value)
840        .ok_or_else(|| anyhow::anyhow!("Invalid {flag_name} value: {raw_value}"))?;
841    let parsed = DateTime::from_timestamp_millis(timestamp)
842        .ok_or_else(|| anyhow::anyhow!("{flag_name} value is out of range: {raw_value}"))?;
843    Ok(Some(parsed))
844}
845
846#[cfg(test)]
847mod tests {
848    use super::*;
849    use chrono::{Datelike, TimeZone};
850    use std::path::Path;
851    use tempfile::TempDir;
852
853    // ==================== ExportFilter tests ====================
854
855    #[test]
856    fn test_export_filter_default_values() {
857        let filter = ExportFilter {
858            agents: None,
859            workspaces: None,
860            since: None,
861            until: None,
862            path_mode: PathMode::Full,
863        };
864
865        assert!(filter.agents.is_none());
866        assert!(filter.workspaces.is_none());
867        assert!(filter.since.is_none());
868        assert!(filter.until.is_none());
869        assert_eq!(filter.path_mode, PathMode::Full);
870    }
871
872    #[test]
873    fn test_export_filter_with_agents() {
874        let filter = ExportFilter {
875            agents: Some(vec!["claude".to_string(), "codex".to_string()]),
876            workspaces: None,
877            since: None,
878            until: None,
879            path_mode: PathMode::Relative,
880        };
881
882        let agents = filter.agents.as_ref().unwrap();
883        assert_eq!(agents.len(), 2);
884        assert!(agents.contains(&"claude".to_string()));
885        assert!(agents.contains(&"codex".to_string()));
886    }
887
888    #[test]
889    fn test_export_filter_with_workspaces() {
890        let filter = ExportFilter {
891            agents: None,
892            workspaces: Some(vec![
893                PathBuf::from("/home/user/project1"),
894                PathBuf::from("/home/user/project2"),
895            ]),
896            since: None,
897            until: None,
898            path_mode: PathMode::Basename,
899        };
900
901        let workspaces = filter.workspaces.as_ref().unwrap();
902        assert_eq!(workspaces.len(), 2);
903    }
904
905    #[test]
906    fn test_export_filter_with_time_range() {
907        let since = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
908        let until = Utc.with_ymd_and_hms(2025, 12, 31, 23, 59, 59).unwrap();
909
910        let filter = ExportFilter {
911            agents: None,
912            workspaces: None,
913            since: Some(since),
914            until: Some(until),
915            path_mode: PathMode::Hash,
916        };
917
918        assert_eq!(filter.since.unwrap().year(), 2025);
919        assert_eq!(filter.until.unwrap().month(), 12);
920    }
921
922    #[test]
923    fn test_export_filter_clone() {
924        let filter = ExportFilter {
925            agents: Some(vec!["gemini".to_string()]),
926            workspaces: Some(vec![PathBuf::from("/tmp/test")]),
927            since: None,
928            until: None,
929            path_mode: PathMode::Full,
930        };
931
932        let cloned = filter.clone();
933        assert_eq!(cloned.agents, filter.agents);
934        assert_eq!(cloned.workspaces, filter.workspaces);
935        assert_eq!(cloned.path_mode, filter.path_mode);
936    }
937
938    // ==================== PathMode tests ====================
939
940    #[test]
941    fn test_path_mode_equality() {
942        assert_eq!(PathMode::Relative, PathMode::Relative);
943        assert_eq!(PathMode::Basename, PathMode::Basename);
944        assert_eq!(PathMode::Full, PathMode::Full);
945        assert_eq!(PathMode::Hash, PathMode::Hash);
946    }
947
948    #[test]
949    fn test_path_mode_inequality() {
950        assert_ne!(PathMode::Relative, PathMode::Full);
951        assert_ne!(PathMode::Basename, PathMode::Hash);
952        assert_ne!(PathMode::Full, PathMode::Relative);
953    }
954
955    #[test]
956    fn test_path_mode_clone() {
957        let mode = PathMode::Hash;
958        let cloned = mode;
959        assert_eq!(mode, cloned);
960    }
961
962    #[test]
963    fn test_path_mode_copy() {
964        let mode = PathMode::Relative;
965        let copied: PathMode = mode;
966        assert_eq!(copied, PathMode::Relative);
967    }
968
969    #[test]
970    fn test_path_mode_debug() {
971        let debug_str = format!("{:?}", PathMode::Full);
972        assert!(debug_str.contains("Full"));
973    }
974
975    // ==================== ExportEngine::new() tests ====================
976
977    #[test]
978    fn test_export_engine_new_stores_paths() {
979        let source = Path::new("/tmp/source.db");
980        let output = Path::new("/tmp/output.db");
981        let filter = ExportFilter {
982            agents: None,
983            workspaces: None,
984            since: None,
985            until: None,
986            path_mode: PathMode::Full,
987        };
988
989        let engine = ExportEngine::new(source, output, filter);
990
991        assert_eq!(engine.source_db_path, PathBuf::from("/tmp/source.db"));
992        assert_eq!(engine.output_path, PathBuf::from("/tmp/output.db"));
993    }
994
995    #[test]
996    fn test_export_engine_new_with_relative_paths() {
997        let source = Path::new("relative/source.db");
998        let output = Path::new("relative/output.db");
999        let filter = ExportFilter {
1000            agents: None,
1001            workspaces: None,
1002            since: None,
1003            until: None,
1004            path_mode: PathMode::Basename,
1005        };
1006
1007        let engine = ExportEngine::new(source, output, filter);
1008
1009        assert_eq!(engine.source_db_path, PathBuf::from("relative/source.db"));
1010        assert_eq!(engine.output_path, PathBuf::from("relative/output.db"));
1011    }
1012
1013    // ==================== ExportEngine::transform_path() tests ====================
1014
1015    #[test]
1016    fn test_transform_path_full_mode() {
1017        let filter = ExportFilter {
1018            agents: None,
1019            workspaces: None,
1020            since: None,
1021            until: None,
1022            path_mode: PathMode::Full,
1023        };
1024        let engine = ExportEngine::new(Path::new("/tmp/s.db"), Path::new("/tmp/o.db"), filter);
1025
1026        let result = engine.transform_path("/home/user/project/file.rs", &None);
1027        assert_eq!(result, "/home/user/project/file.rs");
1028    }
1029
1030    #[test]
1031    fn test_transform_path_full_mode_with_workspace() {
1032        let filter = ExportFilter {
1033            agents: None,
1034            workspaces: None,
1035            since: None,
1036            until: None,
1037            path_mode: PathMode::Full,
1038        };
1039        let engine = ExportEngine::new(Path::new("/tmp/s.db"), Path::new("/tmp/o.db"), filter);
1040
1041        let workspace = Some("/home/user/project".to_string());
1042        let result = engine.transform_path("/home/user/project/src/main.rs", &workspace);
1043        // Full mode ignores workspace
1044        assert_eq!(result, "/home/user/project/src/main.rs");
1045    }
1046
1047    #[test]
1048    fn test_transform_path_basename_mode() {
1049        let filter = ExportFilter {
1050            agents: None,
1051            workspaces: None,
1052            since: None,
1053            until: None,
1054            path_mode: PathMode::Basename,
1055        };
1056        let engine = ExportEngine::new(Path::new("/tmp/s.db"), Path::new("/tmp/o.db"), filter);
1057
1058        let result = engine.transform_path("/home/user/project/src/main.rs", &None);
1059        assert_eq!(result, "main.rs");
1060    }
1061
1062    #[test]
1063    fn test_transform_path_basename_mode_nested() {
1064        let filter = ExportFilter {
1065            agents: None,
1066            workspaces: None,
1067            since: None,
1068            until: None,
1069            path_mode: PathMode::Basename,
1070        };
1071        let engine = ExportEngine::new(Path::new("/tmp/s.db"), Path::new("/tmp/o.db"), filter);
1072
1073        let result = engine.transform_path("/very/deep/nested/path/to/file.txt", &None);
1074        assert_eq!(result, "file.txt");
1075    }
1076
1077    #[test]
1078    fn test_transform_path_basename_mode_no_extension() {
1079        let filter = ExportFilter {
1080            agents: None,
1081            workspaces: None,
1082            since: None,
1083            until: None,
1084            path_mode: PathMode::Basename,
1085        };
1086        let engine = ExportEngine::new(Path::new("/tmp/s.db"), Path::new("/tmp/o.db"), filter);
1087
1088        let result = engine.transform_path("/usr/bin/cargo", &None);
1089        assert_eq!(result, "cargo");
1090    }
1091
1092    #[test]
1093    fn test_transform_path_relative_mode_with_workspace() {
1094        let filter = ExportFilter {
1095            agents: None,
1096            workspaces: None,
1097            since: None,
1098            until: None,
1099            path_mode: PathMode::Relative,
1100        };
1101        let engine = ExportEngine::new(Path::new("/tmp/s.db"), Path::new("/tmp/o.db"), filter);
1102
1103        let workspace = Some("/home/user/project".to_string());
1104        let result = engine.transform_path("/home/user/project/src/main.rs", &workspace);
1105        assert_eq!(result, "src/main.rs");
1106    }
1107
1108    #[test]
1109    fn test_transform_path_relative_mode_without_workspace() {
1110        let filter = ExportFilter {
1111            agents: None,
1112            workspaces: None,
1113            since: None,
1114            until: None,
1115            path_mode: PathMode::Relative,
1116        };
1117        let engine = ExportEngine::new(Path::new("/tmp/s.db"), Path::new("/tmp/o.db"), filter);
1118
1119        let result = engine.transform_path("/home/user/project/src/main.rs", &None);
1120        // Without workspace, returns full path
1121        assert_eq!(result, "/home/user/project/src/main.rs");
1122    }
1123
1124    #[test]
1125    fn test_transform_path_relative_mode_path_not_under_workspace() {
1126        let filter = ExportFilter {
1127            agents: None,
1128            workspaces: None,
1129            since: None,
1130            until: None,
1131            path_mode: PathMode::Relative,
1132        };
1133        let engine = ExportEngine::new(Path::new("/tmp/s.db"), Path::new("/tmp/o.db"), filter);
1134
1135        let workspace = Some("/home/user/project".to_string());
1136        let result = engine.transform_path("/other/path/file.rs", &workspace);
1137        // Path not under workspace, returns full path
1138        assert_eq!(result, "/other/path/file.rs");
1139    }
1140
1141    #[test]
1142    fn test_transform_path_relative_mode_strips_leading_slash() {
1143        let filter = ExportFilter {
1144            agents: None,
1145            workspaces: None,
1146            since: None,
1147            until: None,
1148            path_mode: PathMode::Relative,
1149        };
1150        let engine = ExportEngine::new(Path::new("/tmp/s.db"), Path::new("/tmp/o.db"), filter);
1151
1152        let workspace = Some("/home/user".to_string());
1153        let result = engine.transform_path("/home/user/file.rs", &workspace);
1154        assert_eq!(result, "file.rs");
1155    }
1156
1157    #[test]
1158    fn test_transform_path_hash_mode() {
1159        let filter = ExportFilter {
1160            agents: None,
1161            workspaces: None,
1162            since: None,
1163            until: None,
1164            path_mode: PathMode::Hash,
1165        };
1166        let engine = ExportEngine::new(Path::new("/tmp/s.db"), Path::new("/tmp/o.db"), filter);
1167
1168        let result = engine.transform_path("/home/user/project/file.rs", &None);
1169        // Hash should be 16 hex characters
1170        assert_eq!(result.len(), 16);
1171        assert!(result.chars().all(|c| c.is_ascii_hexdigit()));
1172    }
1173
1174    #[test]
1175    fn test_transform_path_hash_mode_deterministic() {
1176        let filter1 = ExportFilter {
1177            agents: None,
1178            workspaces: None,
1179            since: None,
1180            until: None,
1181            path_mode: PathMode::Hash,
1182        };
1183        let engine1 = ExportEngine::new(Path::new("/tmp/s.db"), Path::new("/tmp/o.db"), filter1);
1184
1185        let filter2 = ExportFilter {
1186            agents: None,
1187            workspaces: None,
1188            since: None,
1189            until: None,
1190            path_mode: PathMode::Hash,
1191        };
1192        let engine2 = ExportEngine::new(Path::new("/tmp/s.db"), Path::new("/tmp/o.db"), filter2);
1193
1194        let path = "/home/user/project/file.rs";
1195        let result1 = engine1.transform_path(path, &None);
1196        let result2 = engine2.transform_path(path, &None);
1197
1198        assert_eq!(result1, result2);
1199    }
1200
1201    #[test]
1202    fn test_transform_path_hash_mode_different_paths_different_hashes() {
1203        let filter = ExportFilter {
1204            agents: None,
1205            workspaces: None,
1206            since: None,
1207            until: None,
1208            path_mode: PathMode::Hash,
1209        };
1210        let engine = ExportEngine::new(Path::new("/tmp/s.db"), Path::new("/tmp/o.db"), filter);
1211
1212        let result1 = engine.transform_path("/path/one/file.rs", &None);
1213        let result2 = engine.transform_path("/path/two/file.rs", &None);
1214
1215        assert_ne!(result1, result2);
1216    }
1217
1218    // ==================== ExportStats tests ====================
1219
1220    #[test]
1221    fn test_export_stats_default_values() {
1222        let stats = ExportStats {
1223            conversations_processed: 0,
1224            messages_processed: 0,
1225        };
1226
1227        assert_eq!(stats.conversations_processed, 0);
1228        assert_eq!(stats.messages_processed, 0);
1229    }
1230
1231    #[test]
1232    fn test_export_stats_with_values() {
1233        let stats = ExportStats {
1234            conversations_processed: 100,
1235            messages_processed: 5000,
1236        };
1237
1238        assert_eq!(stats.conversations_processed, 100);
1239        assert_eq!(stats.messages_processed, 5000);
1240    }
1241
1242    // ==================== Edge case tests ====================
1243
1244    #[test]
1245    fn test_transform_path_empty_path() {
1246        let filter = ExportFilter {
1247            agents: None,
1248            workspaces: None,
1249            since: None,
1250            until: None,
1251            path_mode: PathMode::Full,
1252        };
1253        let engine = ExportEngine::new(Path::new("/tmp/s.db"), Path::new("/tmp/o.db"), filter);
1254
1255        let result = engine.transform_path("", &None);
1256        assert_eq!(result, "");
1257    }
1258
1259    #[test]
1260    fn test_transform_path_basename_empty_returns_original() {
1261        let filter = ExportFilter {
1262            agents: None,
1263            workspaces: None,
1264            since: None,
1265            until: None,
1266            path_mode: PathMode::Basename,
1267        };
1268        let engine = ExportEngine::new(Path::new("/tmp/s.db"), Path::new("/tmp/o.db"), filter);
1269
1270        // Empty path has no file_name
1271        let result = engine.transform_path("", &None);
1272        assert_eq!(result, "");
1273    }
1274
1275    #[test]
1276    fn test_transform_path_with_special_characters() {
1277        let filter = ExportFilter {
1278            agents: None,
1279            workspaces: None,
1280            since: None,
1281            until: None,
1282            path_mode: PathMode::Basename,
1283        };
1284        let engine = ExportEngine::new(Path::new("/tmp/s.db"), Path::new("/tmp/o.db"), filter);
1285
1286        let result = engine.transform_path("/path/to/file with spaces.rs", &None);
1287        assert_eq!(result, "file with spaces.rs");
1288    }
1289
1290    #[test]
1291    fn test_transform_path_hash_with_unicode() {
1292        let filter = ExportFilter {
1293            agents: None,
1294            workspaces: None,
1295            since: None,
1296            until: None,
1297            path_mode: PathMode::Hash,
1298        };
1299        let engine = ExportEngine::new(Path::new("/tmp/s.db"), Path::new("/tmp/o.db"), filter);
1300
1301        let result = engine.transform_path("/path/to/файл.rs", &None);
1302        // Should still produce valid 16-char hex hash
1303        assert_eq!(result.len(), 16);
1304        assert!(result.chars().all(|c| c.is_ascii_hexdigit()));
1305    }
1306
1307    #[test]
1308    fn test_export_filter_empty_agents_list() {
1309        let filter = ExportFilter {
1310            agents: Some(vec![]),
1311            workspaces: None,
1312            since: None,
1313            until: None,
1314            path_mode: PathMode::Full,
1315        };
1316
1317        assert!(filter.agents.as_ref().unwrap().is_empty());
1318    }
1319
1320    #[test]
1321    fn test_export_filter_empty_workspaces_list() {
1322        let filter = ExportFilter {
1323            agents: None,
1324            workspaces: Some(vec![]),
1325            since: None,
1326            until: None,
1327            path_mode: PathMode::Full,
1328        };
1329
1330        assert!(filter.workspaces.as_ref().unwrap().is_empty());
1331    }
1332
1333    // ==================== Integration-style tests (with real temp files) ====================
1334
1335    #[test]
1336    fn test_export_engine_new_with_tempdir() {
1337        let temp_dir = TempDir::new().expect("create temp dir");
1338        let source = temp_dir.path().join("source.db");
1339        let output = temp_dir.path().join("output.db");
1340
1341        let filter = ExportFilter {
1342            agents: None,
1343            workspaces: None,
1344            since: None,
1345            until: None,
1346            path_mode: PathMode::Full,
1347        };
1348
1349        let engine = ExportEngine::new(&source, &output, filter);
1350
1351        assert!(engine.source_db_path.starts_with(temp_dir.path()));
1352        assert!(engine.output_path.starts_with(temp_dir.path()));
1353    }
1354
1355    #[cfg(unix)]
1356    #[test]
1357    fn replacement_path_entry_exists_detects_dangling_symlink() -> Result<()> {
1358        use std::os::unix::fs::symlink;
1359
1360        let temp_dir = TempDir::new()?;
1361        let link_path = temp_dir.path().join("export.db");
1362        let missing_target = temp_dir.path().join("missing-export.db");
1363
1364        symlink(&missing_target, &link_path)?;
1365
1366        if link_path.exists() {
1367            return Err(anyhow::anyhow!(
1368                "Path::exists stopped following the missing target"
1369            ));
1370        }
1371        if !replacement_path_entry_exists(&link_path)? {
1372            return Err(anyhow::anyhow!(
1373                "replacement path helper missed a dangling symlink entry"
1374            ));
1375        }
1376
1377        Ok(())
1378    }
1379
1380    #[test]
1381    fn unique_replace_backup_path_is_not_reused() -> Result<()> {
1382        let temp_dir = TempDir::new()?;
1383        let final_path = temp_dir.path().join("export.db");
1384        let first = unique_replace_backup_path(&final_path);
1385        let second = unique_replace_backup_path(&final_path);
1386
1387        if first == second {
1388            return Err(anyhow::anyhow!(
1389                "export replacement backup path was reused: {}",
1390                first.display()
1391            ));
1392        }
1393
1394        Ok(())
1395    }
1396
1397    #[test]
1398    fn replace_file_from_temp_via_backup_overwrites_existing_file() -> Result<()> {
1399        let temp_dir = TempDir::new()?;
1400        let final_path = temp_dir.path().join("export.db");
1401        let temp_path = temp_dir.path().join("export.tmp");
1402        let first_err = std::io::Error::from(std::io::ErrorKind::AlreadyExists);
1403
1404        std::fs::write(&final_path, b"old export")?;
1405        std::fs::write(&temp_path, b"new export")?;
1406
1407        replace_file_from_temp_via_backup(&temp_path, &final_path, &first_err)?;
1408
1409        if !matches!(
1410            std::fs::read(&final_path)?.as_slice().cmp(b"new export"),
1411            std::cmp::Ordering::Equal
1412        ) {
1413            return Err(anyhow::anyhow!(
1414                "backup replacement did not publish temp bytes"
1415            ));
1416        }
1417        if temp_path.exists() {
1418            return Err(anyhow::anyhow!("export temp path was not consumed"));
1419        }
1420
1421        Ok(())
1422    }
1423
1424    #[test]
1425    fn test_replace_file_from_temp_overwrites_existing_file() {
1426        let temp_dir = TempDir::new().expect("create temp dir");
1427        let final_path = temp_dir.path().join("export.db");
1428        let first_tmp = temp_dir.path().join("first.tmp");
1429        let second_tmp = temp_dir.path().join("second.tmp");
1430
1431        std::fs::write(&first_tmp, b"first").expect("write first temp");
1432        replace_file_from_temp(&first_tmp, &final_path).expect("initial replace");
1433        assert_eq!(
1434            std::fs::read(&final_path).expect("read first final"),
1435            b"first"
1436        );
1437
1438        std::fs::write(&second_tmp, b"second").expect("write second temp");
1439        replace_file_from_temp(&second_tmp, &final_path).expect("overwrite replace");
1440        assert_eq!(
1441            std::fs::read(&final_path).expect("read second final"),
1442            b"second"
1443        );
1444    }
1445}