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