Skip to main content

task_graph_mcp/db/
import.rs

1//! Import functionality for the task-graph database.
2//!
3//! Provides methods to import data from a JSON snapshot into the database.
4//! Supports multiple import modes:
5//! - Fresh: Import into an empty database (fails if data exists)
6//! - Replace: Clear existing project data and import (default with --force)
7//! - Merge: Add missing items, skip or overwrite existing (future)
8//!
9//! Handles foreign key constraints by:
10//! - Deleting tables in reverse order (children first) when clearing
11//! - Inserting tables in forward order (parents first) when importing
12//!
13//! Rebuilds FTS indexes after import.
14
15use crate::export::{CURRENT_SCHEMA_VERSION, Snapshot};
16use anyhow::{Context, Result, anyhow};
17use rusqlite::params;
18use serde_json::Value;
19
20use super::Database;
21
22/// Import mode determining how to handle existing data.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
24pub enum ImportMode {
25    /// Import into an empty database. Fails if any project data exists.
26    #[default]
27    Fresh,
28    /// Clear all existing project data before importing.
29    /// Preserves runtime tables (workers, file_locks).
30    Replace,
31    /// Merge: Add missing items, skip existing.
32    /// - Tasks: skip if ID exists, insert if new
33    /// - Dependencies: skip if exact match exists
34    /// - Attachments: append (keeps both) or replace by name
35    /// - Tags: union existing and imported
36    /// - State sequence: skip (preserves existing history)
37    Merge,
38}
39
40/// Result of a dry-run import preview.
41/// Shows what would happen without making any changes.
42#[derive(Debug, Clone)]
43pub struct DryRunResult {
44    /// Import mode that would be used.
45    pub mode: ImportMode,
46    /// Whether the database is empty (relevant for Fresh mode).
47    pub database_is_empty: bool,
48    /// Number of existing rows per table (before import).
49    pub existing_rows: std::collections::BTreeMap<String, usize>,
50    /// Number of rows that would be deleted per table (Replace mode).
51    pub would_delete: std::collections::BTreeMap<String, usize>,
52    /// Number of rows that would be inserted per table.
53    pub would_insert: std::collections::BTreeMap<String, usize>,
54    /// Number of rows that would be skipped per table (Merge mode).
55    pub would_skip: std::collections::BTreeMap<String, usize>,
56    /// Whether the import would succeed with the given mode.
57    pub would_succeed: bool,
58    /// Reason for failure if would_succeed is false.
59    pub failure_reason: Option<String>,
60    /// Any warnings that would be generated.
61    pub warnings: Vec<String>,
62}
63
64impl DryRunResult {
65    /// Create a new empty dry-run result.
66    fn new(mode: ImportMode) -> Self {
67        Self {
68            mode,
69            database_is_empty: true,
70            existing_rows: std::collections::BTreeMap::new(),
71            would_delete: std::collections::BTreeMap::new(),
72            would_insert: std::collections::BTreeMap::new(),
73            would_skip: std::collections::BTreeMap::new(),
74            would_succeed: true,
75            failure_reason: None,
76            warnings: Vec::new(),
77        }
78    }
79
80    /// Total number of rows that would be deleted.
81    pub fn total_would_delete(&self) -> usize {
82        self.would_delete.values().sum()
83    }
84
85    /// Total number of rows that would be inserted.
86    pub fn total_would_insert(&self) -> usize {
87        self.would_insert.values().sum()
88    }
89
90    /// Total number of rows that would be skipped.
91    pub fn total_would_skip(&self) -> usize {
92        self.would_skip.values().sum()
93    }
94
95    /// Total existing rows in the database.
96    pub fn total_existing(&self) -> usize {
97        self.existing_rows.values().sum()
98    }
99}
100
101/// Result of an import operation.
102#[derive(Debug, Clone)]
103pub struct ImportResult {
104    /// Number of rows imported per table.
105    pub rows_imported: std::collections::BTreeMap<String, usize>,
106    /// Number of rows deleted per table (for replace mode).
107    pub rows_deleted: std::collections::BTreeMap<String, usize>,
108    /// Number of rows skipped per table (for merge mode).
109    pub rows_skipped: std::collections::BTreeMap<String, usize>,
110    /// Whether FTS indexes were rebuilt.
111    pub fts_rebuilt: bool,
112    /// Any warnings encountered during import.
113    pub warnings: Vec<String>,
114}
115
116impl ImportResult {
117    /// Create a new empty import result.
118    fn new() -> Self {
119        Self {
120            rows_imported: std::collections::BTreeMap::new(),
121            rows_deleted: std::collections::BTreeMap::new(),
122            rows_skipped: std::collections::BTreeMap::new(),
123            fts_rebuilt: false,
124            warnings: Vec::new(),
125        }
126    }
127
128    /// Total number of rows imported.
129    pub fn total_rows(&self) -> usize {
130        self.rows_imported.values().sum()
131    }
132
133    /// Total number of rows deleted.
134    pub fn total_deleted(&self) -> usize {
135        self.rows_deleted.values().sum()
136    }
137
138    /// Total number of rows skipped (merge mode).
139    pub fn total_skipped(&self) -> usize {
140        self.rows_skipped.values().sum()
141    }
142}
143
144/// Options for controlling import behavior.
145#[derive(Debug, Clone, Default)]
146pub struct ImportOptions {
147    /// Import mode (Fresh or Replace).
148    pub mode: ImportMode,
149}
150
151impl ImportOptions {
152    /// Create options for fresh import (empty database required).
153    pub fn fresh() -> Self {
154        Self {
155            mode: ImportMode::Fresh,
156        }
157    }
158
159    /// Create options for replace import (clear existing data).
160    pub fn replace() -> Self {
161        Self {
162            mode: ImportMode::Replace,
163        }
164    }
165
166    /// Create options for merge import (add missing items, skip existing).
167    pub fn merge() -> Self {
168        Self {
169            mode: ImportMode::Merge,
170        }
171    }
172}
173
174/// Tables in the order they should be imported (respecting foreign key constraints).
175/// Tasks must be imported first since other tables reference it.
176const IMPORT_ORDER: &[&str] = &[
177    "tasks",
178    "dependencies",
179    "attachments",
180    "task_tags",
181    "task_needed_tags",
182    "task_wanted_tags",
183    "task_sequence",
184];
185
186impl Database {
187    /// Import data from a snapshot into the database.
188    ///
189    /// This function:
190    /// 1. Validates schema version compatibility
191    /// 2. Based on mode:
192    ///    - Fresh: Validates the database is empty
193    ///    - Replace: Clears existing project data (preserves runtime tables)
194    ///    - Merge: Keeps existing data, adds only new items
195    /// 3. Inserts all rows in the correct order (respecting foreign keys)
196    /// 4. Rebuilds FTS indexes
197    ///
198    /// # Arguments
199    /// * `snapshot` - The snapshot to import
200    /// * `options` - Import options
201    ///
202    /// # Returns
203    /// * `Ok(ImportResult)` - Import statistics
204    /// * `Err` - If import fails
205    pub fn import_snapshot(
206        &self,
207        snapshot: &Snapshot,
208        options: &ImportOptions,
209    ) -> Result<ImportResult> {
210        // Validate schema version
211        if snapshot.schema_version != CURRENT_SCHEMA_VERSION {
212            return Err(anyhow!(
213                "Schema version mismatch: snapshot is v{}, database is v{}. Migration required.",
214                snapshot.schema_version,
215                CURRENT_SCHEMA_VERSION
216            ));
217        }
218
219        let mut result = ImportResult::new();
220
221        // Handle mode-specific pre-import actions
222        match options.mode {
223            ImportMode::Fresh => {
224                // Validate database is empty
225                self.validate_empty_database()?;
226            }
227            ImportMode::Replace => {
228                // Clear existing project data
229                result.rows_deleted = self.clear_project_data()?;
230            }
231            ImportMode::Merge => {
232                // No pre-import action needed for merge mode
233                // Existing data is kept, new data is added selectively
234            }
235        }
236
237        // Import tables in order
238        self.with_conn_mut(|conn| {
239            // Disable foreign key checks during import for performance
240            // (we're importing in the correct order anyway)
241            conn.execute("PRAGMA foreign_keys = OFF", [])?;
242
243            // Use a transaction for atomicity
244            let tx = conn.transaction()?;
245
246            for table_name in IMPORT_ORDER {
247                if let Some(rows) = snapshot.tables.get(*table_name) {
248                    let (imported, skipped) = if options.mode == ImportMode::Merge {
249                        merge_table(&tx, table_name, rows)?
250                    } else {
251                        let count = import_table(&tx, table_name, rows)?;
252                        (count, 0)
253                    };
254                    result
255                        .rows_imported
256                        .insert(table_name.to_string(), imported);
257                    if skipped > 0 {
258                        result.rows_skipped.insert(table_name.to_string(), skipped);
259                    }
260                }
261            }
262
263            tx.commit()?;
264
265            // Re-enable foreign keys
266            conn.execute("PRAGMA foreign_keys = ON", [])?;
267
268            Ok(())
269        })?;
270
271        // Rebuild FTS indexes
272        self.rebuild_fts_indexes()?;
273        result.fts_rebuilt = true;
274
275        Ok(result)
276    }
277
278    /// Preview what an import would do without making any changes.
279    ///
280    /// This is the dry-run mode: it analyzes the snapshot against the current
281    /// database state and reports what would be inserted, deleted, or skipped.
282    ///
283    /// # Arguments
284    /// * `snapshot` - The snapshot to preview importing
285    /// * `options` - Import options (determines mode)
286    ///
287    /// # Returns
288    /// * `DryRunResult` - Preview of what would happen
289    pub fn preview_import(&self, snapshot: &Snapshot, options: &ImportOptions) -> DryRunResult {
290        let mut result = DryRunResult::new(options.mode);
291
292        // Check schema compatibility
293        if snapshot.schema_version != CURRENT_SCHEMA_VERSION {
294            result.would_succeed = false;
295            result.failure_reason = Some(format!(
296                "Schema version mismatch: snapshot is v{}, database is v{}. Migration required.",
297                snapshot.schema_version, CURRENT_SCHEMA_VERSION
298            ));
299            return result;
300        }
301
302        // Get current row counts for all tables
303        let existing = self.get_table_row_counts();
304        if let Err(e) = existing {
305            result.would_succeed = false;
306            result.failure_reason = Some(format!("Failed to query database: {}", e));
307            return result;
308        }
309        let existing = existing.unwrap();
310        result.existing_rows = existing.clone();
311        result.database_is_empty = existing.values().all(|&count| count == 0);
312
313        // Check mode-specific conditions
314        match options.mode {
315            ImportMode::Fresh => {
316                if !result.database_is_empty {
317                    result.would_succeed = false;
318                    let non_empty: Vec<_> = existing
319                        .iter()
320                        .filter(|&(_, count)| *count > 0)
321                        .map(|(table, count)| format!("{}: {} rows", table, count))
322                        .collect();
323                    result.failure_reason = Some(format!(
324                        "Database is not empty. Use --force to overwrite or --merge to add. Non-empty tables: {}",
325                        non_empty.join(", ")
326                    ));
327                    return result;
328                }
329                // In fresh mode, all rows from snapshot would be inserted
330                for table_name in IMPORT_ORDER {
331                    let count = snapshot.tables.get(*table_name).map_or(0, |v| v.len());
332                    result.would_insert.insert(table_name.to_string(), count);
333                }
334            }
335            ImportMode::Replace => {
336                // All existing rows would be deleted
337                for (table, count) in &existing {
338                    if *count > 0 {
339                        result.would_delete.insert(table.clone(), *count);
340                    }
341                }
342                // All rows from snapshot would be inserted
343                for table_name in IMPORT_ORDER {
344                    let count = snapshot.tables.get(*table_name).map_or(0, |v| v.len());
345                    result.would_insert.insert(table_name.to_string(), count);
346                }
347            }
348            ImportMode::Merge => {
349                // Need to analyze each table to see what would be inserted vs skipped
350                if let Err(e) = self.preview_merge(snapshot, &mut result) {
351                    result.would_succeed = false;
352                    result.failure_reason = Some(format!("Failed to analyze merge: {}", e));
353                    return result;
354                }
355            }
356        }
357
358        result
359    }
360
361    /// Preview what a merge import would do.
362    fn preview_merge(&self, snapshot: &Snapshot, result: &mut DryRunResult) -> Result<()> {
363        self.with_conn(|conn| {
364            for table_name in IMPORT_ORDER {
365                if let Some(rows) = snapshot.tables.get(*table_name) {
366                    let (would_insert, would_skip) = preview_merge_table(conn, table_name, rows)?;
367                    result
368                        .would_insert
369                        .insert(table_name.to_string(), would_insert);
370                    if would_skip > 0 {
371                        result.would_skip.insert(table_name.to_string(), would_skip);
372                    }
373                } else {
374                    result.would_insert.insert(table_name.to_string(), 0);
375                }
376            }
377            Ok(())
378        })
379    }
380
381    /// Get the row count for each project data table.
382    fn get_table_row_counts(&self) -> Result<std::collections::BTreeMap<String, usize>> {
383        self.with_conn(|conn| {
384            let mut counts = std::collections::BTreeMap::new();
385            for table in IMPORT_ORDER {
386                let count: i64 =
387                    conn.query_row(&format!("SELECT COUNT(*) FROM {}", table), [], |row| {
388                        row.get(0)
389                    })?;
390                counts.insert(table.to_string(), count as usize);
391            }
392            Ok(counts)
393        })
394    }
395
396    /// Validate that the database is empty (no project data).
397    fn validate_empty_database(&self) -> Result<()> {
398        self.with_conn(|conn| {
399            for table in IMPORT_ORDER {
400                let count: i64 = conn.query_row(
401                    &format!("SELECT COUNT(*) FROM {}", table),
402                    [],
403                    |row| row.get(0),
404                )?;
405                if count > 0 {
406                    return Err(anyhow!(
407                        "Database is not empty: table '{}' contains {} rows. Use --force to overwrite.",
408                        table,
409                        count
410                    ));
411                }
412            }
413            Ok(())
414        })
415    }
416
417    /// Clear all project data tables, preserving runtime tables.
418    ///
419    /// Tables are deleted in reverse order to respect foreign key constraints
420    /// (children deleted before parents).
421    ///
422    /// Runtime tables preserved:
423    /// - workers: Session-based worker registrations
424    /// - file_locks: Active file marks (advisory locks)
425    /// - claim_sequence: File lock audit log
426    ///
427    /// # Returns
428    /// A map of table names to number of rows deleted.
429    pub fn clear_project_data(&self) -> Result<std::collections::BTreeMap<String, usize>> {
430        let mut deleted = std::collections::BTreeMap::new();
431
432        self.with_conn_mut(|conn| {
433            // Disable foreign key checks during deletion for performance
434            conn.execute("PRAGMA foreign_keys = OFF", [])?;
435
436            // Use a transaction for atomicity
437            let tx = conn.transaction()?;
438
439            // Delete in reverse order to respect foreign key constraints
440            // (children first, then parents)
441            for table_name in IMPORT_ORDER.iter().rev() {
442                let count: i64 =
443                    tx.query_row(&format!("SELECT COUNT(*) FROM {}", table_name), [], |row| {
444                        row.get(0)
445                    })?;
446
447                if count > 0 {
448                    tx.execute(&format!("DELETE FROM {}", table_name), [])?;
449                    deleted.insert(table_name.to_string(), count as usize);
450                }
451            }
452
453            // Also clear FTS tables (they'll be rebuilt after import)
454            tx.execute("DELETE FROM tasks_fts", [])?;
455            tx.execute("DELETE FROM attachments_fts", [])?;
456
457            // Reset auto-increment counter for task_sequence
458            // This ensures imported IDs don't conflict with auto-generated ones
459            tx.execute(
460                "DELETE FROM sqlite_sequence WHERE name = 'task_sequence'",
461                [],
462            )?;
463
464            tx.commit()?;
465
466            // Re-enable foreign keys
467            conn.execute("PRAGMA foreign_keys = ON", [])?;
468
469            Ok(())
470        })?;
471
472        Ok(deleted)
473    }
474
475    /// Rebuild FTS indexes from the base tables.
476    ///
477    /// This is called after import to populate the FTS virtual tables
478    /// since triggers don't fire during bulk import.
479    pub fn rebuild_fts_indexes(&self) -> Result<()> {
480        self.with_conn(|conn| {
481            // Rebuild tasks_fts
482            conn.execute("DELETE FROM tasks_fts", [])?;
483            conn.execute(
484                "INSERT INTO tasks_fts(task_id, title, description)
485                 SELECT id, title, COALESCE(description, '')
486                 FROM tasks",
487                [],
488            )?;
489
490            // Rebuild attachments_fts (only text content)
491            conn.execute("DELETE FROM attachments_fts", [])?;
492            conn.execute(
493                "INSERT INTO attachments_fts(task_id, attachment_type, sequence, name, content)
494                 SELECT task_id, attachment_type, sequence, name, content
495                 FROM attachments
496                 WHERE mime_type LIKE 'text/%'",
497                [],
498            )?;
499
500            Ok(())
501        })
502    }
503}
504
505/// Import rows into a specific table.
506fn import_table(conn: &rusqlite::Connection, table_name: &str, rows: &[Value]) -> Result<usize> {
507    if rows.is_empty() {
508        return Ok(0);
509    }
510
511    match table_name {
512        "tasks" => import_tasks(conn, rows),
513        "dependencies" => import_dependencies(conn, rows),
514        "attachments" => import_attachments(conn, rows),
515        "task_tags" => import_task_tags(conn, rows),
516        "task_needed_tags" => import_task_needed_tags(conn, rows),
517        "task_wanted_tags" => import_task_wanted_tags(conn, rows),
518        "task_sequence" => import_task_sequence(conn, rows),
519        _ => Err(anyhow!("Unknown table: {}", table_name)),
520    }
521}
522
523/// Merge rows into a specific table (skip existing, insert new).
524/// Returns (imported_count, skipped_count).
525fn merge_table(
526    conn: &rusqlite::Connection,
527    table_name: &str,
528    rows: &[Value],
529) -> Result<(usize, usize)> {
530    if rows.is_empty() {
531        return Ok((0, 0));
532    }
533
534    match table_name {
535        "tasks" => merge_tasks(conn, rows),
536        "dependencies" => merge_dependencies(conn, rows),
537        "attachments" => merge_attachments(conn, rows),
538        "task_tags" => merge_task_tags(conn, rows),
539        "task_needed_tags" => merge_task_needed_tags(conn, rows),
540        "task_wanted_tags" => merge_task_wanted_tags(conn, rows),
541        "task_sequence" => merge_task_sequence(conn, rows),
542        _ => Err(anyhow!("Unknown table: {}", table_name)),
543    }
544}
545
546/// Preview what a merge would do for a specific table (no modifications).
547/// Returns (would_insert_count, would_skip_count).
548fn preview_merge_table(
549    conn: &rusqlite::Connection,
550    table_name: &str,
551    rows: &[Value],
552) -> Result<(usize, usize)> {
553    if rows.is_empty() {
554        return Ok((0, 0));
555    }
556
557    match table_name {
558        "tasks" => preview_merge_tasks(conn, rows),
559        "dependencies" => preview_merge_dependencies(conn, rows),
560        "attachments" => preview_merge_attachments(conn, rows),
561        "task_tags" => preview_merge_task_tags(conn, rows),
562        "task_needed_tags" => preview_merge_task_needed_tags(conn, rows),
563        "task_wanted_tags" => preview_merge_task_wanted_tags(conn, rows),
564        "task_sequence" => Ok((0, rows.len())), // Always skip in merge mode
565        _ => Err(anyhow!("Unknown table: {}", table_name)),
566    }
567}
568
569/// Preview merge for tasks - count how many would be inserted vs skipped.
570fn preview_merge_tasks(conn: &rusqlite::Connection, rows: &[Value]) -> Result<(usize, usize)> {
571    let mut would_insert = 0;
572    let mut would_skip = 0;
573
574    for row in rows {
575        let obj = row.as_object().context("Task row must be an object")?;
576        let task_id = get_string(obj, "id")?;
577
578        let exists: bool = conn
579            .query_row(
580                "SELECT 1 FROM tasks WHERE id = ?1",
581                params![&task_id],
582                |_| Ok(true),
583            )
584            .unwrap_or(false);
585
586        if exists {
587            would_skip += 1;
588        } else {
589            would_insert += 1;
590        }
591    }
592
593    Ok((would_insert, would_skip))
594}
595
596/// Preview merge for dependencies - count how many would be inserted vs skipped.
597fn preview_merge_dependencies(
598    conn: &rusqlite::Connection,
599    rows: &[Value],
600) -> Result<(usize, usize)> {
601    let mut would_insert = 0;
602    let mut would_skip = 0;
603
604    for row in rows {
605        let obj = row
606            .as_object()
607            .context("Dependency row must be an object")?;
608        let from_id = get_string(obj, "from_task_id")?;
609        let to_id = get_string(obj, "to_task_id")?;
610        let dep_type = get_string(obj, "dep_type")?;
611
612        let exists: bool = conn
613            .query_row(
614                "SELECT 1 FROM dependencies WHERE from_task_id = ?1 AND to_task_id = ?2 AND dep_type = ?3",
615                params![&from_id, &to_id, &dep_type],
616                |_| Ok(true),
617            )
618            .unwrap_or(false);
619
620        if exists {
621            would_skip += 1;
622        } else {
623            would_insert += 1;
624        }
625    }
626
627    Ok((would_insert, would_skip))
628}
629
630/// Preview merge for attachments - count how many would be inserted vs skipped.
631fn preview_merge_attachments(
632    conn: &rusqlite::Connection,
633    rows: &[Value],
634) -> Result<(usize, usize)> {
635    let mut would_insert = 0;
636    let mut would_skip = 0;
637
638    for row in rows {
639        let obj = row
640            .as_object()
641            .context("Attachment row must be an object")?;
642        let task_id = get_string(obj, "task_id")?;
643        let attachment_type = get_string(obj, "attachment_type")?;
644        let sequence = get_i32(obj, "sequence")?;
645
646        let exists: bool = conn
647            .query_row(
648                "SELECT 1 FROM attachments WHERE task_id = ?1 AND attachment_type = ?2 AND sequence = ?3",
649                params![&task_id, &attachment_type, sequence],
650                |_| Ok(true),
651            )
652            .unwrap_or(false);
653
654        if exists {
655            would_skip += 1;
656        } else {
657            would_insert += 1;
658        }
659    }
660
661    Ok((would_insert, would_skip))
662}
663
664/// Preview merge for task_tags - count how many would be inserted vs skipped.
665fn preview_merge_task_tags(conn: &rusqlite::Connection, rows: &[Value]) -> Result<(usize, usize)> {
666    let mut would_insert = 0;
667    let mut would_skip = 0;
668
669    for row in rows {
670        let obj = row.as_object().context("TaskTag row must be an object")?;
671        let task_id = get_string(obj, "task_id")?;
672        let tag = get_string(obj, "tag")?;
673
674        let exists: bool = conn
675            .query_row(
676                "SELECT 1 FROM task_tags WHERE task_id = ?1 AND tag = ?2",
677                params![&task_id, &tag],
678                |_| Ok(true),
679            )
680            .unwrap_or(false);
681
682        if exists {
683            would_skip += 1;
684        } else {
685            would_insert += 1;
686        }
687    }
688
689    Ok((would_insert, would_skip))
690}
691
692/// Preview merge for task_needed_tags - count how many would be inserted vs skipped.
693fn preview_merge_task_needed_tags(
694    conn: &rusqlite::Connection,
695    rows: &[Value],
696) -> Result<(usize, usize)> {
697    let mut would_insert = 0;
698    let mut would_skip = 0;
699
700    for row in rows {
701        let obj = row
702            .as_object()
703            .context("TaskNeededTag row must be an object")?;
704        let task_id = get_string(obj, "task_id")?;
705        let tag = get_string(obj, "tag")?;
706
707        let exists: bool = conn
708            .query_row(
709                "SELECT 1 FROM task_needed_tags WHERE task_id = ?1 AND tag = ?2",
710                params![&task_id, &tag],
711                |_| Ok(true),
712            )
713            .unwrap_or(false);
714
715        if exists {
716            would_skip += 1;
717        } else {
718            would_insert += 1;
719        }
720    }
721
722    Ok((would_insert, would_skip))
723}
724
725/// Preview merge for task_wanted_tags - count how many would be inserted vs skipped.
726fn preview_merge_task_wanted_tags(
727    conn: &rusqlite::Connection,
728    rows: &[Value],
729) -> Result<(usize, usize)> {
730    let mut would_insert = 0;
731    let mut would_skip = 0;
732
733    for row in rows {
734        let obj = row
735            .as_object()
736            .context("TaskWantedTag row must be an object")?;
737        let task_id = get_string(obj, "task_id")?;
738        let tag = get_string(obj, "tag")?;
739
740        let exists: bool = conn
741            .query_row(
742                "SELECT 1 FROM task_wanted_tags WHERE task_id = ?1 AND tag = ?2",
743                params![&task_id, &tag],
744                |_| Ok(true),
745            )
746            .unwrap_or(false);
747
748        if exists {
749            would_skip += 1;
750        } else {
751            would_insert += 1;
752        }
753    }
754
755    Ok((would_insert, would_skip))
756}
757
758/// Merge tasks - skip if ID exists, insert if new.
759fn merge_tasks(conn: &rusqlite::Connection, rows: &[Value]) -> Result<(usize, usize)> {
760    let mut insert_stmt = conn.prepare(
761        "INSERT INTO tasks (
762            id, title, description, status, priority, worker_id, claimed_at,
763            needed_tags, wanted_tags, tags,
764            points, time_estimate_ms, time_actual_ms, started_at, completed_at,
765            current_thought,
766            metric_0, metric_1, metric_2, metric_3, metric_4, metric_5, metric_6, metric_7,
767            cost_usd,
768            deleted_at, deleted_by, deleted_reason,
769            created_at, updated_at
770        ) VALUES (
771            ?1, ?2, ?3, ?4, ?5, ?6, ?7,
772            ?8, ?9, ?10,
773            ?11, ?12, ?13, ?14, ?15,
774            ?16,
775            ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24,
776            ?25,
777            ?26, ?27, ?28,
778            ?29, ?30
779        )",
780    )?;
781
782    let mut imported = 0;
783    let mut skipped = 0;
784
785    for row in rows {
786        let obj = row.as_object().context("Task row must be an object")?;
787        let task_id = get_string(obj, "id")?;
788
789        // Check if task already exists
790        let exists: bool = conn
791            .query_row(
792                "SELECT 1 FROM tasks WHERE id = ?1",
793                params![&task_id],
794                |_| Ok(true),
795            )
796            .unwrap_or(false);
797
798        if exists {
799            skipped += 1;
800            continue;
801        }
802
803        insert_stmt.execute(params![
804            task_id,
805            get_string(obj, "title")?,
806            get_opt_string(obj, "description"),
807            get_string(obj, "status")?,
808            get_string(obj, "priority")?,
809            get_opt_string(obj, "worker_id"),
810            get_opt_i64(obj, "claimed_at"),
811            get_opt_string(obj, "needed_tags"),
812            get_opt_string(obj, "wanted_tags"),
813            get_opt_string(obj, "tags"),
814            get_opt_i32(obj, "points"),
815            get_opt_i64(obj, "time_estimate_ms"),
816            get_opt_i64(obj, "time_actual_ms"),
817            get_opt_i64(obj, "started_at"),
818            get_opt_i64(obj, "completed_at"),
819            get_opt_string(obj, "current_thought"),
820            get_i64_or_default(obj, "metric_0"),
821            get_i64_or_default(obj, "metric_1"),
822            get_i64_or_default(obj, "metric_2"),
823            get_i64_or_default(obj, "metric_3"),
824            get_i64_or_default(obj, "metric_4"),
825            get_i64_or_default(obj, "metric_5"),
826            get_i64_or_default(obj, "metric_6"),
827            get_i64_or_default(obj, "metric_7"),
828            get_f64_or_default(obj, "cost_usd"),
829            get_opt_i64(obj, "deleted_at"),
830            get_opt_string(obj, "deleted_by"),
831            get_opt_string(obj, "deleted_reason"),
832            get_i64(obj, "created_at")?,
833            get_i64(obj, "updated_at")?,
834        ])?;
835        imported += 1;
836    }
837
838    Ok((imported, skipped))
839}
840
841/// Merge dependencies - skip if exact match exists.
842fn merge_dependencies(conn: &rusqlite::Connection, rows: &[Value]) -> Result<(usize, usize)> {
843    let mut insert_stmt = conn.prepare(
844        "INSERT INTO dependencies (from_task_id, to_task_id, dep_type)
845         VALUES (?1, ?2, ?3)",
846    )?;
847
848    let mut imported = 0;
849    let mut skipped = 0;
850
851    for row in rows {
852        let obj = row
853            .as_object()
854            .context("Dependency row must be an object")?;
855        let from_id = get_string(obj, "from_task_id")?;
856        let to_id = get_string(obj, "to_task_id")?;
857        let dep_type = get_string(obj, "dep_type")?;
858
859        // Check if exact dependency already exists
860        let exists: bool = conn
861            .query_row(
862                "SELECT 1 FROM dependencies WHERE from_task_id = ?1 AND to_task_id = ?2 AND dep_type = ?3",
863                params![&from_id, &to_id, &dep_type],
864                |_| Ok(true),
865            )
866            .unwrap_or(false);
867
868        if exists {
869            skipped += 1;
870            continue;
871        }
872
873        insert_stmt.execute(params![from_id, to_id, dep_type])?;
874        imported += 1;
875    }
876
877    Ok((imported, skipped))
878}
879
880/// Merge attachments - skip if exact match (task_id + attachment_type + sequence) exists.
881fn merge_attachments(conn: &rusqlite::Connection, rows: &[Value]) -> Result<(usize, usize)> {
882    let mut insert_stmt = conn.prepare(
883        "INSERT INTO attachments (task_id, attachment_type, sequence, name, mime_type, content, file_path, created_at)
884         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
885    )?;
886
887    let mut imported = 0;
888    let mut skipped = 0;
889
890    for row in rows {
891        let obj = row
892            .as_object()
893            .context("Attachment row must be an object")?;
894        let task_id = get_string(obj, "task_id")?;
895        let attachment_type = get_string(obj, "attachment_type")?;
896        let sequence = get_i32(obj, "sequence")?;
897
898        // Check if attachment already exists (by task_id + attachment_type + sequence)
899        let exists: bool = conn
900            .query_row(
901                "SELECT 1 FROM attachments WHERE task_id = ?1 AND attachment_type = ?2 AND sequence = ?3",
902                params![&task_id, &attachment_type, sequence],
903                |_| Ok(true),
904            )
905            .unwrap_or(false);
906
907        if exists {
908            skipped += 1;
909            continue;
910        }
911
912        insert_stmt.execute(params![
913            task_id,
914            attachment_type,
915            sequence,
916            get_string(obj, "name")?,
917            get_string(obj, "mime_type")?,
918            get_string(obj, "content")?,
919            get_opt_string(obj, "file_path"),
920            get_i64(obj, "created_at")?,
921        ])?;
922        imported += 1;
923    }
924
925    Ok((imported, skipped))
926}
927
928/// Merge task_tags - skip if exact match exists.
929fn merge_task_tags(conn: &rusqlite::Connection, rows: &[Value]) -> Result<(usize, usize)> {
930    let mut insert_stmt = conn.prepare("INSERT INTO task_tags (task_id, tag) VALUES (?1, ?2)")?;
931
932    let mut imported = 0;
933    let mut skipped = 0;
934
935    for row in rows {
936        let obj = row.as_object().context("TaskTag row must be an object")?;
937        let task_id = get_string(obj, "task_id")?;
938        let tag = get_string(obj, "tag")?;
939
940        // Check if tag already exists
941        let exists: bool = conn
942            .query_row(
943                "SELECT 1 FROM task_tags WHERE task_id = ?1 AND tag = ?2",
944                params![&task_id, &tag],
945                |_| Ok(true),
946            )
947            .unwrap_or(false);
948
949        if exists {
950            skipped += 1;
951            continue;
952        }
953
954        insert_stmt.execute(params![task_id, tag])?;
955        imported += 1;
956    }
957
958    Ok((imported, skipped))
959}
960
961/// Merge task_needed_tags - skip if exact match exists.
962fn merge_task_needed_tags(conn: &rusqlite::Connection, rows: &[Value]) -> Result<(usize, usize)> {
963    let mut insert_stmt =
964        conn.prepare("INSERT INTO task_needed_tags (task_id, tag) VALUES (?1, ?2)")?;
965
966    let mut imported = 0;
967    let mut skipped = 0;
968
969    for row in rows {
970        let obj = row
971            .as_object()
972            .context("TaskNeededTag row must be an object")?;
973        let task_id = get_string(obj, "task_id")?;
974        let tag = get_string(obj, "tag")?;
975
976        // Check if tag already exists
977        let exists: bool = conn
978            .query_row(
979                "SELECT 1 FROM task_needed_tags WHERE task_id = ?1 AND tag = ?2",
980                params![&task_id, &tag],
981                |_| Ok(true),
982            )
983            .unwrap_or(false);
984
985        if exists {
986            skipped += 1;
987            continue;
988        }
989
990        insert_stmt.execute(params![task_id, tag])?;
991        imported += 1;
992    }
993
994    Ok((imported, skipped))
995}
996
997/// Merge task_wanted_tags - skip if exact match exists.
998fn merge_task_wanted_tags(conn: &rusqlite::Connection, rows: &[Value]) -> Result<(usize, usize)> {
999    let mut insert_stmt =
1000        conn.prepare("INSERT INTO task_wanted_tags (task_id, tag) VALUES (?1, ?2)")?;
1001
1002    let mut imported = 0;
1003    let mut skipped = 0;
1004
1005    for row in rows {
1006        let obj = row
1007            .as_object()
1008            .context("TaskWantedTag row must be an object")?;
1009        let task_id = get_string(obj, "task_id")?;
1010        let tag = get_string(obj, "tag")?;
1011
1012        // Check if tag already exists
1013        let exists: bool = conn
1014            .query_row(
1015                "SELECT 1 FROM task_wanted_tags WHERE task_id = ?1 AND tag = ?2",
1016                params![&task_id, &tag],
1017                |_| Ok(true),
1018            )
1019            .unwrap_or(false);
1020
1021        if exists {
1022            skipped += 1;
1023            continue;
1024        }
1025
1026        insert_stmt.execute(params![task_id, tag])?;
1027        imported += 1;
1028    }
1029
1030    Ok((imported, skipped))
1031}
1032
1033/// Merge task_sequence - skip all in merge mode to preserve existing history.
1034/// State history from the snapshot is not imported to avoid conflicts with existing history.
1035fn merge_task_sequence(conn: &rusqlite::Connection, rows: &[Value]) -> Result<(usize, usize)> {
1036    // In merge mode, we skip all state sequence imports to preserve existing history.
1037    // The rationale is that state history reflects what actually happened in this database,
1038    // and importing history from another database could create inconsistencies.
1039    let _ = conn; // silence unused variable warning
1040    Ok((0, rows.len()))
1041}
1042
1043/// Import tasks table.
1044fn import_tasks(conn: &rusqlite::Connection, rows: &[Value]) -> Result<usize> {
1045    let mut stmt = conn.prepare(
1046        "INSERT INTO tasks (
1047            id, title, description, status, priority, worker_id, claimed_at,
1048            needed_tags, wanted_tags, tags,
1049            points, time_estimate_ms, time_actual_ms, started_at, completed_at,
1050            current_thought,
1051            metric_0, metric_1, metric_2, metric_3, metric_4, metric_5, metric_6, metric_7,
1052            cost_usd,
1053            deleted_at, deleted_by, deleted_reason,
1054            created_at, updated_at
1055        ) VALUES (
1056            ?1, ?2, ?3, ?4, ?5, ?6, ?7,
1057            ?8, ?9, ?10,
1058            ?11, ?12, ?13, ?14, ?15,
1059            ?16,
1060            ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24,
1061            ?25,
1062            ?26, ?27, ?28,
1063            ?29, ?30
1064        )",
1065    )?;
1066
1067    let mut count = 0;
1068    for row in rows {
1069        let obj = row.as_object().context("Task row must be an object")?;
1070
1071        stmt.execute(params![
1072            get_string(obj, "id")?,
1073            get_string(obj, "title")?,
1074            get_opt_string(obj, "description"),
1075            get_string(obj, "status")?,
1076            get_string(obj, "priority")?,
1077            get_opt_string(obj, "worker_id"),
1078            get_opt_i64(obj, "claimed_at"),
1079            get_opt_string(obj, "needed_tags"),
1080            get_opt_string(obj, "wanted_tags"),
1081            get_opt_string(obj, "tags"),
1082            get_opt_i32(obj, "points"),
1083            get_opt_i64(obj, "time_estimate_ms"),
1084            get_opt_i64(obj, "time_actual_ms"),
1085            get_opt_i64(obj, "started_at"),
1086            get_opt_i64(obj, "completed_at"),
1087            get_opt_string(obj, "current_thought"),
1088            get_i64_or_default(obj, "metric_0"),
1089            get_i64_or_default(obj, "metric_1"),
1090            get_i64_or_default(obj, "metric_2"),
1091            get_i64_or_default(obj, "metric_3"),
1092            get_i64_or_default(obj, "metric_4"),
1093            get_i64_or_default(obj, "metric_5"),
1094            get_i64_or_default(obj, "metric_6"),
1095            get_i64_or_default(obj, "metric_7"),
1096            get_f64_or_default(obj, "cost_usd"),
1097            get_opt_i64(obj, "deleted_at"),
1098            get_opt_string(obj, "deleted_by"),
1099            get_opt_string(obj, "deleted_reason"),
1100            get_i64(obj, "created_at")?,
1101            get_i64(obj, "updated_at")?,
1102        ])?;
1103        count += 1;
1104    }
1105
1106    Ok(count)
1107}
1108
1109/// Import dependencies table.
1110fn import_dependencies(conn: &rusqlite::Connection, rows: &[Value]) -> Result<usize> {
1111    let mut stmt = conn.prepare(
1112        "INSERT INTO dependencies (from_task_id, to_task_id, dep_type)
1113         VALUES (?1, ?2, ?3)",
1114    )?;
1115
1116    let mut count = 0;
1117    for row in rows {
1118        let obj = row
1119            .as_object()
1120            .context("Dependency row must be an object")?;
1121
1122        stmt.execute(params![
1123            get_string(obj, "from_task_id")?,
1124            get_string(obj, "to_task_id")?,
1125            get_string(obj, "dep_type")?,
1126        ])?;
1127        count += 1;
1128    }
1129
1130    Ok(count)
1131}
1132
1133/// Import attachments table.
1134fn import_attachments(conn: &rusqlite::Connection, rows: &[Value]) -> Result<usize> {
1135    let mut stmt = conn.prepare(
1136        "INSERT INTO attachments (task_id, attachment_type, sequence, name, mime_type, content, file_path, created_at)
1137         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
1138    )?;
1139
1140    let mut count = 0;
1141    for row in rows {
1142        let obj = row
1143            .as_object()
1144            .context("Attachment row must be an object")?;
1145
1146        stmt.execute(params![
1147            get_string(obj, "task_id")?,
1148            get_string(obj, "attachment_type")?,
1149            get_i32(obj, "sequence")?,
1150            get_string(obj, "name")?,
1151            get_string(obj, "mime_type")?,
1152            get_string(obj, "content")?,
1153            get_opt_string(obj, "file_path"),
1154            get_i64(obj, "created_at")?,
1155        ])?;
1156        count += 1;
1157    }
1158
1159    Ok(count)
1160}
1161
1162/// Import task_tags table.
1163fn import_task_tags(conn: &rusqlite::Connection, rows: &[Value]) -> Result<usize> {
1164    let mut stmt = conn.prepare("INSERT INTO task_tags (task_id, tag) VALUES (?1, ?2)")?;
1165
1166    let mut count = 0;
1167    for row in rows {
1168        let obj = row.as_object().context("TaskTag row must be an object")?;
1169
1170        stmt.execute(params![
1171            get_string(obj, "task_id")?,
1172            get_string(obj, "tag")?,
1173        ])?;
1174        count += 1;
1175    }
1176
1177    Ok(count)
1178}
1179
1180/// Import task_needed_tags table.
1181fn import_task_needed_tags(conn: &rusqlite::Connection, rows: &[Value]) -> Result<usize> {
1182    let mut stmt = conn.prepare("INSERT INTO task_needed_tags (task_id, tag) VALUES (?1, ?2)")?;
1183
1184    let mut count = 0;
1185    for row in rows {
1186        let obj = row
1187            .as_object()
1188            .context("TaskNeededTag row must be an object")?;
1189
1190        stmt.execute(params![
1191            get_string(obj, "task_id")?,
1192            get_string(obj, "tag")?,
1193        ])?;
1194        count += 1;
1195    }
1196
1197    Ok(count)
1198}
1199
1200/// Import task_wanted_tags table.
1201fn import_task_wanted_tags(conn: &rusqlite::Connection, rows: &[Value]) -> Result<usize> {
1202    let mut stmt = conn.prepare("INSERT INTO task_wanted_tags (task_id, tag) VALUES (?1, ?2)")?;
1203
1204    let mut count = 0;
1205    for row in rows {
1206        let obj = row
1207            .as_object()
1208            .context("TaskWantedTag row must be an object")?;
1209
1210        stmt.execute(params![
1211            get_string(obj, "task_id")?,
1212            get_string(obj, "tag")?,
1213        ])?;
1214        count += 1;
1215    }
1216
1217    Ok(count)
1218}
1219
1220/// Import task_sequence table.
1221fn import_task_sequence(conn: &rusqlite::Connection, rows: &[Value]) -> Result<usize> {
1222    let mut stmt = conn.prepare(
1223        "INSERT INTO task_sequence (id, task_id, worker_id, status, phase, reason, timestamp, end_timestamp)
1224         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
1225    )?;
1226
1227    let mut count = 0;
1228    for row in rows {
1229        let obj = row
1230            .as_object()
1231            .context("TaskSequenceEvent row must be an object")?;
1232
1233        stmt.execute(params![
1234            get_i64(obj, "id")?,
1235            get_string(obj, "task_id")?,
1236            get_opt_string(obj, "worker_id"),
1237            get_opt_string(obj, "status"),
1238            get_opt_string(obj, "phase"),
1239            get_opt_string(obj, "reason"),
1240            get_i64(obj, "timestamp")?,
1241            get_opt_i64(obj, "end_timestamp"),
1242        ])?;
1243        count += 1;
1244    }
1245
1246    Ok(count)
1247}
1248
1249// ============================================================================
1250// JSON value extraction helpers
1251// ============================================================================
1252
1253/// Get a required string value from a JSON object.
1254fn get_string(obj: &serde_json::Map<String, Value>, key: &str) -> Result<String> {
1255    obj.get(key)
1256        .and_then(|v| v.as_str())
1257        .map(|s| s.to_string())
1258        .ok_or_else(|| anyhow!("Missing or invalid string field: {}", key))
1259}
1260
1261/// Get an optional string value from a JSON object.
1262fn get_opt_string(obj: &serde_json::Map<String, Value>, key: &str) -> Option<String> {
1263    obj.get(key).and_then(|v| {
1264        if v.is_null() {
1265            None
1266        } else {
1267            v.as_str().map(|s| s.to_string())
1268        }
1269    })
1270}
1271
1272/// Get a required i64 value from a JSON object.
1273fn get_i64(obj: &serde_json::Map<String, Value>, key: &str) -> Result<i64> {
1274    obj.get(key)
1275        .and_then(|v| v.as_i64())
1276        .ok_or_else(|| anyhow!("Missing or invalid i64 field: {}", key))
1277}
1278
1279/// Get an optional i64 value from a JSON object.
1280fn get_opt_i64(obj: &serde_json::Map<String, Value>, key: &str) -> Option<i64> {
1281    obj.get(key)
1282        .and_then(|v| if v.is_null() { None } else { v.as_i64() })
1283}
1284
1285/// Get an i64 value with a default of 0.
1286fn get_i64_or_default(obj: &serde_json::Map<String, Value>, key: &str) -> i64 {
1287    get_opt_i64(obj, key).unwrap_or(0)
1288}
1289
1290/// Get a required i32 value from a JSON object.
1291fn get_i32(obj: &serde_json::Map<String, Value>, key: &str) -> Result<i32> {
1292    obj.get(key)
1293        .and_then(|v| v.as_i64())
1294        .map(|i| i as i32)
1295        .ok_or_else(|| anyhow!("Missing or invalid i32 field: {}", key))
1296}
1297
1298/// Get an optional i32 value from a JSON object.
1299#[allow(dead_code)]
1300fn get_opt_i32(obj: &serde_json::Map<String, Value>, key: &str) -> Option<i32> {
1301    obj.get(key).and_then(|v| {
1302        if v.is_null() {
1303            None
1304        } else {
1305            v.as_i64().map(|i| i as i32)
1306        }
1307    })
1308}
1309
1310/// Get an f64 value with a default of 0.0.
1311fn get_f64_or_default(obj: &serde_json::Map<String, Value>, key: &str) -> f64 {
1312    obj.get(key)
1313        .and_then(|v| if v.is_null() { None } else { v.as_f64() })
1314        .unwrap_or(0.0)
1315}
1316
1317#[cfg(test)]
1318mod tests {
1319    use super::*;
1320    use crate::config::IdsConfig;
1321    use crate::export::Snapshot;
1322    use serde_json::json;
1323
1324    #[test]
1325    fn test_import_empty_snapshot() {
1326        let db = Database::open_in_memory().unwrap();
1327        let snapshot = Snapshot::new();
1328        let options = ImportOptions::default();
1329
1330        let result = db.import_snapshot(&snapshot, &options).unwrap();
1331        assert_eq!(result.total_rows(), 0);
1332        assert!(result.fts_rebuilt);
1333    }
1334
1335    #[test]
1336    fn test_import_tasks() {
1337        let db = Database::open_in_memory().unwrap();
1338        let mut snapshot = Snapshot::new();
1339
1340        snapshot.tables.insert(
1341            "tasks".to_string(),
1342            vec![json!({
1343                "id": "task-1",
1344                "title": "Test Task",
1345                "description": "A test task",
1346                "status": "pending",
1347                "priority": "5",
1348                "worker_id": null,
1349                "claimed_at": null,
1350                "needed_tags": null,
1351                "wanted_tags": null,
1352                "tags": "[]",
1353                "points": null,
1354                "time_estimate_ms": null,
1355                "time_actual_ms": null,
1356                "started_at": null,
1357                "completed_at": null,
1358                "current_thought": null,
1359                "metric_0": 0,
1360                "metric_1": 0,
1361                "metric_2": 0,
1362                "metric_3": 0,
1363                "metric_4": 0,
1364                "metric_5": 0,
1365                "metric_6": 0,
1366                "metric_7": 0,
1367                "cost_usd": 0.0,
1368                "deleted_at": null,
1369                "deleted_by": null,
1370                "deleted_reason": null,
1371                "created_at": 1700000000000_i64,
1372                "updated_at": 1700000000000_i64
1373            })],
1374        );
1375
1376        let options = ImportOptions::default();
1377        let result = db.import_snapshot(&snapshot, &options).unwrap();
1378
1379        assert_eq!(result.rows_imported.get("tasks"), Some(&1));
1380        assert!(result.fts_rebuilt);
1381
1382        // Verify FTS was populated
1383        let results = db.search_tasks("Test", None, 0, false, None).unwrap();
1384        assert_eq!(results.len(), 1);
1385        assert_eq!(results[0].task_id, "task-1");
1386    }
1387
1388    #[test]
1389    fn test_import_with_dependencies() {
1390        let db = Database::open_in_memory().unwrap();
1391        let mut snapshot = Snapshot::new();
1392
1393        // Add tasks
1394        snapshot.tables.insert(
1395            "tasks".to_string(),
1396            vec![
1397                json!({
1398                    "id": "task-a",
1399                    "title": "Task A",
1400                    "description": null,
1401                    "status": "pending",
1402                    "priority": "5",
1403                    "worker_id": null,
1404                    "claimed_at": null,
1405                    "needed_tags": null,
1406                    "wanted_tags": null,
1407                    "tags": "[]",
1408                    "points": null,
1409                    "time_estimate_ms": null,
1410                    "time_actual_ms": null,
1411                    "started_at": null,
1412                    "completed_at": null,
1413                    "current_thought": null,
1414                    "metric_0": 0,
1415                    "metric_1": 0,
1416                    "metric_2": 0,
1417                    "metric_3": 0,
1418                    "metric_4": 0,
1419                    "metric_5": 0,
1420                    "metric_6": 0,
1421                    "metric_7": 0,
1422                    "cost_usd": 0.0,
1423                    "deleted_at": null,
1424                    "deleted_by": null,
1425                    "deleted_reason": null,
1426                    "created_at": 1700000000000_i64,
1427                    "updated_at": 1700000000000_i64
1428                }),
1429                json!({
1430                    "id": "task-b",
1431                    "title": "Task B",
1432                    "description": null,
1433                    "status": "pending",
1434                    "priority": "5",
1435                    "worker_id": null,
1436                    "claimed_at": null,
1437                    "needed_tags": null,
1438                    "wanted_tags": null,
1439                    "tags": "[]",
1440                    "points": null,
1441                    "time_estimate_ms": null,
1442                    "time_actual_ms": null,
1443                    "started_at": null,
1444                    "completed_at": null,
1445                    "current_thought": null,
1446                    "metric_0": 0,
1447                    "metric_1": 0,
1448                    "metric_2": 0,
1449                    "metric_3": 0,
1450                    "metric_4": 0,
1451                    "metric_5": 0,
1452                    "metric_6": 0,
1453                    "metric_7": 0,
1454                    "cost_usd": 0.0,
1455                    "deleted_at": null,
1456                    "deleted_by": null,
1457                    "deleted_reason": null,
1458                    "created_at": 1700000000000_i64,
1459                    "updated_at": 1700000000000_i64
1460                }),
1461            ],
1462        );
1463
1464        // Add dependency
1465        snapshot.tables.insert(
1466            "dependencies".to_string(),
1467            vec![json!({
1468                "from_task_id": "task-a",
1469                "to_task_id": "task-b",
1470                "dep_type": "blocks"
1471            })],
1472        );
1473
1474        let options = ImportOptions::default();
1475        let result = db.import_snapshot(&snapshot, &options).unwrap();
1476
1477        assert_eq!(result.rows_imported.get("tasks"), Some(&2));
1478        assert_eq!(result.rows_imported.get("dependencies"), Some(&1));
1479    }
1480
1481    #[test]
1482    fn test_import_fails_on_non_empty_database() {
1483        let db = Database::open_in_memory().unwrap();
1484
1485        // Create a task first
1486        use crate::config::StatesConfig;
1487        db.create_task(
1488            None,
1489            "Existing task".to_string(),
1490            None,
1491            None,
1492            None,
1493            None,
1494            None,
1495            None,
1496            None,
1497            None, // tags
1498            &StatesConfig::default(),
1499            &IdsConfig::default(),
1500        )
1501        .unwrap();
1502
1503        let snapshot = Snapshot::new();
1504        let options = ImportOptions::fresh(); // Explicitly use fresh mode
1505
1506        let result = db.import_snapshot(&snapshot, &options);
1507        assert!(result.is_err());
1508        assert!(result.unwrap_err().to_string().contains("not empty"));
1509    }
1510
1511    #[test]
1512    fn test_import_replace_mode() {
1513        let db = Database::open_in_memory().unwrap();
1514
1515        // Create existing task
1516        use crate::config::StatesConfig;
1517        let existing_id = db
1518            .create_task(
1519                None,
1520                "Existing task".to_string(),
1521                None,
1522                None,
1523                None,
1524                None,
1525                None,
1526                None,
1527                None,
1528                None, // tags
1529                &StatesConfig::default(),
1530                &IdsConfig::default(),
1531            )
1532            .unwrap();
1533
1534        // Verify task exists
1535        let task = db.get_task(&existing_id.id).unwrap();
1536        assert!(task.is_some());
1537        assert_eq!(task.unwrap().title, "Existing task");
1538
1539        // Create snapshot with different task
1540        let mut snapshot = Snapshot::new();
1541        snapshot.tables.insert(
1542            "tasks".to_string(),
1543            vec![json!({
1544                "id": "imported-task",
1545                "title": "Imported Task",
1546                "description": null,
1547                "status": "pending",
1548                "priority": "5",
1549                "worker_id": null,
1550                "claimed_at": null,
1551                "needed_tags": null,
1552                "wanted_tags": null,
1553                "tags": "[]",
1554                "points": null,
1555                "time_estimate_ms": null,
1556                "time_actual_ms": null,
1557                "started_at": null,
1558                "completed_at": null,
1559                "current_thought": null,
1560                "metric_0": 0,
1561                "metric_1": 0,
1562                "metric_2": 0,
1563                "metric_3": 0,
1564                "metric_4": 0,
1565                "metric_5": 0,
1566                "metric_6": 0,
1567                "metric_7": 0,
1568                "cost_usd": 0.0,
1569                "deleted_at": null,
1570                "deleted_by": null,
1571                "deleted_reason": null,
1572                "created_at": 1700000000000_i64,
1573                "updated_at": 1700000000000_i64
1574            })],
1575        );
1576
1577        // Import in replace mode
1578        let options = ImportOptions::replace();
1579        let result = db.import_snapshot(&snapshot, &options).unwrap();
1580
1581        // Verify old task was deleted and new task imported
1582        assert_eq!(result.rows_deleted.get("tasks"), Some(&1));
1583        assert_eq!(result.rows_imported.get("tasks"), Some(&1));
1584
1585        // Old task should be gone
1586        let old_task = db.get_task(&existing_id.id).unwrap();
1587        assert!(old_task.is_none());
1588
1589        // New task should exist
1590        let new_task = db.get_task("imported-task").unwrap();
1591        assert!(new_task.is_some());
1592        assert_eq!(new_task.unwrap().title, "Imported Task");
1593    }
1594
1595    #[test]
1596    fn test_replace_mode_preserves_workers() {
1597        let db = Database::open_in_memory().unwrap();
1598
1599        // Register a worker
1600        db.register_worker(
1601            Some("test-worker".to_string()),
1602            vec!["rust".to_string(), "test".to_string()],
1603            false,
1604            &IdsConfig::default(),
1605            None,
1606        )
1607        .unwrap();
1608
1609        // Verify worker exists
1610        let workers = db.list_workers().unwrap();
1611        assert_eq!(workers.len(), 1);
1612        assert_eq!(workers[0].id, "test-worker");
1613
1614        // Create a task
1615        use crate::config::StatesConfig;
1616        db.create_task(
1617            None,
1618            "Task to replace".to_string(),
1619            None,
1620            None,
1621            None,
1622            None,
1623            None,
1624            None,
1625            None,
1626            None, // tags
1627            &StatesConfig::default(),
1628            &IdsConfig::default(),
1629        )
1630        .unwrap();
1631
1632        // Import empty snapshot in replace mode
1633        let snapshot = Snapshot::new();
1634        let options = ImportOptions::replace();
1635        let result = db.import_snapshot(&snapshot, &options).unwrap();
1636
1637        // Task should be deleted
1638        assert_eq!(result.rows_deleted.get("tasks"), Some(&1));
1639
1640        // Worker should still exist (preserved)
1641        let workers = db.list_workers().unwrap();
1642        assert_eq!(workers.len(), 1);
1643        assert_eq!(workers[0].id, "test-worker");
1644    }
1645
1646    #[test]
1647    fn test_clear_project_data() {
1648        let db = Database::open_in_memory().unwrap();
1649
1650        // Create tasks with dependencies and tags
1651        use crate::config::{DependenciesConfig, StatesConfig};
1652        let task_a = db
1653            .create_task(
1654                None,
1655                "Task A".to_string(),
1656                None,
1657                None, // phase
1658                None,
1659                None,
1660                None,
1661                None,
1662                None,
1663                Some(vec!["rust".to_string(), "test".to_string()]), // tags
1664                &StatesConfig::default(),
1665                &IdsConfig::default(),
1666            )
1667            .unwrap();
1668
1669        let task_b = db
1670            .create_task(
1671                None,
1672                "Task B".to_string(),
1673                None,
1674                None,
1675                None,
1676                None,
1677                None,
1678                None,
1679                None,
1680                None, // tags
1681                &StatesConfig::default(),
1682                &IdsConfig::default(),
1683            )
1684            .unwrap();
1685
1686        // Add dependency
1687        db.add_dependency(
1688            &task_a.id,
1689            &task_b.id,
1690            "blocks",
1691            &DependenciesConfig::default(),
1692        )
1693        .unwrap();
1694
1695        // Clear all project data
1696        let deleted = db.clear_project_data().unwrap();
1697
1698        // Verify counts
1699        assert_eq!(deleted.get("tasks"), Some(&2));
1700        assert_eq!(deleted.get("dependencies"), Some(&1));
1701        assert_eq!(deleted.get("task_tags"), Some(&2));
1702
1703        // Verify tables are empty
1704        db.with_conn(|conn| {
1705            for table in IMPORT_ORDER {
1706                let count: i64 =
1707                    conn.query_row(&format!("SELECT COUNT(*) FROM {}", table), [], |row| {
1708                        row.get(0)
1709                    })?;
1710                assert_eq!(count, 0, "Table {} should be empty", table);
1711            }
1712            Ok(())
1713        })
1714        .unwrap();
1715    }
1716
1717    #[test]
1718    fn test_import_schema_version_mismatch() {
1719        let db = Database::open_in_memory().unwrap();
1720        let mut snapshot = Snapshot::new();
1721        snapshot.schema_version = 999; // Invalid version
1722
1723        let options = ImportOptions::default();
1724        let result = db.import_snapshot(&snapshot, &options);
1725
1726        assert!(result.is_err());
1727        assert!(
1728            result
1729                .unwrap_err()
1730                .to_string()
1731                .contains("Schema version mismatch")
1732        );
1733    }
1734
1735    #[test]
1736    fn test_import_with_attachments() {
1737        let db = Database::open_in_memory().unwrap();
1738        let mut snapshot = Snapshot::new();
1739
1740        // Add task
1741        snapshot.tables.insert(
1742            "tasks".to_string(),
1743            vec![json!({
1744                "id": "task-1",
1745                "title": "Task with attachment",
1746                "description": null,
1747                "status": "pending",
1748                "priority": "5",
1749                "worker_id": null,
1750                "claimed_at": null,
1751                "needed_tags": null,
1752                "wanted_tags": null,
1753                "tags": "[]",
1754                "points": null,
1755                "time_estimate_ms": null,
1756                "time_actual_ms": null,
1757                "started_at": null,
1758                "completed_at": null,
1759                "current_thought": null,
1760                "metric_0": 0,
1761                "metric_1": 0,
1762                "metric_2": 0,
1763                "metric_3": 0,
1764                "metric_4": 0,
1765                "metric_5": 0,
1766                "metric_6": 0,
1767                "metric_7": 0,
1768                "cost_usd": 0.0,
1769                "deleted_at": null,
1770                "deleted_by": null,
1771                "deleted_reason": null,
1772                "created_at": 1700000000000_i64,
1773                "updated_at": 1700000000000_i64
1774            })],
1775        );
1776
1777        // Add attachment
1778        snapshot.tables.insert(
1779            "attachments".to_string(),
1780            vec![json!({
1781                "task_id": "task-1",
1782                "attachment_type": "notes",
1783                "sequence": 0,
1784                "name": "",
1785                "mime_type": "text/plain",
1786                "content": "Some searchable notes content",
1787                "file_path": null,
1788                "created_at": 1700000000000_i64
1789            })],
1790        );
1791
1792        let options = ImportOptions::default();
1793        let result = db.import_snapshot(&snapshot, &options).unwrap();
1794
1795        assert_eq!(result.rows_imported.get("tasks"), Some(&1));
1796        assert_eq!(result.rows_imported.get("attachments"), Some(&1));
1797
1798        // Verify attachment FTS was populated
1799        let results = db.search_tasks("searchable", None, 0, true, None).unwrap();
1800        assert_eq!(results.len(), 1);
1801        assert_eq!(results[0].attachment_matches.len(), 1);
1802    }
1803
1804    #[test]
1805    fn test_import_with_tags() {
1806        let db = Database::open_in_memory().unwrap();
1807        let mut snapshot = Snapshot::new();
1808
1809        // Add task
1810        snapshot.tables.insert(
1811            "tasks".to_string(),
1812            vec![json!({
1813                "id": "task-1",
1814                "title": "Tagged Task",
1815                "description": null,
1816                "status": "pending",
1817                "priority": "5",
1818                "worker_id": null,
1819                "claimed_at": null,
1820                "needed_tags": null,
1821                "wanted_tags": null,
1822                "tags": "[]",
1823                "points": null,
1824                "time_estimate_ms": null,
1825                "time_actual_ms": null,
1826                "started_at": null,
1827                "completed_at": null,
1828                "current_thought": null,
1829                "metric_0": 0,
1830                "metric_1": 0,
1831                "metric_2": 0,
1832                "metric_3": 0,
1833                "metric_4": 0,
1834                "metric_5": 0,
1835                "metric_6": 0,
1836                "metric_7": 0,
1837                "cost_usd": 0.0,
1838                "deleted_at": null,
1839                "deleted_by": null,
1840                "deleted_reason": null,
1841                "created_at": 1700000000000_i64,
1842                "updated_at": 1700000000000_i64
1843            })],
1844        );
1845
1846        // Add tags
1847        snapshot.tables.insert(
1848            "task_tags".to_string(),
1849            vec![
1850                json!({"task_id": "task-1", "tag": "rust"}),
1851                json!({"task_id": "task-1", "tag": "backend"}),
1852            ],
1853        );
1854
1855        snapshot.tables.insert(
1856            "task_needed_tags".to_string(),
1857            vec![json!({"task_id": "task-1", "tag": "senior"})],
1858        );
1859
1860        snapshot.tables.insert(
1861            "task_wanted_tags".to_string(),
1862            vec![json!({"task_id": "task-1", "tag": "rust-expert"})],
1863        );
1864
1865        let options = ImportOptions::default();
1866        let result = db.import_snapshot(&snapshot, &options).unwrap();
1867
1868        assert_eq!(result.rows_imported.get("task_tags"), Some(&2));
1869        assert_eq!(result.rows_imported.get("task_needed_tags"), Some(&1));
1870        assert_eq!(result.rows_imported.get("task_wanted_tags"), Some(&1));
1871    }
1872
1873    #[test]
1874    fn test_import_task_sequence() {
1875        let db = Database::open_in_memory().unwrap();
1876        let mut snapshot = Snapshot::new();
1877
1878        // Add task
1879        snapshot.tables.insert(
1880            "tasks".to_string(),
1881            vec![json!({
1882                "id": "task-1",
1883                "title": "Task with history",
1884                "description": null,
1885                "status": "completed",
1886                "priority": "5",
1887                "worker_id": null,
1888                "claimed_at": null,
1889                "needed_tags": null,
1890                "wanted_tags": null,
1891                "tags": "[]",
1892                "points": null,
1893                "time_estimate_ms": null,
1894                "time_actual_ms": null,
1895                "started_at": null,
1896                "completed_at": 1700000001000_i64,
1897                "current_thought": null,
1898                "metric_0": 0,
1899                "metric_1": 0,
1900                "metric_2": 0,
1901                "metric_3": 0,
1902                "metric_4": 0,
1903                "metric_5": 0,
1904                "metric_6": 0,
1905                "metric_7": 0,
1906                "cost_usd": 0.0,
1907                "deleted_at": null,
1908                "deleted_by": null,
1909                "deleted_reason": null,
1910                "created_at": 1700000000000_i64,
1911                "updated_at": 1700000001000_i64
1912            })],
1913        );
1914
1915        // Add state history
1916        snapshot.tables.insert(
1917            "task_sequence".to_string(),
1918            vec![
1919                json!({
1920                    "id": 1,
1921                    "task_id": "task-1",
1922                    "worker_id": null,
1923                    "event": "pending",
1924                    "reason": "Task created",
1925                    "timestamp": 1700000000000_i64,
1926                    "end_timestamp": 1700000000500_i64
1927                }),
1928                json!({
1929                    "id": 2,
1930                    "task_id": "task-1",
1931                    "worker_id": "worker-1",
1932                    "event": "working",
1933                    "reason": "Started work",
1934                    "timestamp": 1700000000500_i64,
1935                    "end_timestamp": 1700000001000_i64
1936                }),
1937                json!({
1938                    "id": 3,
1939                    "task_id": "task-1",
1940                    "worker_id": "worker-1",
1941                    "event": "completed",
1942                    "reason": "Done",
1943                    "timestamp": 1700000001000_i64,
1944                    "end_timestamp": null
1945                }),
1946            ],
1947        );
1948
1949        let options = ImportOptions::default();
1950        let result = db.import_snapshot(&snapshot, &options).unwrap();
1951
1952        assert_eq!(result.rows_imported.get("task_sequence"), Some(&3));
1953    }
1954
1955    #[test]
1956    fn test_rebuild_fts_indexes() {
1957        let db = Database::open_in_memory().unwrap();
1958
1959        // First, insert a task normally (trigger will fire)
1960        db.with_conn(|conn| {
1961            conn.execute(
1962                "INSERT INTO tasks (id, title, description, status, priority, created_at, updated_at)
1963                 VALUES ('test-task', 'Manual Insert Test', 'Bypass trigger', 'pending', '5', 1700000000000, 1700000000000)",
1964                [],
1965            )?;
1966            Ok(())
1967        }).unwrap();
1968
1969        // FTS should have the task due to triggers
1970        let results = db.search_tasks("Manual", None, 0, false, None).unwrap();
1971        assert_eq!(results.len(), 1);
1972
1973        // Now delete from FTS to simulate a corrupted/empty FTS state
1974        db.with_conn(|conn| {
1975            conn.execute("DELETE FROM tasks_fts", [])?;
1976            Ok(())
1977        })
1978        .unwrap();
1979
1980        // Search should now find nothing
1981        let results = db.search_tasks("Manual", None, 0, false, None).unwrap();
1982        assert!(results.is_empty());
1983
1984        // Rebuild FTS
1985        db.rebuild_fts_indexes().unwrap();
1986
1987        // Now search should work again
1988        let results = db.search_tasks("Manual", None, 0, false, None).unwrap();
1989        assert_eq!(results.len(), 1);
1990        assert_eq!(results[0].task_id, "test-task");
1991    }
1992
1993    #[test]
1994    fn test_import_mode_default() {
1995        // Default mode should be Fresh
1996        let options = ImportOptions::default();
1997        assert_eq!(options.mode, ImportMode::Fresh);
1998    }
1999
2000    #[test]
2001    fn test_import_result_total_deleted() {
2002        let mut result = ImportResult::new();
2003        result.rows_deleted.insert("tasks".to_string(), 5);
2004        result.rows_deleted.insert("dependencies".to_string(), 3);
2005        assert_eq!(result.total_deleted(), 8);
2006    }
2007
2008    #[test]
2009    fn test_import_result_total_skipped() {
2010        let mut result = ImportResult::new();
2011        result.rows_skipped.insert("tasks".to_string(), 3);
2012        result.rows_skipped.insert("dependencies".to_string(), 2);
2013        assert_eq!(result.total_skipped(), 5);
2014    }
2015
2016    #[test]
2017    fn test_merge_mode_skips_existing_tasks() {
2018        let db = Database::open_in_memory().unwrap();
2019
2020        // Create existing task with specific ID
2021        use crate::config::StatesConfig;
2022        db.create_task(
2023            Some("existing-task".to_string()),
2024            "Existing task".to_string(),
2025            None,
2026            None, // phase
2027            None,
2028            None,
2029            None,
2030            None,
2031            None,
2032            None, // tags
2033            &StatesConfig::default(),
2034            &IdsConfig::default(),
2035        )
2036        .unwrap();
2037
2038        // Create snapshot with same ID task and a new task
2039        let mut snapshot = Snapshot::new();
2040        snapshot.tables.insert(
2041            "tasks".to_string(),
2042            vec![
2043                json!({
2044                    "id": "existing-task", // This should be skipped
2045                    "title": "Should Be Skipped",
2046                    "description": null,
2047                    "status": "pending",
2048                    "priority": "5",
2049                    "worker_id": null,
2050                    "claimed_at": null,
2051                    "needed_tags": null,
2052                    "wanted_tags": null,
2053                    "tags": "[]",
2054                    "points": null,
2055                    "time_estimate_ms": null,
2056                    "time_actual_ms": null,
2057                    "started_at": null,
2058                    "completed_at": null,
2059                    "current_thought": null,
2060                    "metric_0": 0,
2061                    "metric_1": 0,
2062                    "metric_2": 0,
2063                    "metric_3": 0,
2064                    "metric_4": 0,
2065                    "metric_5": 0,
2066                    "metric_6": 0,
2067                    "metric_7": 0,
2068                    "cost_usd": 0.0,
2069                    "deleted_at": null,
2070                    "deleted_by": null,
2071                    "deleted_reason": null,
2072                    "created_at": 1700000000000_i64,
2073                    "updated_at": 1700000000000_i64
2074                }),
2075                json!({
2076                    "id": "new-task", // This should be imported
2077                    "title": "New Task",
2078                    "description": null,
2079                    "status": "pending",
2080                    "priority": "5",
2081                    "worker_id": null,
2082                    "claimed_at": null,
2083                    "needed_tags": null,
2084                    "wanted_tags": null,
2085                    "tags": "[]",
2086                    "points": null,
2087                    "time_estimate_ms": null,
2088                    "time_actual_ms": null,
2089                    "started_at": null,
2090                    "completed_at": null,
2091                    "current_thought": null,
2092                    "metric_0": 0,
2093                    "metric_1": 0,
2094                    "metric_2": 0,
2095                    "metric_3": 0,
2096                    "metric_4": 0,
2097                    "metric_5": 0,
2098                    "metric_6": 0,
2099                    "metric_7": 0,
2100                    "cost_usd": 0.0,
2101                    "deleted_at": null,
2102                    "deleted_by": null,
2103                    "deleted_reason": null,
2104                    "created_at": 1700000000000_i64,
2105                    "updated_at": 1700000000000_i64
2106                }),
2107            ],
2108        );
2109
2110        // Import in merge mode
2111        let options = ImportOptions::merge();
2112        let result = db.import_snapshot(&snapshot, &options).unwrap();
2113
2114        // 1 imported (new-task), 1 skipped (existing-task)
2115        assert_eq!(result.rows_imported.get("tasks"), Some(&1));
2116        assert_eq!(result.rows_skipped.get("tasks"), Some(&1));
2117
2118        // Existing task should still have original title
2119        let existing = db.get_task("existing-task").unwrap().unwrap();
2120        assert_eq!(existing.title, "Existing task");
2121
2122        // New task should be imported
2123        let new_task = db.get_task("new-task").unwrap();
2124        assert!(new_task.is_some());
2125        assert_eq!(new_task.unwrap().title, "New Task");
2126    }
2127
2128    #[test]
2129    fn test_merge_mode_skips_existing_dependencies() {
2130        let db = Database::open_in_memory().unwrap();
2131
2132        // Create tasks and dependency
2133        use crate::config::{DependenciesConfig, StatesConfig};
2134        db.create_task(
2135            Some("task-a".to_string()),
2136            "Task A".to_string(),
2137            None,
2138            None,
2139            None,
2140            None,
2141            None,
2142            None,
2143            None,
2144            None, // tags
2145            &StatesConfig::default(),
2146            &IdsConfig::default(),
2147        )
2148        .unwrap();
2149        db.create_task(
2150            Some("task-b".to_string()),
2151            "Task B".to_string(),
2152            None,
2153            None,
2154            None,
2155            None,
2156            None,
2157            None,
2158            None,
2159            None, // tags
2160            &StatesConfig::default(),
2161            &IdsConfig::default(),
2162        )
2163        .unwrap();
2164        db.create_task(
2165            Some("task-c".to_string()),
2166            "Task C".to_string(),
2167            None,
2168            None,
2169            None,
2170            None,
2171            None,
2172            None,
2173            None,
2174            None, // tags
2175            &StatesConfig::default(),
2176            &IdsConfig::default(),
2177        )
2178        .unwrap();
2179        db.add_dependency("task-a", "task-b", "blocks", &DependenciesConfig::default())
2180            .unwrap();
2181
2182        // Create snapshot with existing and new dependencies
2183        let mut snapshot = Snapshot::new();
2184        snapshot.tables.insert(
2185            "dependencies".to_string(),
2186            vec![
2187                json!({
2188                    "from_task_id": "task-a",
2189                    "to_task_id": "task-b",
2190                    "dep_type": "blocks" // Existing - should be skipped
2191                }),
2192                json!({
2193                    "from_task_id": "task-b",
2194                    "to_task_id": "task-c",
2195                    "dep_type": "blocks" // New - should be imported
2196                }),
2197            ],
2198        );
2199
2200        // Import in merge mode
2201        let options = ImportOptions::merge();
2202        let result = db.import_snapshot(&snapshot, &options).unwrap();
2203
2204        // 1 imported (b->c), 1 skipped (a->b)
2205        assert_eq!(result.rows_imported.get("dependencies"), Some(&1));
2206        assert_eq!(result.rows_skipped.get("dependencies"), Some(&1));
2207    }
2208
2209    #[test]
2210    fn test_merge_mode_skips_state_sequence() {
2211        let db = Database::open_in_memory().unwrap();
2212
2213        // Create a task (will have initial state history)
2214        use crate::config::StatesConfig;
2215        db.create_task(
2216            Some("task-1".to_string()),
2217            "Task 1".to_string(),
2218            None,
2219            None,
2220            None,
2221            None,
2222            None,
2223            None,
2224            None,
2225            None, // tags
2226            &StatesConfig::default(),
2227            &IdsConfig::default(),
2228        )
2229        .unwrap();
2230
2231        // Snapshot with state history
2232        let mut snapshot = Snapshot::new();
2233        snapshot.tables.insert(
2234            "task_sequence".to_string(),
2235            vec![json!({
2236                "id": 999,
2237                "task_id": "task-1",
2238                "worker_id": null,
2239                "event": "pending",
2240                "reason": "Imported history",
2241                "timestamp": 1700000000000_i64,
2242                "end_timestamp": null
2243            })],
2244        );
2245
2246        // Import in merge mode
2247        let options = ImportOptions::merge();
2248        let result = db.import_snapshot(&snapshot, &options).unwrap();
2249
2250        // State sequence should be all skipped in merge mode
2251        assert_eq!(result.rows_imported.get("task_sequence"), Some(&0));
2252        assert_eq!(result.rows_skipped.get("task_sequence"), Some(&1));
2253    }
2254
2255    #[test]
2256    fn test_merge_mode_adds_new_tags() {
2257        let db = Database::open_in_memory().unwrap();
2258
2259        // Create task with tags
2260        use crate::config::StatesConfig;
2261        db.create_task(
2262            Some("task-1".to_string()),
2263            "Task 1".to_string(),
2264            None,
2265            None,
2266            None,
2267            None,
2268            None,
2269            None,
2270            None,
2271            Some(vec!["existing-tag".to_string()]), // tags
2272            &StatesConfig::default(),
2273            &IdsConfig::default(),
2274        )
2275        .unwrap();
2276
2277        // Snapshot with existing and new tags
2278        let mut snapshot = Snapshot::new();
2279        snapshot.tables.insert(
2280            "task_tags".to_string(),
2281            vec![
2282                json!({"task_id": "task-1", "tag": "existing-tag"}), // Existing - skip
2283                json!({"task_id": "task-1", "tag": "new-tag"}),      // New - import
2284            ],
2285        );
2286
2287        // Import in merge mode
2288        let options = ImportOptions::merge();
2289        let result = db.import_snapshot(&snapshot, &options).unwrap();
2290
2291        // 1 imported (new-tag), 1 skipped (existing-tag)
2292        assert_eq!(result.rows_imported.get("task_tags"), Some(&1));
2293        assert_eq!(result.rows_skipped.get("task_tags"), Some(&1));
2294    }
2295
2296    #[test]
2297    fn test_import_options_merge() {
2298        let options = ImportOptions::merge();
2299        assert_eq!(options.mode, ImportMode::Merge);
2300    }
2301
2302    // ============================================================================
2303    // Dry-run (preview_import) tests
2304    // ============================================================================
2305
2306    #[test]
2307    fn test_preview_fresh_mode_empty_db() {
2308        let db = Database::open_in_memory().unwrap();
2309        let mut snapshot = Snapshot::new();
2310
2311        // Add a task to the snapshot
2312        snapshot.tables.insert(
2313            "tasks".to_string(),
2314            vec![json!({
2315                "id": "task-1",
2316                "title": "Test Task",
2317                "description": null,
2318                "status": "pending",
2319                "priority": "5",
2320                "worker_id": null,
2321                "claimed_at": null,
2322                "needed_tags": null,
2323                "wanted_tags": null,
2324                "tags": "[]",
2325                "points": null,
2326                "time_estimate_ms": null,
2327                "time_actual_ms": null,
2328                "started_at": null,
2329                "completed_at": null,
2330                "current_thought": null,
2331                "metric_0": 0,
2332                "metric_1": 0,
2333                "metric_2": 0,
2334                "metric_3": 0,
2335                "metric_4": 0,
2336                "metric_5": 0,
2337                "metric_6": 0,
2338                "metric_7": 0,
2339                "cost_usd": 0.0,
2340                "deleted_at": null,
2341                "deleted_by": null,
2342                "deleted_reason": null,
2343                "created_at": 1700000000000_i64,
2344                "updated_at": 1700000000000_i64
2345            })],
2346        );
2347
2348        let options = ImportOptions::fresh();
2349        let preview = db.preview_import(&snapshot, &options);
2350
2351        assert!(preview.would_succeed);
2352        assert!(preview.database_is_empty);
2353        assert_eq!(preview.mode, ImportMode::Fresh);
2354        assert_eq!(preview.total_would_insert(), 1);
2355        assert_eq!(preview.total_would_delete(), 0);
2356        assert_eq!(preview.total_would_skip(), 0);
2357    }
2358
2359    #[test]
2360    fn test_preview_fresh_mode_non_empty_db() {
2361        let db = Database::open_in_memory().unwrap();
2362
2363        // Create existing task
2364        use crate::config::StatesConfig;
2365        db.create_task(
2366            None,
2367            "Existing task".to_string(),
2368            None,
2369            None,
2370            None,
2371            None,
2372            None,
2373            None,
2374            None,
2375            None, // tags
2376            &StatesConfig::default(),
2377            &IdsConfig::default(),
2378        )
2379        .unwrap();
2380
2381        let snapshot = Snapshot::new();
2382        let options = ImportOptions::fresh();
2383        let preview = db.preview_import(&snapshot, &options);
2384
2385        // Should fail because database is not empty
2386        assert!(!preview.would_succeed);
2387        assert!(!preview.database_is_empty);
2388        assert!(preview.failure_reason.is_some());
2389        assert!(preview.failure_reason.unwrap().contains("not empty"));
2390    }
2391
2392    #[test]
2393    fn test_preview_replace_mode() {
2394        let db = Database::open_in_memory().unwrap();
2395
2396        // Create existing tasks
2397        use crate::config::StatesConfig;
2398        db.create_task(
2399            Some("existing-1".to_string()),
2400            "Existing 1".to_string(),
2401            None,
2402            None,
2403            None,
2404            None,
2405            None,
2406            None,
2407            None,
2408            None, // tags
2409            &StatesConfig::default(),
2410            &IdsConfig::default(),
2411        )
2412        .unwrap();
2413        db.create_task(
2414            Some("existing-2".to_string()),
2415            "Existing 2".to_string(),
2416            None,
2417            None,
2418            None,
2419            None,
2420            None,
2421            None,
2422            None,
2423            None, // tags
2424            &StatesConfig::default(),
2425            &IdsConfig::default(),
2426        )
2427        .unwrap();
2428
2429        // Create snapshot with different task
2430        let mut snapshot = Snapshot::new();
2431        snapshot.tables.insert(
2432            "tasks".to_string(),
2433            vec![json!({
2434                "id": "new-task",
2435                "title": "New Task",
2436                "description": null,
2437                "status": "pending",
2438                "priority": "5",
2439                "worker_id": null,
2440                "claimed_at": null,
2441                "needed_tags": null,
2442                "wanted_tags": null,
2443                "tags": "[]",
2444                "points": null,
2445                "time_estimate_ms": null,
2446                "time_actual_ms": null,
2447                "started_at": null,
2448                "completed_at": null,
2449                "current_thought": null,
2450                "metric_0": 0,
2451                "metric_1": 0,
2452                "metric_2": 0,
2453                "metric_3": 0,
2454                "metric_4": 0,
2455                "metric_5": 0,
2456                "metric_6": 0,
2457                "metric_7": 0,
2458                "cost_usd": 0.0,
2459                "deleted_at": null,
2460                "deleted_by": null,
2461                "deleted_reason": null,
2462                "created_at": 1700000000000_i64,
2463                "updated_at": 1700000000000_i64
2464            })],
2465        );
2466
2467        let options = ImportOptions::replace();
2468        let preview = db.preview_import(&snapshot, &options);
2469
2470        assert!(preview.would_succeed);
2471        assert!(!preview.database_is_empty);
2472        assert_eq!(preview.mode, ImportMode::Replace);
2473        // Would delete 2 existing tasks
2474        assert_eq!(preview.would_delete.get("tasks"), Some(&2));
2475        // Would insert 1 new task
2476        assert_eq!(preview.would_insert.get("tasks"), Some(&1));
2477        assert_eq!(preview.total_would_skip(), 0);
2478    }
2479
2480    #[test]
2481    fn test_preview_merge_mode() {
2482        let db = Database::open_in_memory().unwrap();
2483
2484        // Create existing task
2485        use crate::config::StatesConfig;
2486        db.create_task(
2487            Some("existing-task".to_string()),
2488            "Existing Task".to_string(),
2489            None,
2490            None,
2491            None,
2492            None,
2493            None,
2494            None,
2495            None,
2496            None, // tags
2497            &StatesConfig::default(),
2498            &IdsConfig::default(),
2499        )
2500        .unwrap();
2501
2502        // Create snapshot with existing and new tasks
2503        let mut snapshot = Snapshot::new();
2504        snapshot.tables.insert(
2505            "tasks".to_string(),
2506            vec![
2507                json!({
2508                    "id": "existing-task", // Will be skipped
2509                    "title": "Should Skip",
2510                    "description": null,
2511                    "status": "pending",
2512                    "priority": "5",
2513                    "worker_id": null,
2514                    "claimed_at": null,
2515                    "needed_tags": null,
2516                    "wanted_tags": null,
2517                    "tags": "[]",
2518                    "points": null,
2519                    "time_estimate_ms": null,
2520                    "time_actual_ms": null,
2521                    "started_at": null,
2522                    "completed_at": null,
2523                    "current_thought": null,
2524                    "metric_0": 0,
2525                    "metric_1": 0,
2526                    "metric_2": 0,
2527                    "metric_3": 0,
2528                    "metric_4": 0,
2529                    "metric_5": 0,
2530                    "metric_6": 0,
2531                    "metric_7": 0,
2532                    "cost_usd": 0.0,
2533                    "deleted_at": null,
2534                    "deleted_by": null,
2535                    "deleted_reason": null,
2536                    "created_at": 1700000000000_i64,
2537                    "updated_at": 1700000000000_i64
2538                }),
2539                json!({
2540                    "id": "new-task", // Will be inserted
2541                    "title": "New Task",
2542                    "description": null,
2543                    "status": "pending",
2544                    "priority": "5",
2545                    "worker_id": null,
2546                    "claimed_at": null,
2547                    "needed_tags": null,
2548                    "wanted_tags": null,
2549                    "tags": "[]",
2550                    "points": null,
2551                    "time_estimate_ms": null,
2552                    "time_actual_ms": null,
2553                    "started_at": null,
2554                    "completed_at": null,
2555                    "current_thought": null,
2556                    "metric_0": 0,
2557                    "metric_1": 0,
2558                    "metric_2": 0,
2559                    "metric_3": 0,
2560                    "metric_4": 0,
2561                    "metric_5": 0,
2562                    "metric_6": 0,
2563                    "metric_7": 0,
2564                    "cost_usd": 0.0,
2565                    "deleted_at": null,
2566                    "deleted_by": null,
2567                    "deleted_reason": null,
2568                    "created_at": 1700000000000_i64,
2569                    "updated_at": 1700000000000_i64
2570                }),
2571            ],
2572        );
2573
2574        let options = ImportOptions::merge();
2575        let preview = db.preview_import(&snapshot, &options);
2576
2577        assert!(preview.would_succeed);
2578        assert!(!preview.database_is_empty);
2579        assert_eq!(preview.mode, ImportMode::Merge);
2580        // Would skip 1 existing task
2581        assert_eq!(preview.would_skip.get("tasks"), Some(&1));
2582        // Would insert 1 new task
2583        assert_eq!(preview.would_insert.get("tasks"), Some(&1));
2584        // No deletions in merge mode
2585        assert_eq!(preview.total_would_delete(), 0);
2586    }
2587
2588    #[test]
2589    fn test_preview_schema_version_mismatch() {
2590        let db = Database::open_in_memory().unwrap();
2591        let mut snapshot = Snapshot::new();
2592        snapshot.schema_version = 999; // Invalid version
2593
2594        let options = ImportOptions::fresh();
2595        let preview = db.preview_import(&snapshot, &options);
2596
2597        assert!(!preview.would_succeed);
2598        assert!(preview.failure_reason.is_some());
2599        assert!(
2600            preview
2601                .failure_reason
2602                .unwrap()
2603                .contains("Schema version mismatch")
2604        );
2605    }
2606
2607    #[test]
2608    fn test_dry_run_result_totals() {
2609        let mut result = DryRunResult::new(ImportMode::Replace);
2610        result.existing_rows.insert("tasks".to_string(), 5);
2611        result.existing_rows.insert("dependencies".to_string(), 3);
2612        result.would_delete.insert("tasks".to_string(), 5);
2613        result.would_delete.insert("dependencies".to_string(), 3);
2614        result.would_insert.insert("tasks".to_string(), 2);
2615        result.would_skip.insert("attachments".to_string(), 1);
2616
2617        assert_eq!(result.total_existing(), 8);
2618        assert_eq!(result.total_would_delete(), 8);
2619        assert_eq!(result.total_would_insert(), 2);
2620        assert_eq!(result.total_would_skip(), 1);
2621    }
2622}