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::config::IdsConfig;
16use crate::export::{CURRENT_SCHEMA_VERSION, Snapshot};
17use anyhow::{Context, Result, anyhow};
18use rusqlite::params;
19use serde_json::Value;
20use std::collections::HashMap;
21
22use super::Database;
23
24/// Import mode determining how to handle existing data.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
26pub enum ImportMode {
27    /// Import into an empty database. Fails if any project data exists.
28    #[default]
29    Fresh,
30    /// Clear all existing project data before importing.
31    /// Preserves runtime tables (workers, file_locks).
32    Replace,
33    /// Merge: Add missing items, skip existing.
34    /// - Tasks: skip if ID exists, insert if new
35    /// - Dependencies: skip if exact match exists
36    /// - Attachments: append (keeps both) or replace by name
37    /// - Tags: union existing and imported
38    /// - State sequence: skip (preserves existing history)
39    Merge,
40}
41
42/// Result of a dry-run import preview.
43/// Shows what would happen without making any changes.
44#[derive(Debug, Clone)]
45pub struct DryRunResult {
46    /// Import mode that would be used.
47    pub mode: ImportMode,
48    /// Whether the database is empty (relevant for Fresh mode).
49    pub database_is_empty: bool,
50    /// Number of existing rows per table (before import).
51    pub existing_rows: std::collections::BTreeMap<String, usize>,
52    /// Number of rows that would be deleted per table (Replace mode).
53    pub would_delete: std::collections::BTreeMap<String, usize>,
54    /// Number of rows that would be inserted per table.
55    pub would_insert: std::collections::BTreeMap<String, usize>,
56    /// Number of rows that would be skipped per table (Merge mode).
57    pub would_skip: std::collections::BTreeMap<String, usize>,
58    /// Whether the import would succeed with the given mode.
59    pub would_succeed: bool,
60    /// Reason for failure if would_succeed is false.
61    pub failure_reason: Option<String>,
62    /// Any warnings that would be generated.
63    pub warnings: Vec<String>,
64}
65
66impl DryRunResult {
67    /// Create a new empty dry-run result.
68    fn new(mode: ImportMode) -> Self {
69        Self {
70            mode,
71            database_is_empty: true,
72            existing_rows: std::collections::BTreeMap::new(),
73            would_delete: std::collections::BTreeMap::new(),
74            would_insert: std::collections::BTreeMap::new(),
75            would_skip: std::collections::BTreeMap::new(),
76            would_succeed: true,
77            failure_reason: None,
78            warnings: Vec::new(),
79        }
80    }
81
82    /// Total number of rows that would be deleted.
83    pub fn total_would_delete(&self) -> usize {
84        self.would_delete.values().sum()
85    }
86
87    /// Total number of rows that would be inserted.
88    pub fn total_would_insert(&self) -> usize {
89        self.would_insert.values().sum()
90    }
91
92    /// Total number of rows that would be skipped.
93    pub fn total_would_skip(&self) -> usize {
94        self.would_skip.values().sum()
95    }
96
97    /// Total existing rows in the database.
98    pub fn total_existing(&self) -> usize {
99        self.existing_rows.values().sum()
100    }
101}
102
103/// Result of an import operation.
104#[derive(Debug, Clone)]
105pub struct ImportResult {
106    /// Number of rows imported per table.
107    pub rows_imported: std::collections::BTreeMap<String, usize>,
108    /// Number of rows deleted per table (for replace mode).
109    pub rows_deleted: std::collections::BTreeMap<String, usize>,
110    /// Number of rows skipped per table (for merge mode).
111    pub rows_skipped: std::collections::BTreeMap<String, usize>,
112    /// Whether FTS indexes were rebuilt.
113    pub fts_rebuilt: bool,
114    /// Any warnings encountered during import.
115    pub warnings: Vec<String>,
116    /// ID remapping table (old_id -> new_id), populated when remap_ids is used.
117    pub id_remap: Option<HashMap<String, String>>,
118    /// Root task IDs that were attached to a parent (when parent_id option was used).
119    pub parent_linked_roots: Vec<String>,
120}
121
122impl ImportResult {
123    /// Create a new empty import result.
124    fn new() -> Self {
125        Self {
126            rows_imported: std::collections::BTreeMap::new(),
127            rows_deleted: std::collections::BTreeMap::new(),
128            rows_skipped: std::collections::BTreeMap::new(),
129            fts_rebuilt: false,
130            warnings: Vec::new(),
131            id_remap: None,
132            parent_linked_roots: Vec::new(),
133        }
134    }
135
136    /// Total number of rows imported.
137    pub fn total_rows(&self) -> usize {
138        self.rows_imported.values().sum()
139    }
140
141    /// Total number of rows deleted.
142    pub fn total_deleted(&self) -> usize {
143        self.rows_deleted.values().sum()
144    }
145
146    /// Total number of rows skipped (merge mode).
147    pub fn total_skipped(&self) -> usize {
148        self.rows_skipped.values().sum()
149    }
150}
151
152/// Options for controlling import behavior.
153#[derive(Debug, Clone, Default)]
154pub struct ImportOptions {
155    /// Import mode (Fresh or Replace).
156    pub mode: ImportMode,
157    /// Whether to remap all task IDs to fresh petname IDs.
158    /// When enabled, generates new IDs for every task and updates
159    /// all references (dependencies, attachments, tags, state history).
160    pub remap_ids: bool,
161    /// Optional parent task ID. When set, root tasks in the imported
162    /// snapshot (those with no incoming "contains" dependency) will be
163    /// attached to this parent via "contains" dependencies after import.
164    pub parent_id: Option<String>,
165}
166
167impl ImportOptions {
168    /// Create options for fresh import (empty database required).
169    pub fn fresh() -> Self {
170        Self {
171            mode: ImportMode::Fresh,
172            remap_ids: false,
173            parent_id: None,
174        }
175    }
176
177    /// Create options for replace import (clear existing data).
178    pub fn replace() -> Self {
179        Self {
180            mode: ImportMode::Replace,
181            remap_ids: false,
182            parent_id: None,
183        }
184    }
185
186    /// Create options for merge import (add missing items, skip existing).
187    pub fn merge() -> Self {
188        Self {
189            mode: ImportMode::Merge,
190            remap_ids: false,
191            parent_id: None,
192        }
193    }
194
195    /// Enable ID remapping on this options instance (builder pattern).
196    pub fn with_remap_ids(mut self) -> Self {
197        self.remap_ids = true;
198        self
199    }
200
201    /// Set parent task ID for attaching imported roots (builder pattern).
202    pub fn with_parent(mut self, parent_id: String) -> Self {
203        self.parent_id = Some(parent_id);
204        self
205    }
206}
207
208/// Generate a fresh petname ID for use in ID remapping.
209/// Uses the same approach as generate_task_id in db/tasks.rs.
210fn generate_remap_id(ids_config: &IdsConfig) -> String {
211    use petname::{Generator, Petnames};
212
213    let words = ids_config.task_id_words;
214    let case = ids_config.id_case;
215
216    let base = Petnames::medium()
217        .generate_one(words, "-")
218        .unwrap_or_else(|| format!("task-{}", chrono::Utc::now().timestamp_millis()));
219
220    case.convert(&base)
221}
222
223/// Remap all task IDs in a snapshot, generating fresh petname IDs for each task
224/// and updating all references (dependencies, attachments, tags, state history).
225///
226/// Returns a new snapshot with remapped IDs and the old->new ID mapping table.
227///
228/// # Arguments
229/// * `snapshot` - The original snapshot to remap
230/// * `ids_config` - ID generation configuration (word count, case style)
231///
232/// # Returns
233/// * `(Snapshot, HashMap<String, String>)` - The remapped snapshot and the old->new mapping
234pub fn remap_snapshot(
235    snapshot: &Snapshot,
236    ids_config: &IdsConfig,
237) -> Result<(Snapshot, HashMap<String, String>)> {
238    let mut remapped = snapshot.clone();
239    let mut id_map: HashMap<String, String> = HashMap::new();
240
241    // Phase 1: Build the old->new ID mapping from the tasks table.
242    // Generate a unique new ID for each task. If a collision occurs
243    // (extremely unlikely with petnames), retry.
244    if let Some(tasks) = snapshot.tables.get("tasks") {
245        let mut used_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
246
247        for task_row in tasks {
248            if let Some(old_id) = task_row.get("id").and_then(|v| v.as_str()) {
249                let mut new_id = generate_remap_id(ids_config);
250                // Ensure uniqueness (retry on collision)
251                let mut attempts = 0;
252                while used_ids.contains(&new_id) {
253                    new_id = generate_remap_id(ids_config);
254                    attempts += 1;
255                    if attempts > 100 {
256                        return Err(anyhow!(
257                            "Failed to generate unique ID after 100 attempts. \
258                             Consider increasing ids.task_id_words in config."
259                        ));
260                    }
261                }
262                used_ids.insert(new_id.clone());
263                id_map.insert(old_id.to_string(), new_id);
264            }
265        }
266    }
267
268    // Helper closure: remap an ID field in a JSON object, returning the object unchanged
269    // if the old ID is not in the map (external reference).
270    let remap_field = |obj: &mut serde_json::Map<String, Value>, field: &str| {
271        if let Some(val) = obj.get(field)
272            && let Some(old_id) = val.as_str()
273            && let Some(new_id) = id_map.get(old_id)
274        {
275            obj.insert(field.to_string(), Value::String(new_id.clone()));
276        }
277        // If old_id not in map, it's an external reference -- left unchanged
278    };
279
280    // Phase 2: Remap IDs in all tables.
281
282    // tasks: remap "id" field
283    if let Some(tasks) = remapped.tables.get_mut("tasks") {
284        for task_row in tasks.iter_mut() {
285            if let Some(obj) = task_row.as_object_mut() {
286                remap_field(obj, "id");
287            }
288        }
289    }
290
291    // dependencies: remap "from_task_id" and "to_task_id"
292    if let Some(deps) = remapped.tables.get_mut("dependencies") {
293        for dep_row in deps.iter_mut() {
294            if let Some(obj) = dep_row.as_object_mut() {
295                remap_field(obj, "from_task_id");
296                remap_field(obj, "to_task_id");
297            }
298        }
299    }
300
301    // attachments: remap "task_id"
302    if let Some(attachments) = remapped.tables.get_mut("attachments") {
303        for att_row in attachments.iter_mut() {
304            if let Some(obj) = att_row.as_object_mut() {
305                remap_field(obj, "task_id");
306            }
307        }
308    }
309
310    // task_tags: remap "task_id"
311    if let Some(tags) = remapped.tables.get_mut("task_tags") {
312        for tag_row in tags.iter_mut() {
313            if let Some(obj) = tag_row.as_object_mut() {
314                remap_field(obj, "task_id");
315            }
316        }
317    }
318
319    // task_needed_tags: remap "task_id"
320    if let Some(tags) = remapped.tables.get_mut("task_needed_tags") {
321        for tag_row in tags.iter_mut() {
322            if let Some(obj) = tag_row.as_object_mut() {
323                remap_field(obj, "task_id");
324            }
325        }
326    }
327
328    // task_wanted_tags: remap "task_id"
329    if let Some(tags) = remapped.tables.get_mut("task_wanted_tags") {
330        for tag_row in tags.iter_mut() {
331            if let Some(obj) = tag_row.as_object_mut() {
332                remap_field(obj, "task_id");
333            }
334        }
335    }
336
337    // task_sequence: remap "task_id"
338    if let Some(events) = remapped.tables.get_mut("task_sequence") {
339        for event_row in events.iter_mut() {
340            if let Some(obj) = event_row.as_object_mut() {
341                remap_field(obj, "task_id");
342            }
343        }
344    }
345
346    Ok((remapped, id_map))
347}
348
349/// Extract root task IDs from a snapshot.
350///
351/// Root tasks are those whose IDs do NOT appear as the `to_task_id` of any
352/// dependency with `dep_type = "contains"` within the snapshot. These are the
353/// top-level tasks that have no parent in the imported tree.
354///
355/// # Arguments
356/// * `snapshot` - The snapshot to analyze
357///
358/// # Returns
359/// A vector of task IDs that are root tasks in the snapshot.
360pub fn snapshot_root_task_ids(snapshot: &Snapshot) -> Vec<String> {
361    use std::collections::HashSet;
362
363    // Collect all task IDs from the snapshot
364    let all_task_ids: HashSet<String> = snapshot
365        .tables
366        .get("tasks")
367        .map(|tasks| {
368            tasks
369                .iter()
370                .filter_map(|row| row.get("id").and_then(|v| v.as_str()).map(String::from))
371                .collect()
372        })
373        .unwrap_or_default();
374
375    // Collect IDs that are children (targets of "contains" dependencies)
376    let child_ids: HashSet<String> = snapshot
377        .tables
378        .get("dependencies")
379        .map(|deps| {
380            deps.iter()
381                .filter_map(|row| {
382                    let dep_type = row.get("dep_type").and_then(|v| v.as_str())?;
383                    if dep_type == "contains" {
384                        row.get("to_task_id")
385                            .and_then(|v| v.as_str())
386                            .map(String::from)
387                    } else {
388                        None
389                    }
390                })
391                .collect()
392        })
393        .unwrap_or_default();
394
395    // Root tasks = all tasks minus children
396    all_task_ids
397        .into_iter()
398        .filter(|id| !child_ids.contains(id))
399        .collect()
400}
401
402/// Tables in the order they should be imported (respecting foreign key constraints).
403/// Tasks must be imported first since other tables reference it.
404const IMPORT_ORDER: &[&str] = &[
405    "tasks",
406    "dependencies",
407    "attachments",
408    "task_tags",
409    "task_needed_tags",
410    "task_wanted_tags",
411    "task_sequence",
412];
413
414impl Database {
415    /// Import data from a snapshot into the database.
416    ///
417    /// This function:
418    /// 1. Validates schema version compatibility
419    /// 2. Based on mode:
420    ///    - Fresh: Validates the database is empty
421    ///    - Replace: Clears existing project data (preserves runtime tables)
422    ///    - Merge: Keeps existing data, adds only new items
423    /// 3. Inserts all rows in the correct order (respecting foreign keys)
424    /// 4. Rebuilds FTS indexes
425    ///
426    /// # Arguments
427    /// * `snapshot` - The snapshot to import
428    /// * `options` - Import options
429    ///
430    /// # Returns
431    /// * `Ok(ImportResult)` - Import statistics
432    /// * `Err` - If import fails
433    pub fn import_snapshot(
434        &self,
435        snapshot: &Snapshot,
436        options: &ImportOptions,
437    ) -> Result<ImportResult> {
438        // Validate schema version
439        if snapshot.schema_version != CURRENT_SCHEMA_VERSION {
440            return Err(anyhow!(
441                "Schema version mismatch: snapshot is v{}, database is v{}. Migration required.",
442                snapshot.schema_version,
443                CURRENT_SCHEMA_VERSION
444            ));
445        }
446
447        let mut result = ImportResult::new();
448
449        // Handle mode-specific pre-import actions
450        match options.mode {
451            ImportMode::Fresh => {
452                // Validate database is empty
453                self.validate_empty_database()?;
454            }
455            ImportMode::Replace => {
456                // Clear existing project data
457                result.rows_deleted = self.clear_project_data()?;
458            }
459            ImportMode::Merge => {
460                // No pre-import action needed for merge mode
461                // Existing data is kept, new data is added selectively
462            }
463        }
464
465        // Import tables in order
466        self.with_conn_mut(|conn| {
467            // Disable foreign key checks during import for performance
468            // (we're importing in the correct order anyway)
469            conn.execute("PRAGMA foreign_keys = OFF", [])?;
470
471            // Use a transaction for atomicity
472            let tx = conn.transaction()?;
473
474            for table_name in IMPORT_ORDER {
475                if let Some(rows) = snapshot.tables.get(*table_name) {
476                    let (imported, skipped) = if options.mode == ImportMode::Merge {
477                        merge_table(&tx, table_name, rows)?
478                    } else {
479                        let count = import_table(&tx, table_name, rows)?;
480                        (count, 0)
481                    };
482                    result
483                        .rows_imported
484                        .insert(table_name.to_string(), imported);
485                    if skipped > 0 {
486                        result.rows_skipped.insert(table_name.to_string(), skipped);
487                    }
488                }
489            }
490
491            tx.commit()?;
492
493            // Re-enable foreign keys
494            conn.execute("PRAGMA foreign_keys = ON", [])?;
495
496            Ok(())
497        })?;
498
499        // Rebuild FTS indexes
500        self.rebuild_fts_indexes()?;
501        result.fts_rebuilt = true;
502
503        // If a parent task ID is specified, attach root tasks from the snapshot
504        // under the parent with "contains" dependencies.
505        if let Some(ref parent_id) = options.parent_id {
506            // Verify parent task exists in the database
507            if !self.task_exists(parent_id)? {
508                return Err(anyhow!(
509                    "Parent task '{}' not found in database. Cannot attach imported roots.",
510                    parent_id
511                ));
512            }
513
514            let root_ids = snapshot_root_task_ids(snapshot);
515            if !root_ids.is_empty() {
516                self.with_conn(|conn| {
517                    for root_id in &root_ids {
518                        conn.execute(
519                            "INSERT OR IGNORE INTO dependencies (from_task_id, to_task_id, dep_type) VALUES (?1, ?2, ?3)",
520                            params![parent_id, root_id, "contains"],
521                        )?;
522                    }
523                    Ok(())
524                })?;
525                result.parent_linked_roots = root_ids;
526            }
527        }
528
529        Ok(result)
530    }
531
532    /// Preview what an import would do without making any changes.
533    ///
534    /// This is the dry-run mode: it analyzes the snapshot against the current
535    /// database state and reports what would be inserted, deleted, or skipped.
536    ///
537    /// # Arguments
538    /// * `snapshot` - The snapshot to preview importing
539    /// * `options` - Import options (determines mode)
540    ///
541    /// # Returns
542    /// * `DryRunResult` - Preview of what would happen
543    pub fn preview_import(&self, snapshot: &Snapshot, options: &ImportOptions) -> DryRunResult {
544        let mut result = DryRunResult::new(options.mode);
545
546        // Check schema compatibility
547        if snapshot.schema_version != CURRENT_SCHEMA_VERSION {
548            result.would_succeed = false;
549            result.failure_reason = Some(format!(
550                "Schema version mismatch: snapshot is v{}, database is v{}. Migration required.",
551                snapshot.schema_version, CURRENT_SCHEMA_VERSION
552            ));
553            return result;
554        }
555
556        // Get current row counts for all tables
557        let existing = self.get_table_row_counts();
558        if let Err(e) = existing {
559            result.would_succeed = false;
560            result.failure_reason = Some(format!("Failed to query database: {}", e));
561            return result;
562        }
563        let existing = existing.unwrap();
564        result.existing_rows = existing.clone();
565        result.database_is_empty = existing.values().all(|&count| count == 0);
566
567        // Check mode-specific conditions
568        match options.mode {
569            ImportMode::Fresh => {
570                if !result.database_is_empty {
571                    result.would_succeed = false;
572                    let non_empty: Vec<_> = existing
573                        .iter()
574                        .filter(|&(_, count)| *count > 0)
575                        .map(|(table, count)| format!("{}: {} rows", table, count))
576                        .collect();
577                    result.failure_reason = Some(format!(
578                        "Database is not empty. Use --force to overwrite or --merge to add. Non-empty tables: {}",
579                        non_empty.join(", ")
580                    ));
581                    return result;
582                }
583                // In fresh mode, all rows from snapshot would be inserted
584                for table_name in IMPORT_ORDER {
585                    let count = snapshot.tables.get(*table_name).map_or(0, |v| v.len());
586                    result.would_insert.insert(table_name.to_string(), count);
587                }
588            }
589            ImportMode::Replace => {
590                // All existing rows would be deleted
591                for (table, count) in &existing {
592                    if *count > 0 {
593                        result.would_delete.insert(table.clone(), *count);
594                    }
595                }
596                // All rows from snapshot would be inserted
597                for table_name in IMPORT_ORDER {
598                    let count = snapshot.tables.get(*table_name).map_or(0, |v| v.len());
599                    result.would_insert.insert(table_name.to_string(), count);
600                }
601            }
602            ImportMode::Merge => {
603                // Need to analyze each table to see what would be inserted vs skipped
604                if let Err(e) = self.preview_merge(snapshot, &mut result) {
605                    result.would_succeed = false;
606                    result.failure_reason = Some(format!("Failed to analyze merge: {}", e));
607                    return result;
608                }
609            }
610        }
611
612        result
613    }
614
615    /// Preview what a merge import would do.
616    fn preview_merge(&self, snapshot: &Snapshot, result: &mut DryRunResult) -> Result<()> {
617        self.with_conn(|conn| {
618            for table_name in IMPORT_ORDER {
619                if let Some(rows) = snapshot.tables.get(*table_name) {
620                    let (would_insert, would_skip) = preview_merge_table(conn, table_name, rows)?;
621                    result
622                        .would_insert
623                        .insert(table_name.to_string(), would_insert);
624                    if would_skip > 0 {
625                        result.would_skip.insert(table_name.to_string(), would_skip);
626                    }
627                } else {
628                    result.would_insert.insert(table_name.to_string(), 0);
629                }
630            }
631            Ok(())
632        })
633    }
634
635    /// Get the row count for each project data table.
636    fn get_table_row_counts(&self) -> Result<std::collections::BTreeMap<String, usize>> {
637        self.with_conn(|conn| {
638            let mut counts = std::collections::BTreeMap::new();
639            for table in IMPORT_ORDER {
640                let count: i64 =
641                    conn.query_row(&format!("SELECT COUNT(*) FROM {}", table), [], |row| {
642                        row.get(0)
643                    })?;
644                counts.insert(table.to_string(), count as usize);
645            }
646            Ok(counts)
647        })
648    }
649
650    /// Validate that the database is empty (no project data).
651    fn validate_empty_database(&self) -> Result<()> {
652        self.with_conn(|conn| {
653            for table in IMPORT_ORDER {
654                let count: i64 = conn.query_row(
655                    &format!("SELECT COUNT(*) FROM {}", table),
656                    [],
657                    |row| row.get(0),
658                )?;
659                if count > 0 {
660                    return Err(anyhow!(
661                        "Database is not empty: table '{}' contains {} rows. Use --force to overwrite.",
662                        table,
663                        count
664                    ));
665                }
666            }
667            Ok(())
668        })
669    }
670
671    /// Clear all project data tables, preserving runtime tables.
672    ///
673    /// Tables are deleted in reverse order to respect foreign key constraints
674    /// (children deleted before parents).
675    ///
676    /// Runtime tables preserved:
677    /// - workers: Session-based worker registrations
678    /// - file_locks: Active file marks (advisory locks)
679    /// - claim_sequence: File lock audit log
680    ///
681    /// # Returns
682    /// A map of table names to number of rows deleted.
683    pub fn clear_project_data(&self) -> Result<std::collections::BTreeMap<String, usize>> {
684        let mut deleted = std::collections::BTreeMap::new();
685
686        self.with_conn_mut(|conn| {
687            // Disable foreign key checks during deletion for performance
688            conn.execute("PRAGMA foreign_keys = OFF", [])?;
689
690            // Use a transaction for atomicity
691            let tx = conn.transaction()?;
692
693            // Delete in reverse order to respect foreign key constraints
694            // (children first, then parents)
695            for table_name in IMPORT_ORDER.iter().rev() {
696                let count: i64 =
697                    tx.query_row(&format!("SELECT COUNT(*) FROM {}", table_name), [], |row| {
698                        row.get(0)
699                    })?;
700
701                if count > 0 {
702                    tx.execute(&format!("DELETE FROM {}", table_name), [])?;
703                    deleted.insert(table_name.to_string(), count as usize);
704                }
705            }
706
707            // Also clear FTS tables (they'll be rebuilt after import)
708            tx.execute("DELETE FROM tasks_fts", [])?;
709            tx.execute("DELETE FROM attachments_fts", [])?;
710
711            // Reset auto-increment counter for task_sequence
712            // This ensures imported IDs don't conflict with auto-generated ones
713            tx.execute(
714                "DELETE FROM sqlite_sequence WHERE name = 'task_sequence'",
715                [],
716            )?;
717
718            tx.commit()?;
719
720            // Re-enable foreign keys
721            conn.execute("PRAGMA foreign_keys = ON", [])?;
722
723            Ok(())
724        })?;
725
726        Ok(deleted)
727    }
728
729    /// Rebuild FTS indexes from the base tables.
730    ///
731    /// This is called after import to populate the FTS virtual tables
732    /// since triggers don't fire during bulk import.
733    pub fn rebuild_fts_indexes(&self) -> Result<()> {
734        self.with_conn(|conn| {
735            // Rebuild tasks_fts
736            conn.execute("DELETE FROM tasks_fts", [])?;
737            conn.execute(
738                "INSERT INTO tasks_fts(task_id, title, description)
739                 SELECT id, title, COALESCE(description, '')
740                 FROM tasks",
741                [],
742            )?;
743
744            // Rebuild attachments_fts (only text content)
745            conn.execute("DELETE FROM attachments_fts", [])?;
746            conn.execute(
747                "INSERT INTO attachments_fts(task_id, attachment_type, sequence, name, content)
748                 SELECT task_id, attachment_type, sequence, name, content
749                 FROM attachments
750                 WHERE mime_type LIKE 'text/%'",
751                [],
752            )?;
753
754            Ok(())
755        })
756    }
757}
758
759/// Import rows into a specific table.
760fn import_table(conn: &rusqlite::Connection, table_name: &str, rows: &[Value]) -> Result<usize> {
761    if rows.is_empty() {
762        return Ok(0);
763    }
764
765    match table_name {
766        "tasks" => import_tasks(conn, rows),
767        "dependencies" => import_dependencies(conn, rows),
768        "attachments" => import_attachments(conn, rows),
769        "task_tags" => import_task_tags(conn, rows),
770        "task_needed_tags" => import_task_needed_tags(conn, rows),
771        "task_wanted_tags" => import_task_wanted_tags(conn, rows),
772        "task_sequence" => import_task_sequence(conn, rows),
773        _ => Err(anyhow!("Unknown table: {}", table_name)),
774    }
775}
776
777/// Merge rows into a specific table (skip existing, insert new).
778/// Returns (imported_count, skipped_count).
779fn merge_table(
780    conn: &rusqlite::Connection,
781    table_name: &str,
782    rows: &[Value],
783) -> Result<(usize, usize)> {
784    if rows.is_empty() {
785        return Ok((0, 0));
786    }
787
788    match table_name {
789        "tasks" => merge_tasks(conn, rows),
790        "dependencies" => merge_dependencies(conn, rows),
791        "attachments" => merge_attachments(conn, rows),
792        "task_tags" => merge_task_tags(conn, rows),
793        "task_needed_tags" => merge_task_needed_tags(conn, rows),
794        "task_wanted_tags" => merge_task_wanted_tags(conn, rows),
795        "task_sequence" => merge_task_sequence(conn, rows),
796        _ => Err(anyhow!("Unknown table: {}", table_name)),
797    }
798}
799
800/// Preview what a merge would do for a specific table (no modifications).
801/// Returns (would_insert_count, would_skip_count).
802fn preview_merge_table(
803    conn: &rusqlite::Connection,
804    table_name: &str,
805    rows: &[Value],
806) -> Result<(usize, usize)> {
807    if rows.is_empty() {
808        return Ok((0, 0));
809    }
810
811    match table_name {
812        "tasks" => preview_merge_tasks(conn, rows),
813        "dependencies" => preview_merge_dependencies(conn, rows),
814        "attachments" => preview_merge_attachments(conn, rows),
815        "task_tags" => preview_merge_task_tags(conn, rows),
816        "task_needed_tags" => preview_merge_task_needed_tags(conn, rows),
817        "task_wanted_tags" => preview_merge_task_wanted_tags(conn, rows),
818        "task_sequence" => Ok((0, rows.len())), // Always skip in merge mode
819        _ => Err(anyhow!("Unknown table: {}", table_name)),
820    }
821}
822
823/// Preview merge for tasks - count how many would be inserted vs skipped.
824fn preview_merge_tasks(conn: &rusqlite::Connection, rows: &[Value]) -> Result<(usize, usize)> {
825    let mut would_insert = 0;
826    let mut would_skip = 0;
827
828    for row in rows {
829        let obj = row.as_object().context("Task row must be an object")?;
830        let task_id = get_string(obj, "id")?;
831
832        let exists: bool = conn
833            .query_row(
834                "SELECT 1 FROM tasks WHERE id = ?1",
835                params![&task_id],
836                |_| Ok(true),
837            )
838            .unwrap_or(false);
839
840        if exists {
841            would_skip += 1;
842        } else {
843            would_insert += 1;
844        }
845    }
846
847    Ok((would_insert, would_skip))
848}
849
850/// Preview merge for dependencies - count how many would be inserted vs skipped.
851fn preview_merge_dependencies(
852    conn: &rusqlite::Connection,
853    rows: &[Value],
854) -> Result<(usize, usize)> {
855    let mut would_insert = 0;
856    let mut would_skip = 0;
857
858    for row in rows {
859        let obj = row
860            .as_object()
861            .context("Dependency row must be an object")?;
862        let from_id = get_string(obj, "from_task_id")?;
863        let to_id = get_string(obj, "to_task_id")?;
864        let dep_type = get_string(obj, "dep_type")?;
865
866        let exists: bool = conn
867            .query_row(
868                "SELECT 1 FROM dependencies WHERE from_task_id = ?1 AND to_task_id = ?2 AND dep_type = ?3",
869                params![&from_id, &to_id, &dep_type],
870                |_| Ok(true),
871            )
872            .unwrap_or(false);
873
874        if exists {
875            would_skip += 1;
876        } else {
877            would_insert += 1;
878        }
879    }
880
881    Ok((would_insert, would_skip))
882}
883
884/// Preview merge for attachments - count how many would be inserted vs skipped.
885fn preview_merge_attachments(
886    conn: &rusqlite::Connection,
887    rows: &[Value],
888) -> Result<(usize, usize)> {
889    let mut would_insert = 0;
890    let mut would_skip = 0;
891
892    for row in rows {
893        let obj = row
894            .as_object()
895            .context("Attachment row must be an object")?;
896        let task_id = get_string(obj, "task_id")?;
897        let attachment_type = get_string(obj, "attachment_type")?;
898        let sequence = get_i32(obj, "sequence")?;
899
900        let exists: bool = conn
901            .query_row(
902                "SELECT 1 FROM attachments WHERE task_id = ?1 AND attachment_type = ?2 AND sequence = ?3",
903                params![&task_id, &attachment_type, sequence],
904                |_| Ok(true),
905            )
906            .unwrap_or(false);
907
908        if exists {
909            would_skip += 1;
910        } else {
911            would_insert += 1;
912        }
913    }
914
915    Ok((would_insert, would_skip))
916}
917
918/// Preview merge for task_tags - count how many would be inserted vs skipped.
919fn preview_merge_task_tags(conn: &rusqlite::Connection, rows: &[Value]) -> Result<(usize, usize)> {
920    let mut would_insert = 0;
921    let mut would_skip = 0;
922
923    for row in rows {
924        let obj = row.as_object().context("TaskTag row must be an object")?;
925        let task_id = get_string(obj, "task_id")?;
926        let tag = get_string(obj, "tag")?;
927
928        let exists: bool = conn
929            .query_row(
930                "SELECT 1 FROM task_tags WHERE task_id = ?1 AND tag = ?2",
931                params![&task_id, &tag],
932                |_| Ok(true),
933            )
934            .unwrap_or(false);
935
936        if exists {
937            would_skip += 1;
938        } else {
939            would_insert += 1;
940        }
941    }
942
943    Ok((would_insert, would_skip))
944}
945
946/// Preview merge for task_needed_tags - count how many would be inserted vs skipped.
947fn preview_merge_task_needed_tags(
948    conn: &rusqlite::Connection,
949    rows: &[Value],
950) -> Result<(usize, usize)> {
951    let mut would_insert = 0;
952    let mut would_skip = 0;
953
954    for row in rows {
955        let obj = row
956            .as_object()
957            .context("TaskNeededTag row must be an object")?;
958        let task_id = get_string(obj, "task_id")?;
959        let tag = get_string(obj, "tag")?;
960
961        let exists: bool = conn
962            .query_row(
963                "SELECT 1 FROM task_needed_tags WHERE task_id = ?1 AND tag = ?2",
964                params![&task_id, &tag],
965                |_| Ok(true),
966            )
967            .unwrap_or(false);
968
969        if exists {
970            would_skip += 1;
971        } else {
972            would_insert += 1;
973        }
974    }
975
976    Ok((would_insert, would_skip))
977}
978
979/// Preview merge for task_wanted_tags - count how many would be inserted vs skipped.
980fn preview_merge_task_wanted_tags(
981    conn: &rusqlite::Connection,
982    rows: &[Value],
983) -> Result<(usize, usize)> {
984    let mut would_insert = 0;
985    let mut would_skip = 0;
986
987    for row in rows {
988        let obj = row
989            .as_object()
990            .context("TaskWantedTag row must be an object")?;
991        let task_id = get_string(obj, "task_id")?;
992        let tag = get_string(obj, "tag")?;
993
994        let exists: bool = conn
995            .query_row(
996                "SELECT 1 FROM task_wanted_tags WHERE task_id = ?1 AND tag = ?2",
997                params![&task_id, &tag],
998                |_| Ok(true),
999            )
1000            .unwrap_or(false);
1001
1002        if exists {
1003            would_skip += 1;
1004        } else {
1005            would_insert += 1;
1006        }
1007    }
1008
1009    Ok((would_insert, would_skip))
1010}
1011
1012/// Merge tasks - skip if ID exists, insert if new.
1013fn merge_tasks(conn: &rusqlite::Connection, rows: &[Value]) -> Result<(usize, usize)> {
1014    let mut insert_stmt = conn.prepare(
1015        "INSERT INTO tasks (
1016            id, title, description, status, priority, worker_id, claimed_at,
1017            needed_tags, wanted_tags, tags,
1018            points, time_estimate_ms, time_actual_ms, started_at, completed_at,
1019            current_thought,
1020            metric_0, metric_1, metric_2, metric_3, metric_4, metric_5, metric_6, metric_7,
1021            cost_usd,
1022            deleted_at, deleted_by, deleted_reason,
1023            created_at, updated_at
1024        ) VALUES (
1025            ?1, ?2, ?3, ?4, ?5, ?6, ?7,
1026            ?8, ?9, ?10,
1027            ?11, ?12, ?13, ?14, ?15,
1028            ?16,
1029            ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24,
1030            ?25,
1031            ?26, ?27, ?28,
1032            ?29, ?30
1033        )",
1034    )?;
1035
1036    let mut imported = 0;
1037    let mut skipped = 0;
1038
1039    for row in rows {
1040        let obj = row.as_object().context("Task row must be an object")?;
1041        let task_id = get_string(obj, "id")?;
1042
1043        // Check if task already exists
1044        let exists: bool = conn
1045            .query_row(
1046                "SELECT 1 FROM tasks WHERE id = ?1",
1047                params![&task_id],
1048                |_| Ok(true),
1049            )
1050            .unwrap_or(false);
1051
1052        if exists {
1053            skipped += 1;
1054            continue;
1055        }
1056
1057        insert_stmt.execute(params![
1058            task_id,
1059            get_string(obj, "title")?,
1060            get_opt_string(obj, "description"),
1061            get_string(obj, "status")?,
1062            get_string(obj, "priority")?,
1063            get_opt_string(obj, "worker_id"),
1064            get_opt_i64(obj, "claimed_at"),
1065            get_opt_string(obj, "needed_tags"),
1066            get_opt_string(obj, "wanted_tags"),
1067            get_opt_string(obj, "tags"),
1068            get_opt_i32(obj, "points"),
1069            get_opt_i64(obj, "time_estimate_ms"),
1070            get_opt_i64(obj, "time_actual_ms"),
1071            get_opt_i64(obj, "started_at"),
1072            get_opt_i64(obj, "completed_at"),
1073            get_opt_string(obj, "current_thought"),
1074            get_i64_or_default(obj, "metric_0"),
1075            get_i64_or_default(obj, "metric_1"),
1076            get_i64_or_default(obj, "metric_2"),
1077            get_i64_or_default(obj, "metric_3"),
1078            get_i64_or_default(obj, "metric_4"),
1079            get_i64_or_default(obj, "metric_5"),
1080            get_i64_or_default(obj, "metric_6"),
1081            get_i64_or_default(obj, "metric_7"),
1082            get_f64_or_default(obj, "cost_usd"),
1083            get_opt_i64(obj, "deleted_at"),
1084            get_opt_string(obj, "deleted_by"),
1085            get_opt_string(obj, "deleted_reason"),
1086            get_i64(obj, "created_at")?,
1087            get_i64(obj, "updated_at")?,
1088        ])?;
1089        imported += 1;
1090    }
1091
1092    Ok((imported, skipped))
1093}
1094
1095/// Merge dependencies - skip if exact match exists.
1096fn merge_dependencies(conn: &rusqlite::Connection, rows: &[Value]) -> Result<(usize, usize)> {
1097    let mut insert_stmt = conn.prepare(
1098        "INSERT INTO dependencies (from_task_id, to_task_id, dep_type)
1099         VALUES (?1, ?2, ?3)",
1100    )?;
1101
1102    let mut imported = 0;
1103    let mut skipped = 0;
1104
1105    for row in rows {
1106        let obj = row
1107            .as_object()
1108            .context("Dependency row must be an object")?;
1109        let from_id = get_string(obj, "from_task_id")?;
1110        let to_id = get_string(obj, "to_task_id")?;
1111        let dep_type = get_string(obj, "dep_type")?;
1112
1113        // Check if exact dependency already exists
1114        let exists: bool = conn
1115            .query_row(
1116                "SELECT 1 FROM dependencies WHERE from_task_id = ?1 AND to_task_id = ?2 AND dep_type = ?3",
1117                params![&from_id, &to_id, &dep_type],
1118                |_| Ok(true),
1119            )
1120            .unwrap_or(false);
1121
1122        if exists {
1123            skipped += 1;
1124            continue;
1125        }
1126
1127        insert_stmt.execute(params![from_id, to_id, dep_type])?;
1128        imported += 1;
1129    }
1130
1131    Ok((imported, skipped))
1132}
1133
1134/// Merge attachments - skip if exact match (task_id + attachment_type + sequence) exists.
1135fn merge_attachments(conn: &rusqlite::Connection, rows: &[Value]) -> Result<(usize, usize)> {
1136    let mut insert_stmt = conn.prepare(
1137        "INSERT INTO attachments (task_id, attachment_type, sequence, name, mime_type, content, file_path, created_at)
1138         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
1139    )?;
1140
1141    let mut imported = 0;
1142    let mut skipped = 0;
1143
1144    for row in rows {
1145        let obj = row
1146            .as_object()
1147            .context("Attachment row must be an object")?;
1148        let task_id = get_string(obj, "task_id")?;
1149        let attachment_type = get_string(obj, "attachment_type")?;
1150        let sequence = get_i32(obj, "sequence")?;
1151
1152        // Check if attachment already exists (by task_id + attachment_type + sequence)
1153        let exists: bool = conn
1154            .query_row(
1155                "SELECT 1 FROM attachments WHERE task_id = ?1 AND attachment_type = ?2 AND sequence = ?3",
1156                params![&task_id, &attachment_type, sequence],
1157                |_| Ok(true),
1158            )
1159            .unwrap_or(false);
1160
1161        if exists {
1162            skipped += 1;
1163            continue;
1164        }
1165
1166        insert_stmt.execute(params![
1167            task_id,
1168            attachment_type,
1169            sequence,
1170            get_string(obj, "name")?,
1171            get_string(obj, "mime_type")?,
1172            get_string(obj, "content")?,
1173            get_opt_string(obj, "file_path"),
1174            get_i64(obj, "created_at")?,
1175        ])?;
1176        imported += 1;
1177    }
1178
1179    Ok((imported, skipped))
1180}
1181
1182/// Merge task_tags - skip if exact match exists.
1183fn merge_task_tags(conn: &rusqlite::Connection, rows: &[Value]) -> Result<(usize, usize)> {
1184    let mut insert_stmt = conn.prepare("INSERT INTO task_tags (task_id, tag) VALUES (?1, ?2)")?;
1185
1186    let mut imported = 0;
1187    let mut skipped = 0;
1188
1189    for row in rows {
1190        let obj = row.as_object().context("TaskTag row must be an object")?;
1191        let task_id = get_string(obj, "task_id")?;
1192        let tag = get_string(obj, "tag")?;
1193
1194        // Check if tag already exists
1195        let exists: bool = conn
1196            .query_row(
1197                "SELECT 1 FROM task_tags WHERE task_id = ?1 AND tag = ?2",
1198                params![&task_id, &tag],
1199                |_| Ok(true),
1200            )
1201            .unwrap_or(false);
1202
1203        if exists {
1204            skipped += 1;
1205            continue;
1206        }
1207
1208        insert_stmt.execute(params![task_id, tag])?;
1209        imported += 1;
1210    }
1211
1212    Ok((imported, skipped))
1213}
1214
1215/// Merge task_needed_tags - skip if exact match exists.
1216fn merge_task_needed_tags(conn: &rusqlite::Connection, rows: &[Value]) -> Result<(usize, usize)> {
1217    let mut insert_stmt =
1218        conn.prepare("INSERT INTO task_needed_tags (task_id, tag) VALUES (?1, ?2)")?;
1219
1220    let mut imported = 0;
1221    let mut skipped = 0;
1222
1223    for row in rows {
1224        let obj = row
1225            .as_object()
1226            .context("TaskNeededTag row must be an object")?;
1227        let task_id = get_string(obj, "task_id")?;
1228        let tag = get_string(obj, "tag")?;
1229
1230        // Check if tag already exists
1231        let exists: bool = conn
1232            .query_row(
1233                "SELECT 1 FROM task_needed_tags WHERE task_id = ?1 AND tag = ?2",
1234                params![&task_id, &tag],
1235                |_| Ok(true),
1236            )
1237            .unwrap_or(false);
1238
1239        if exists {
1240            skipped += 1;
1241            continue;
1242        }
1243
1244        insert_stmt.execute(params![task_id, tag])?;
1245        imported += 1;
1246    }
1247
1248    Ok((imported, skipped))
1249}
1250
1251/// Merge task_wanted_tags - skip if exact match exists.
1252fn merge_task_wanted_tags(conn: &rusqlite::Connection, rows: &[Value]) -> Result<(usize, usize)> {
1253    let mut insert_stmt =
1254        conn.prepare("INSERT INTO task_wanted_tags (task_id, tag) VALUES (?1, ?2)")?;
1255
1256    let mut imported = 0;
1257    let mut skipped = 0;
1258
1259    for row in rows {
1260        let obj = row
1261            .as_object()
1262            .context("TaskWantedTag row must be an object")?;
1263        let task_id = get_string(obj, "task_id")?;
1264        let tag = get_string(obj, "tag")?;
1265
1266        // Check if tag already exists
1267        let exists: bool = conn
1268            .query_row(
1269                "SELECT 1 FROM task_wanted_tags WHERE task_id = ?1 AND tag = ?2",
1270                params![&task_id, &tag],
1271                |_| Ok(true),
1272            )
1273            .unwrap_or(false);
1274
1275        if exists {
1276            skipped += 1;
1277            continue;
1278        }
1279
1280        insert_stmt.execute(params![task_id, tag])?;
1281        imported += 1;
1282    }
1283
1284    Ok((imported, skipped))
1285}
1286
1287/// Merge task_sequence - skip all in merge mode to preserve existing history.
1288/// State history from the snapshot is not imported to avoid conflicts with existing history.
1289fn merge_task_sequence(conn: &rusqlite::Connection, rows: &[Value]) -> Result<(usize, usize)> {
1290    // In merge mode, we skip all state sequence imports to preserve existing history.
1291    // The rationale is that state history reflects what actually happened in this database,
1292    // and importing history from another database could create inconsistencies.
1293    let _ = conn; // silence unused variable warning
1294    Ok((0, rows.len()))
1295}
1296
1297/// Import tasks table.
1298fn import_tasks(conn: &rusqlite::Connection, rows: &[Value]) -> Result<usize> {
1299    let mut stmt = conn.prepare(
1300        "INSERT INTO tasks (
1301            id, title, description, status, priority, worker_id, claimed_at,
1302            needed_tags, wanted_tags, tags,
1303            points, time_estimate_ms, time_actual_ms, started_at, completed_at,
1304            current_thought,
1305            metric_0, metric_1, metric_2, metric_3, metric_4, metric_5, metric_6, metric_7,
1306            cost_usd,
1307            deleted_at, deleted_by, deleted_reason,
1308            created_at, updated_at
1309        ) VALUES (
1310            ?1, ?2, ?3, ?4, ?5, ?6, ?7,
1311            ?8, ?9, ?10,
1312            ?11, ?12, ?13, ?14, ?15,
1313            ?16,
1314            ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24,
1315            ?25,
1316            ?26, ?27, ?28,
1317            ?29, ?30
1318        )",
1319    )?;
1320
1321    let mut count = 0;
1322    for row in rows {
1323        let obj = row.as_object().context("Task row must be an object")?;
1324
1325        stmt.execute(params![
1326            get_string(obj, "id")?,
1327            get_string(obj, "title")?,
1328            get_opt_string(obj, "description"),
1329            get_string(obj, "status")?,
1330            get_string(obj, "priority")?,
1331            get_opt_string(obj, "worker_id"),
1332            get_opt_i64(obj, "claimed_at"),
1333            get_opt_string(obj, "needed_tags"),
1334            get_opt_string(obj, "wanted_tags"),
1335            get_opt_string(obj, "tags"),
1336            get_opt_i32(obj, "points"),
1337            get_opt_i64(obj, "time_estimate_ms"),
1338            get_opt_i64(obj, "time_actual_ms"),
1339            get_opt_i64(obj, "started_at"),
1340            get_opt_i64(obj, "completed_at"),
1341            get_opt_string(obj, "current_thought"),
1342            get_i64_or_default(obj, "metric_0"),
1343            get_i64_or_default(obj, "metric_1"),
1344            get_i64_or_default(obj, "metric_2"),
1345            get_i64_or_default(obj, "metric_3"),
1346            get_i64_or_default(obj, "metric_4"),
1347            get_i64_or_default(obj, "metric_5"),
1348            get_i64_or_default(obj, "metric_6"),
1349            get_i64_or_default(obj, "metric_7"),
1350            get_f64_or_default(obj, "cost_usd"),
1351            get_opt_i64(obj, "deleted_at"),
1352            get_opt_string(obj, "deleted_by"),
1353            get_opt_string(obj, "deleted_reason"),
1354            get_i64(obj, "created_at")?,
1355            get_i64(obj, "updated_at")?,
1356        ])?;
1357        count += 1;
1358    }
1359
1360    Ok(count)
1361}
1362
1363/// Import dependencies table.
1364fn import_dependencies(conn: &rusqlite::Connection, rows: &[Value]) -> Result<usize> {
1365    let mut stmt = conn.prepare(
1366        "INSERT INTO dependencies (from_task_id, to_task_id, dep_type)
1367         VALUES (?1, ?2, ?3)",
1368    )?;
1369
1370    let mut count = 0;
1371    for row in rows {
1372        let obj = row
1373            .as_object()
1374            .context("Dependency row must be an object")?;
1375
1376        stmt.execute(params![
1377            get_string(obj, "from_task_id")?,
1378            get_string(obj, "to_task_id")?,
1379            get_string(obj, "dep_type")?,
1380        ])?;
1381        count += 1;
1382    }
1383
1384    Ok(count)
1385}
1386
1387/// Import attachments table.
1388fn import_attachments(conn: &rusqlite::Connection, rows: &[Value]) -> Result<usize> {
1389    let mut stmt = conn.prepare(
1390        "INSERT INTO attachments (task_id, attachment_type, sequence, name, mime_type, content, file_path, created_at)
1391         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
1392    )?;
1393
1394    let mut count = 0;
1395    for row in rows {
1396        let obj = row
1397            .as_object()
1398            .context("Attachment row must be an object")?;
1399
1400        stmt.execute(params![
1401            get_string(obj, "task_id")?,
1402            get_string(obj, "attachment_type")?,
1403            get_i32(obj, "sequence")?,
1404            get_string(obj, "name")?,
1405            get_string(obj, "mime_type")?,
1406            get_string(obj, "content")?,
1407            get_opt_string(obj, "file_path"),
1408            get_i64(obj, "created_at")?,
1409        ])?;
1410        count += 1;
1411    }
1412
1413    Ok(count)
1414}
1415
1416/// Import task_tags table.
1417fn import_task_tags(conn: &rusqlite::Connection, rows: &[Value]) -> Result<usize> {
1418    let mut stmt = conn.prepare("INSERT INTO task_tags (task_id, tag) VALUES (?1, ?2)")?;
1419
1420    let mut count = 0;
1421    for row in rows {
1422        let obj = row.as_object().context("TaskTag row must be an object")?;
1423
1424        stmt.execute(params![
1425            get_string(obj, "task_id")?,
1426            get_string(obj, "tag")?,
1427        ])?;
1428        count += 1;
1429    }
1430
1431    Ok(count)
1432}
1433
1434/// Import task_needed_tags table.
1435fn import_task_needed_tags(conn: &rusqlite::Connection, rows: &[Value]) -> Result<usize> {
1436    let mut stmt = conn.prepare("INSERT INTO task_needed_tags (task_id, tag) VALUES (?1, ?2)")?;
1437
1438    let mut count = 0;
1439    for row in rows {
1440        let obj = row
1441            .as_object()
1442            .context("TaskNeededTag row must be an object")?;
1443
1444        stmt.execute(params![
1445            get_string(obj, "task_id")?,
1446            get_string(obj, "tag")?,
1447        ])?;
1448        count += 1;
1449    }
1450
1451    Ok(count)
1452}
1453
1454/// Import task_wanted_tags table.
1455fn import_task_wanted_tags(conn: &rusqlite::Connection, rows: &[Value]) -> Result<usize> {
1456    let mut stmt = conn.prepare("INSERT INTO task_wanted_tags (task_id, tag) VALUES (?1, ?2)")?;
1457
1458    let mut count = 0;
1459    for row in rows {
1460        let obj = row
1461            .as_object()
1462            .context("TaskWantedTag row must be an object")?;
1463
1464        stmt.execute(params![
1465            get_string(obj, "task_id")?,
1466            get_string(obj, "tag")?,
1467        ])?;
1468        count += 1;
1469    }
1470
1471    Ok(count)
1472}
1473
1474/// Import task_sequence table.
1475fn import_task_sequence(conn: &rusqlite::Connection, rows: &[Value]) -> Result<usize> {
1476    let mut stmt = conn.prepare(
1477        "INSERT INTO task_sequence (id, task_id, worker_id, status, phase, reason, timestamp, end_timestamp, concurrency)
1478         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
1479    )?;
1480
1481    let mut count = 0;
1482    for row in rows {
1483        let obj = row
1484            .as_object()
1485            .context("TaskSequenceEvent row must be an object")?;
1486
1487        stmt.execute(params![
1488            get_i64(obj, "id")?,
1489            get_string(obj, "task_id")?,
1490            get_opt_string(obj, "worker_id"),
1491            get_opt_string(obj, "status"),
1492            get_opt_string(obj, "phase"),
1493            get_opt_string(obj, "reason"),
1494            get_i64(obj, "timestamp")?,
1495            get_opt_i64(obj, "end_timestamp"),
1496            get_opt_i64(obj, "concurrency"),
1497        ])?;
1498        count += 1;
1499    }
1500
1501    Ok(count)
1502}
1503
1504// ============================================================================
1505// JSON value extraction helpers
1506// ============================================================================
1507
1508/// Get a required string value from a JSON object.
1509fn get_string(obj: &serde_json::Map<String, Value>, key: &str) -> Result<String> {
1510    obj.get(key)
1511        .and_then(|v| v.as_str())
1512        .map(|s| s.to_string())
1513        .ok_or_else(|| anyhow!("Missing or invalid string field: {}", key))
1514}
1515
1516/// Get an optional string value from a JSON object.
1517fn get_opt_string(obj: &serde_json::Map<String, Value>, key: &str) -> Option<String> {
1518    obj.get(key).and_then(|v| {
1519        if v.is_null() {
1520            None
1521        } else {
1522            v.as_str().map(|s| s.to_string())
1523        }
1524    })
1525}
1526
1527/// Get a required i64 value from a JSON object.
1528fn get_i64(obj: &serde_json::Map<String, Value>, key: &str) -> Result<i64> {
1529    obj.get(key)
1530        .and_then(|v| v.as_i64())
1531        .ok_or_else(|| anyhow!("Missing or invalid i64 field: {}", key))
1532}
1533
1534/// Get an optional i64 value from a JSON object.
1535fn get_opt_i64(obj: &serde_json::Map<String, Value>, key: &str) -> Option<i64> {
1536    obj.get(key)
1537        .and_then(|v| if v.is_null() { None } else { v.as_i64() })
1538}
1539
1540/// Get an i64 value with a default of 0.
1541fn get_i64_or_default(obj: &serde_json::Map<String, Value>, key: &str) -> i64 {
1542    get_opt_i64(obj, key).unwrap_or(0)
1543}
1544
1545/// Get a required i32 value from a JSON object.
1546fn get_i32(obj: &serde_json::Map<String, Value>, key: &str) -> Result<i32> {
1547    obj.get(key)
1548        .and_then(|v| v.as_i64())
1549        .map(|i| i as i32)
1550        .ok_or_else(|| anyhow!("Missing or invalid i32 field: {}", key))
1551}
1552
1553/// Get an optional i32 value from a JSON object.
1554#[allow(dead_code)]
1555fn get_opt_i32(obj: &serde_json::Map<String, Value>, key: &str) -> Option<i32> {
1556    obj.get(key).and_then(|v| {
1557        if v.is_null() {
1558            None
1559        } else {
1560            v.as_i64().map(|i| i as i32)
1561        }
1562    })
1563}
1564
1565/// Get an f64 value with a default of 0.0.
1566fn get_f64_or_default(obj: &serde_json::Map<String, Value>, key: &str) -> f64 {
1567    obj.get(key)
1568        .and_then(|v| if v.is_null() { None } else { v.as_f64() })
1569        .unwrap_or(0.0)
1570}
1571
1572#[cfg(test)]
1573mod tests {
1574    use super::*;
1575    use crate::config::IdsConfig;
1576    use crate::export::Snapshot;
1577    use serde_json::json;
1578
1579    #[test]
1580    fn test_import_empty_snapshot() {
1581        let db = Database::open_in_memory().unwrap();
1582        let snapshot = Snapshot::new();
1583        let options = ImportOptions::default();
1584
1585        let result = db.import_snapshot(&snapshot, &options).unwrap();
1586        assert_eq!(result.total_rows(), 0);
1587        assert!(result.fts_rebuilt);
1588    }
1589
1590    #[test]
1591    fn test_import_tasks() {
1592        let db = Database::open_in_memory().unwrap();
1593        let mut snapshot = Snapshot::new();
1594
1595        snapshot.tables.insert(
1596            "tasks".to_string(),
1597            vec![json!({
1598                "id": "task-1",
1599                "title": "Test Task",
1600                "description": "A test task",
1601                "status": "pending",
1602                "priority": "5",
1603                "worker_id": null,
1604                "claimed_at": null,
1605                "needed_tags": null,
1606                "wanted_tags": null,
1607                "tags": "[]",
1608                "points": null,
1609                "time_estimate_ms": null,
1610                "time_actual_ms": null,
1611                "started_at": null,
1612                "completed_at": null,
1613                "current_thought": null,
1614                "metric_0": 0,
1615                "metric_1": 0,
1616                "metric_2": 0,
1617                "metric_3": 0,
1618                "metric_4": 0,
1619                "metric_5": 0,
1620                "metric_6": 0,
1621                "metric_7": 0,
1622                "cost_usd": 0.0,
1623                "deleted_at": null,
1624                "deleted_by": null,
1625                "deleted_reason": null,
1626                "created_at": 1700000000000_i64,
1627                "updated_at": 1700000000000_i64
1628            })],
1629        );
1630
1631        let options = ImportOptions::default();
1632        let result = db.import_snapshot(&snapshot, &options).unwrap();
1633
1634        assert_eq!(result.rows_imported.get("tasks"), Some(&1));
1635        assert!(result.fts_rebuilt);
1636
1637        // Verify FTS was populated
1638        let results = db.search_tasks("Test", None, 0, false, None).unwrap();
1639        assert_eq!(results.len(), 1);
1640        assert_eq!(results[0].task_id, "task-1");
1641    }
1642
1643    #[test]
1644    fn test_import_with_dependencies() {
1645        let db = Database::open_in_memory().unwrap();
1646        let mut snapshot = Snapshot::new();
1647
1648        // Add tasks
1649        snapshot.tables.insert(
1650            "tasks".to_string(),
1651            vec![
1652                json!({
1653                    "id": "task-a",
1654                    "title": "Task A",
1655                    "description": null,
1656                    "status": "pending",
1657                    "priority": "5",
1658                    "worker_id": null,
1659                    "claimed_at": null,
1660                    "needed_tags": null,
1661                    "wanted_tags": null,
1662                    "tags": "[]",
1663                    "points": null,
1664                    "time_estimate_ms": null,
1665                    "time_actual_ms": null,
1666                    "started_at": null,
1667                    "completed_at": null,
1668                    "current_thought": null,
1669                    "metric_0": 0,
1670                    "metric_1": 0,
1671                    "metric_2": 0,
1672                    "metric_3": 0,
1673                    "metric_4": 0,
1674                    "metric_5": 0,
1675                    "metric_6": 0,
1676                    "metric_7": 0,
1677                    "cost_usd": 0.0,
1678                    "deleted_at": null,
1679                    "deleted_by": null,
1680                    "deleted_reason": null,
1681                    "created_at": 1700000000000_i64,
1682                    "updated_at": 1700000000000_i64
1683                }),
1684                json!({
1685                    "id": "task-b",
1686                    "title": "Task B",
1687                    "description": null,
1688                    "status": "pending",
1689                    "priority": "5",
1690                    "worker_id": null,
1691                    "claimed_at": null,
1692                    "needed_tags": null,
1693                    "wanted_tags": null,
1694                    "tags": "[]",
1695                    "points": null,
1696                    "time_estimate_ms": null,
1697                    "time_actual_ms": null,
1698                    "started_at": null,
1699                    "completed_at": null,
1700                    "current_thought": null,
1701                    "metric_0": 0,
1702                    "metric_1": 0,
1703                    "metric_2": 0,
1704                    "metric_3": 0,
1705                    "metric_4": 0,
1706                    "metric_5": 0,
1707                    "metric_6": 0,
1708                    "metric_7": 0,
1709                    "cost_usd": 0.0,
1710                    "deleted_at": null,
1711                    "deleted_by": null,
1712                    "deleted_reason": null,
1713                    "created_at": 1700000000000_i64,
1714                    "updated_at": 1700000000000_i64
1715                }),
1716            ],
1717        );
1718
1719        // Add dependency
1720        snapshot.tables.insert(
1721            "dependencies".to_string(),
1722            vec![json!({
1723                "from_task_id": "task-a",
1724                "to_task_id": "task-b",
1725                "dep_type": "blocks"
1726            })],
1727        );
1728
1729        let options = ImportOptions::default();
1730        let result = db.import_snapshot(&snapshot, &options).unwrap();
1731
1732        assert_eq!(result.rows_imported.get("tasks"), Some(&2));
1733        assert_eq!(result.rows_imported.get("dependencies"), Some(&1));
1734    }
1735
1736    #[test]
1737    fn test_import_fails_on_non_empty_database() {
1738        let db = Database::open_in_memory().unwrap();
1739
1740        // Create a task first
1741        use crate::config::StatesConfig;
1742        db.create_task(
1743            None,
1744            "Existing task".to_string(),
1745            None,
1746            None,
1747            None,
1748            None,
1749            None,
1750            None,
1751            None,
1752            None,
1753            None, // tags
1754            &StatesConfig::default(),
1755            &IdsConfig::default(),
1756        )
1757        .unwrap();
1758
1759        let snapshot = Snapshot::new();
1760        let options = ImportOptions::fresh(); // Explicitly use fresh mode
1761
1762        let result = db.import_snapshot(&snapshot, &options);
1763        assert!(result.is_err());
1764        assert!(result.unwrap_err().to_string().contains("not empty"));
1765    }
1766
1767    #[test]
1768    fn test_import_replace_mode() {
1769        let db = Database::open_in_memory().unwrap();
1770
1771        // Create existing task
1772        use crate::config::StatesConfig;
1773        let existing_id = db
1774            .create_task(
1775                None,
1776                "Existing task".to_string(),
1777                None,
1778                None,
1779                None,
1780                None,
1781                None,
1782                None,
1783                None,
1784                None,
1785                None, // tags
1786                &StatesConfig::default(),
1787                &IdsConfig::default(),
1788            )
1789            .unwrap();
1790
1791        // Verify task exists
1792        let task = db.get_task(&existing_id.id).unwrap();
1793        assert!(task.is_some());
1794        assert_eq!(task.unwrap().title, "Existing task");
1795
1796        // Create snapshot with different task
1797        let mut snapshot = Snapshot::new();
1798        snapshot.tables.insert(
1799            "tasks".to_string(),
1800            vec![json!({
1801                "id": "imported-task",
1802                "title": "Imported Task",
1803                "description": null,
1804                "status": "pending",
1805                "priority": "5",
1806                "worker_id": null,
1807                "claimed_at": null,
1808                "needed_tags": null,
1809                "wanted_tags": null,
1810                "tags": "[]",
1811                "points": null,
1812                "time_estimate_ms": null,
1813                "time_actual_ms": null,
1814                "started_at": null,
1815                "completed_at": null,
1816                "current_thought": null,
1817                "metric_0": 0,
1818                "metric_1": 0,
1819                "metric_2": 0,
1820                "metric_3": 0,
1821                "metric_4": 0,
1822                "metric_5": 0,
1823                "metric_6": 0,
1824                "metric_7": 0,
1825                "cost_usd": 0.0,
1826                "deleted_at": null,
1827                "deleted_by": null,
1828                "deleted_reason": null,
1829                "created_at": 1700000000000_i64,
1830                "updated_at": 1700000000000_i64
1831            })],
1832        );
1833
1834        // Import in replace mode
1835        let options = ImportOptions::replace();
1836        let result = db.import_snapshot(&snapshot, &options).unwrap();
1837
1838        // Verify old task was deleted and new task imported
1839        assert_eq!(result.rows_deleted.get("tasks"), Some(&1));
1840        assert_eq!(result.rows_imported.get("tasks"), Some(&1));
1841
1842        // Old task should be gone
1843        let old_task = db.get_task(&existing_id.id).unwrap();
1844        assert!(old_task.is_none());
1845
1846        // New task should exist
1847        let new_task = db.get_task("imported-task").unwrap();
1848        assert!(new_task.is_some());
1849        assert_eq!(new_task.unwrap().title, "Imported Task");
1850    }
1851
1852    #[test]
1853    fn test_replace_mode_preserves_workers() {
1854        let db = Database::open_in_memory().unwrap();
1855
1856        // Register a worker
1857        db.register_worker(
1858            Some("test-worker".to_string()),
1859            vec!["rust".to_string(), "test".to_string()],
1860            false,
1861            &IdsConfig::default(),
1862            None,
1863            vec![],
1864            Some(0),
1865        )
1866        .unwrap();
1867
1868        // Verify worker exists
1869        let workers = db.list_workers().unwrap();
1870        assert_eq!(workers.len(), 1);
1871        assert_eq!(workers[0].id, "test-worker");
1872
1873        // Create a task
1874        use crate::config::StatesConfig;
1875        db.create_task(
1876            None,
1877            "Task to replace".to_string(),
1878            None,
1879            None,
1880            None,
1881            None,
1882            None,
1883            None,
1884            None,
1885            None,
1886            None, // tags
1887            &StatesConfig::default(),
1888            &IdsConfig::default(),
1889        )
1890        .unwrap();
1891
1892        // Import empty snapshot in replace mode
1893        let snapshot = Snapshot::new();
1894        let options = ImportOptions::replace();
1895        let result = db.import_snapshot(&snapshot, &options).unwrap();
1896
1897        // Task should be deleted
1898        assert_eq!(result.rows_deleted.get("tasks"), Some(&1));
1899
1900        // Worker should still exist (preserved)
1901        let workers = db.list_workers().unwrap();
1902        assert_eq!(workers.len(), 1);
1903        assert_eq!(workers[0].id, "test-worker");
1904    }
1905
1906    #[test]
1907    fn test_clear_project_data() {
1908        let db = Database::open_in_memory().unwrap();
1909
1910        // Create tasks with dependencies and tags
1911        use crate::config::{DependenciesConfig, StatesConfig};
1912        let task_a = db
1913            .create_task(
1914                None,
1915                "Task A".to_string(),
1916                None,
1917                None,
1918                None, // phase
1919                None,
1920                None,
1921                None,
1922                None,
1923                None,
1924                Some(vec!["rust".to_string(), "test".to_string()]), // tags
1925                &StatesConfig::default(),
1926                &IdsConfig::default(),
1927            )
1928            .unwrap();
1929
1930        let task_b = db
1931            .create_task(
1932                None,
1933                "Task B".to_string(),
1934                None,
1935                None,
1936                None,
1937                None,
1938                None,
1939                None,
1940                None,
1941                None,
1942                None, // tags
1943                &StatesConfig::default(),
1944                &IdsConfig::default(),
1945            )
1946            .unwrap();
1947
1948        // Add dependency
1949        db.add_dependency(
1950            &task_a.id,
1951            &task_b.id,
1952            "blocks",
1953            &DependenciesConfig::default(),
1954        )
1955        .unwrap();
1956
1957        // Clear all project data
1958        let deleted = db.clear_project_data().unwrap();
1959
1960        // Verify counts
1961        assert_eq!(deleted.get("tasks"), Some(&2));
1962        assert_eq!(deleted.get("dependencies"), Some(&1));
1963        assert_eq!(deleted.get("task_tags"), Some(&2));
1964
1965        // Verify tables are empty
1966        db.with_conn(|conn| {
1967            for table in IMPORT_ORDER {
1968                let count: i64 =
1969                    conn.query_row(&format!("SELECT COUNT(*) FROM {}", table), [], |row| {
1970                        row.get(0)
1971                    })?;
1972                assert_eq!(count, 0, "Table {} should be empty", table);
1973            }
1974            Ok(())
1975        })
1976        .unwrap();
1977    }
1978
1979    #[test]
1980    fn test_import_schema_version_mismatch() {
1981        let db = Database::open_in_memory().unwrap();
1982        let mut snapshot = Snapshot::new();
1983        snapshot.schema_version = 999; // Invalid version
1984
1985        let options = ImportOptions::default();
1986        let result = db.import_snapshot(&snapshot, &options);
1987
1988        assert!(result.is_err());
1989        assert!(
1990            result
1991                .unwrap_err()
1992                .to_string()
1993                .contains("Schema version mismatch")
1994        );
1995    }
1996
1997    #[test]
1998    fn test_import_with_attachments() {
1999        let db = Database::open_in_memory().unwrap();
2000        let mut snapshot = Snapshot::new();
2001
2002        // Add task
2003        snapshot.tables.insert(
2004            "tasks".to_string(),
2005            vec![json!({
2006                "id": "task-1",
2007                "title": "Task with attachment",
2008                "description": null,
2009                "status": "pending",
2010                "priority": "5",
2011                "worker_id": null,
2012                "claimed_at": null,
2013                "needed_tags": null,
2014                "wanted_tags": null,
2015                "tags": "[]",
2016                "points": null,
2017                "time_estimate_ms": null,
2018                "time_actual_ms": null,
2019                "started_at": null,
2020                "completed_at": null,
2021                "current_thought": null,
2022                "metric_0": 0,
2023                "metric_1": 0,
2024                "metric_2": 0,
2025                "metric_3": 0,
2026                "metric_4": 0,
2027                "metric_5": 0,
2028                "metric_6": 0,
2029                "metric_7": 0,
2030                "cost_usd": 0.0,
2031                "deleted_at": null,
2032                "deleted_by": null,
2033                "deleted_reason": null,
2034                "created_at": 1700000000000_i64,
2035                "updated_at": 1700000000000_i64
2036            })],
2037        );
2038
2039        // Add attachment
2040        snapshot.tables.insert(
2041            "attachments".to_string(),
2042            vec![json!({
2043                "task_id": "task-1",
2044                "attachment_type": "notes",
2045                "sequence": 0,
2046                "name": "",
2047                "mime_type": "text/plain",
2048                "content": "Some searchable notes content",
2049                "file_path": null,
2050                "created_at": 1700000000000_i64
2051            })],
2052        );
2053
2054        let options = ImportOptions::default();
2055        let result = db.import_snapshot(&snapshot, &options).unwrap();
2056
2057        assert_eq!(result.rows_imported.get("tasks"), Some(&1));
2058        assert_eq!(result.rows_imported.get("attachments"), Some(&1));
2059
2060        // Verify attachment FTS was populated
2061        let results = db.search_tasks("searchable", None, 0, true, None).unwrap();
2062        assert_eq!(results.len(), 1);
2063        assert_eq!(results[0].attachment_matches.len(), 1);
2064    }
2065
2066    #[test]
2067    fn test_import_with_tags() {
2068        let db = Database::open_in_memory().unwrap();
2069        let mut snapshot = Snapshot::new();
2070
2071        // Add task
2072        snapshot.tables.insert(
2073            "tasks".to_string(),
2074            vec![json!({
2075                "id": "task-1",
2076                "title": "Tagged Task",
2077                "description": null,
2078                "status": "pending",
2079                "priority": "5",
2080                "worker_id": null,
2081                "claimed_at": null,
2082                "needed_tags": null,
2083                "wanted_tags": null,
2084                "tags": "[]",
2085                "points": null,
2086                "time_estimate_ms": null,
2087                "time_actual_ms": null,
2088                "started_at": null,
2089                "completed_at": null,
2090                "current_thought": null,
2091                "metric_0": 0,
2092                "metric_1": 0,
2093                "metric_2": 0,
2094                "metric_3": 0,
2095                "metric_4": 0,
2096                "metric_5": 0,
2097                "metric_6": 0,
2098                "metric_7": 0,
2099                "cost_usd": 0.0,
2100                "deleted_at": null,
2101                "deleted_by": null,
2102                "deleted_reason": null,
2103                "created_at": 1700000000000_i64,
2104                "updated_at": 1700000000000_i64
2105            })],
2106        );
2107
2108        // Add tags
2109        snapshot.tables.insert(
2110            "task_tags".to_string(),
2111            vec![
2112                json!({"task_id": "task-1", "tag": "rust"}),
2113                json!({"task_id": "task-1", "tag": "backend"}),
2114            ],
2115        );
2116
2117        snapshot.tables.insert(
2118            "task_needed_tags".to_string(),
2119            vec![json!({"task_id": "task-1", "tag": "senior"})],
2120        );
2121
2122        snapshot.tables.insert(
2123            "task_wanted_tags".to_string(),
2124            vec![json!({"task_id": "task-1", "tag": "rust-expert"})],
2125        );
2126
2127        let options = ImportOptions::default();
2128        let result = db.import_snapshot(&snapshot, &options).unwrap();
2129
2130        assert_eq!(result.rows_imported.get("task_tags"), Some(&2));
2131        assert_eq!(result.rows_imported.get("task_needed_tags"), Some(&1));
2132        assert_eq!(result.rows_imported.get("task_wanted_tags"), Some(&1));
2133    }
2134
2135    #[test]
2136    fn test_import_task_sequence() {
2137        let db = Database::open_in_memory().unwrap();
2138        let mut snapshot = Snapshot::new();
2139
2140        // Add task
2141        snapshot.tables.insert(
2142            "tasks".to_string(),
2143            vec![json!({
2144                "id": "task-1",
2145                "title": "Task with history",
2146                "description": null,
2147                "status": "completed",
2148                "priority": "5",
2149                "worker_id": null,
2150                "claimed_at": null,
2151                "needed_tags": null,
2152                "wanted_tags": null,
2153                "tags": "[]",
2154                "points": null,
2155                "time_estimate_ms": null,
2156                "time_actual_ms": null,
2157                "started_at": null,
2158                "completed_at": 1700000001000_i64,
2159                "current_thought": null,
2160                "metric_0": 0,
2161                "metric_1": 0,
2162                "metric_2": 0,
2163                "metric_3": 0,
2164                "metric_4": 0,
2165                "metric_5": 0,
2166                "metric_6": 0,
2167                "metric_7": 0,
2168                "cost_usd": 0.0,
2169                "deleted_at": null,
2170                "deleted_by": null,
2171                "deleted_reason": null,
2172                "created_at": 1700000000000_i64,
2173                "updated_at": 1700000001000_i64
2174            })],
2175        );
2176
2177        // Add state history
2178        snapshot.tables.insert(
2179            "task_sequence".to_string(),
2180            vec![
2181                json!({
2182                    "id": 1,
2183                    "task_id": "task-1",
2184                    "worker_id": null,
2185                    "event": "pending",
2186                    "reason": "Task created",
2187                    "timestamp": 1700000000000_i64,
2188                    "end_timestamp": 1700000000500_i64
2189                }),
2190                json!({
2191                    "id": 2,
2192                    "task_id": "task-1",
2193                    "worker_id": "worker-1",
2194                    "event": "working",
2195                    "reason": "Started work",
2196                    "timestamp": 1700000000500_i64,
2197                    "end_timestamp": 1700000001000_i64
2198                }),
2199                json!({
2200                    "id": 3,
2201                    "task_id": "task-1",
2202                    "worker_id": "worker-1",
2203                    "event": "completed",
2204                    "reason": "Done",
2205                    "timestamp": 1700000001000_i64,
2206                    "end_timestamp": null
2207                }),
2208            ],
2209        );
2210
2211        let options = ImportOptions::default();
2212        let result = db.import_snapshot(&snapshot, &options).unwrap();
2213
2214        assert_eq!(result.rows_imported.get("task_sequence"), Some(&3));
2215    }
2216
2217    #[test]
2218    fn test_rebuild_fts_indexes() {
2219        let db = Database::open_in_memory().unwrap();
2220
2221        // First, insert a task normally (trigger will fire)
2222        db.with_conn(|conn| {
2223            conn.execute(
2224                "INSERT INTO tasks (id, title, description, status, priority, created_at, updated_at)
2225                 VALUES ('test-task', 'Manual Insert Test', 'Bypass trigger', 'pending', '5', 1700000000000, 1700000000000)",
2226                [],
2227            )?;
2228            Ok(())
2229        }).unwrap();
2230
2231        // FTS should have the task due to triggers
2232        let results = db.search_tasks("Manual", None, 0, false, None).unwrap();
2233        assert_eq!(results.len(), 1);
2234
2235        // Now delete from FTS to simulate a corrupted/empty FTS state
2236        db.with_conn(|conn| {
2237            conn.execute("DELETE FROM tasks_fts", [])?;
2238            Ok(())
2239        })
2240        .unwrap();
2241
2242        // Search should now find nothing
2243        let results = db.search_tasks("Manual", None, 0, false, None).unwrap();
2244        assert!(results.is_empty());
2245
2246        // Rebuild FTS
2247        db.rebuild_fts_indexes().unwrap();
2248
2249        // Now search should work again
2250        let results = db.search_tasks("Manual", None, 0, false, None).unwrap();
2251        assert_eq!(results.len(), 1);
2252        assert_eq!(results[0].task_id, "test-task");
2253    }
2254
2255    #[test]
2256    fn test_import_mode_default() {
2257        // Default mode should be Fresh
2258        let options = ImportOptions::default();
2259        assert_eq!(options.mode, ImportMode::Fresh);
2260    }
2261
2262    #[test]
2263    fn test_import_result_total_deleted() {
2264        let mut result = ImportResult::new();
2265        result.rows_deleted.insert("tasks".to_string(), 5);
2266        result.rows_deleted.insert("dependencies".to_string(), 3);
2267        assert_eq!(result.total_deleted(), 8);
2268    }
2269
2270    #[test]
2271    fn test_import_result_total_skipped() {
2272        let mut result = ImportResult::new();
2273        result.rows_skipped.insert("tasks".to_string(), 3);
2274        result.rows_skipped.insert("dependencies".to_string(), 2);
2275        assert_eq!(result.total_skipped(), 5);
2276    }
2277
2278    #[test]
2279    fn test_merge_mode_skips_existing_tasks() {
2280        let db = Database::open_in_memory().unwrap();
2281
2282        // Create existing task with specific ID
2283        use crate::config::StatesConfig;
2284        db.create_task(
2285            Some("existing-task".to_string()),
2286            "Existing task".to_string(),
2287            None,
2288            None,
2289            None, // phase
2290            None,
2291            None,
2292            None,
2293            None,
2294            None,
2295            None, // tags
2296            &StatesConfig::default(),
2297            &IdsConfig::default(),
2298        )
2299        .unwrap();
2300
2301        // Create snapshot with same ID task and a new task
2302        let mut snapshot = Snapshot::new();
2303        snapshot.tables.insert(
2304            "tasks".to_string(),
2305            vec![
2306                json!({
2307                    "id": "existing-task", // This should be skipped
2308                    "title": "Should Be Skipped",
2309                    "description": null,
2310                    "status": "pending",
2311                    "priority": "5",
2312                    "worker_id": null,
2313                    "claimed_at": null,
2314                    "needed_tags": null,
2315                    "wanted_tags": null,
2316                    "tags": "[]",
2317                    "points": null,
2318                    "time_estimate_ms": null,
2319                    "time_actual_ms": null,
2320                    "started_at": null,
2321                    "completed_at": null,
2322                    "current_thought": null,
2323                    "metric_0": 0,
2324                    "metric_1": 0,
2325                    "metric_2": 0,
2326                    "metric_3": 0,
2327                    "metric_4": 0,
2328                    "metric_5": 0,
2329                    "metric_6": 0,
2330                    "metric_7": 0,
2331                    "cost_usd": 0.0,
2332                    "deleted_at": null,
2333                    "deleted_by": null,
2334                    "deleted_reason": null,
2335                    "created_at": 1700000000000_i64,
2336                    "updated_at": 1700000000000_i64
2337                }),
2338                json!({
2339                    "id": "new-task", // This should be imported
2340                    "title": "New Task",
2341                    "description": null,
2342                    "status": "pending",
2343                    "priority": "5",
2344                    "worker_id": null,
2345                    "claimed_at": null,
2346                    "needed_tags": null,
2347                    "wanted_tags": null,
2348                    "tags": "[]",
2349                    "points": null,
2350                    "time_estimate_ms": null,
2351                    "time_actual_ms": null,
2352                    "started_at": null,
2353                    "completed_at": null,
2354                    "current_thought": null,
2355                    "metric_0": 0,
2356                    "metric_1": 0,
2357                    "metric_2": 0,
2358                    "metric_3": 0,
2359                    "metric_4": 0,
2360                    "metric_5": 0,
2361                    "metric_6": 0,
2362                    "metric_7": 0,
2363                    "cost_usd": 0.0,
2364                    "deleted_at": null,
2365                    "deleted_by": null,
2366                    "deleted_reason": null,
2367                    "created_at": 1700000000000_i64,
2368                    "updated_at": 1700000000000_i64
2369                }),
2370            ],
2371        );
2372
2373        // Import in merge mode
2374        let options = ImportOptions::merge();
2375        let result = db.import_snapshot(&snapshot, &options).unwrap();
2376
2377        // 1 imported (new-task), 1 skipped (existing-task)
2378        assert_eq!(result.rows_imported.get("tasks"), Some(&1));
2379        assert_eq!(result.rows_skipped.get("tasks"), Some(&1));
2380
2381        // Existing task should still have original title
2382        let existing = db.get_task("existing-task").unwrap().unwrap();
2383        assert_eq!(existing.title, "Existing task");
2384
2385        // New task should be imported
2386        let new_task = db.get_task("new-task").unwrap();
2387        assert!(new_task.is_some());
2388        assert_eq!(new_task.unwrap().title, "New Task");
2389    }
2390
2391    #[test]
2392    fn test_merge_mode_skips_existing_dependencies() {
2393        let db = Database::open_in_memory().unwrap();
2394
2395        // Create tasks and dependency
2396        use crate::config::{DependenciesConfig, StatesConfig};
2397        db.create_task(
2398            Some("task-a".to_string()),
2399            "Task A".to_string(),
2400            None,
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("task-b".to_string()),
2415            "Task B".to_string(),
2416            None,
2417            None,
2418            None,
2419            None,
2420            None,
2421            None,
2422            None,
2423            None,
2424            None, // tags
2425            &StatesConfig::default(),
2426            &IdsConfig::default(),
2427        )
2428        .unwrap();
2429        db.create_task(
2430            Some("task-c".to_string()),
2431            "Task C".to_string(),
2432            None,
2433            None,
2434            None,
2435            None,
2436            None,
2437            None,
2438            None,
2439            None,
2440            None, // tags
2441            &StatesConfig::default(),
2442            &IdsConfig::default(),
2443        )
2444        .unwrap();
2445        db.add_dependency("task-a", "task-b", "blocks", &DependenciesConfig::default())
2446            .unwrap();
2447
2448        // Create snapshot with existing and new dependencies
2449        let mut snapshot = Snapshot::new();
2450        snapshot.tables.insert(
2451            "dependencies".to_string(),
2452            vec![
2453                json!({
2454                    "from_task_id": "task-a",
2455                    "to_task_id": "task-b",
2456                    "dep_type": "blocks" // Existing - should be skipped
2457                }),
2458                json!({
2459                    "from_task_id": "task-b",
2460                    "to_task_id": "task-c",
2461                    "dep_type": "blocks" // New - should be imported
2462                }),
2463            ],
2464        );
2465
2466        // Import in merge mode
2467        let options = ImportOptions::merge();
2468        let result = db.import_snapshot(&snapshot, &options).unwrap();
2469
2470        // 1 imported (b->c), 1 skipped (a->b)
2471        assert_eq!(result.rows_imported.get("dependencies"), Some(&1));
2472        assert_eq!(result.rows_skipped.get("dependencies"), Some(&1));
2473    }
2474
2475    #[test]
2476    fn test_merge_mode_skips_state_sequence() {
2477        let db = Database::open_in_memory().unwrap();
2478
2479        // Create a task (will have initial state history)
2480        use crate::config::StatesConfig;
2481        db.create_task(
2482            Some("task-1".to_string()),
2483            "Task 1".to_string(),
2484            None,
2485            None,
2486            None,
2487            None,
2488            None,
2489            None,
2490            None,
2491            None,
2492            None, // tags
2493            &StatesConfig::default(),
2494            &IdsConfig::default(),
2495        )
2496        .unwrap();
2497
2498        // Snapshot with state history
2499        let mut snapshot = Snapshot::new();
2500        snapshot.tables.insert(
2501            "task_sequence".to_string(),
2502            vec![json!({
2503                "id": 999,
2504                "task_id": "task-1",
2505                "worker_id": null,
2506                "event": "pending",
2507                "reason": "Imported history",
2508                "timestamp": 1700000000000_i64,
2509                "end_timestamp": null
2510            })],
2511        );
2512
2513        // Import in merge mode
2514        let options = ImportOptions::merge();
2515        let result = db.import_snapshot(&snapshot, &options).unwrap();
2516
2517        // State sequence should be all skipped in merge mode
2518        assert_eq!(result.rows_imported.get("task_sequence"), Some(&0));
2519        assert_eq!(result.rows_skipped.get("task_sequence"), Some(&1));
2520    }
2521
2522    #[test]
2523    fn test_merge_mode_adds_new_tags() {
2524        let db = Database::open_in_memory().unwrap();
2525
2526        // Create task with tags
2527        use crate::config::StatesConfig;
2528        db.create_task(
2529            Some("task-1".to_string()),
2530            "Task 1".to_string(),
2531            None,
2532            None,
2533            None,
2534            None,
2535            None,
2536            None,
2537            None,
2538            None,
2539            Some(vec!["existing-tag".to_string()]), // tags
2540            &StatesConfig::default(),
2541            &IdsConfig::default(),
2542        )
2543        .unwrap();
2544
2545        // Snapshot with existing and new tags
2546        let mut snapshot = Snapshot::new();
2547        snapshot.tables.insert(
2548            "task_tags".to_string(),
2549            vec![
2550                json!({"task_id": "task-1", "tag": "existing-tag"}), // Existing - skip
2551                json!({"task_id": "task-1", "tag": "new-tag"}),      // New - import
2552            ],
2553        );
2554
2555        // Import in merge mode
2556        let options = ImportOptions::merge();
2557        let result = db.import_snapshot(&snapshot, &options).unwrap();
2558
2559        // 1 imported (new-tag), 1 skipped (existing-tag)
2560        assert_eq!(result.rows_imported.get("task_tags"), Some(&1));
2561        assert_eq!(result.rows_skipped.get("task_tags"), Some(&1));
2562    }
2563
2564    #[test]
2565    fn test_import_options_merge() {
2566        let options = ImportOptions::merge();
2567        assert_eq!(options.mode, ImportMode::Merge);
2568    }
2569
2570    // ============================================================================
2571    // Dry-run (preview_import) tests
2572    // ============================================================================
2573
2574    #[test]
2575    fn test_preview_fresh_mode_empty_db() {
2576        let db = Database::open_in_memory().unwrap();
2577        let mut snapshot = Snapshot::new();
2578
2579        // Add a task to the snapshot
2580        snapshot.tables.insert(
2581            "tasks".to_string(),
2582            vec![json!({
2583                "id": "task-1",
2584                "title": "Test Task",
2585                "description": null,
2586                "status": "pending",
2587                "priority": "5",
2588                "worker_id": null,
2589                "claimed_at": null,
2590                "needed_tags": null,
2591                "wanted_tags": null,
2592                "tags": "[]",
2593                "points": null,
2594                "time_estimate_ms": null,
2595                "time_actual_ms": null,
2596                "started_at": null,
2597                "completed_at": null,
2598                "current_thought": null,
2599                "metric_0": 0,
2600                "metric_1": 0,
2601                "metric_2": 0,
2602                "metric_3": 0,
2603                "metric_4": 0,
2604                "metric_5": 0,
2605                "metric_6": 0,
2606                "metric_7": 0,
2607                "cost_usd": 0.0,
2608                "deleted_at": null,
2609                "deleted_by": null,
2610                "deleted_reason": null,
2611                "created_at": 1700000000000_i64,
2612                "updated_at": 1700000000000_i64
2613            })],
2614        );
2615
2616        let options = ImportOptions::fresh();
2617        let preview = db.preview_import(&snapshot, &options);
2618
2619        assert!(preview.would_succeed);
2620        assert!(preview.database_is_empty);
2621        assert_eq!(preview.mode, ImportMode::Fresh);
2622        assert_eq!(preview.total_would_insert(), 1);
2623        assert_eq!(preview.total_would_delete(), 0);
2624        assert_eq!(preview.total_would_skip(), 0);
2625    }
2626
2627    #[test]
2628    fn test_preview_fresh_mode_non_empty_db() {
2629        let db = Database::open_in_memory().unwrap();
2630
2631        // Create existing task
2632        use crate::config::StatesConfig;
2633        db.create_task(
2634            None,
2635            "Existing task".to_string(),
2636            None,
2637            None,
2638            None,
2639            None,
2640            None,
2641            None,
2642            None,
2643            None,
2644            None, // tags
2645            &StatesConfig::default(),
2646            &IdsConfig::default(),
2647        )
2648        .unwrap();
2649
2650        let snapshot = Snapshot::new();
2651        let options = ImportOptions::fresh();
2652        let preview = db.preview_import(&snapshot, &options);
2653
2654        // Should fail because database is not empty
2655        assert!(!preview.would_succeed);
2656        assert!(!preview.database_is_empty);
2657        assert!(preview.failure_reason.is_some());
2658        assert!(preview.failure_reason.unwrap().contains("not empty"));
2659    }
2660
2661    #[test]
2662    fn test_preview_replace_mode() {
2663        let db = Database::open_in_memory().unwrap();
2664
2665        // Create existing tasks
2666        use crate::config::StatesConfig;
2667        db.create_task(
2668            Some("existing-1".to_string()),
2669            "Existing 1".to_string(),
2670            None,
2671            None,
2672            None,
2673            None,
2674            None,
2675            None,
2676            None,
2677            None,
2678            None, // tags
2679            &StatesConfig::default(),
2680            &IdsConfig::default(),
2681        )
2682        .unwrap();
2683        db.create_task(
2684            Some("existing-2".to_string()),
2685            "Existing 2".to_string(),
2686            None,
2687            None,
2688            None,
2689            None,
2690            None,
2691            None,
2692            None,
2693            None,
2694            None, // tags
2695            &StatesConfig::default(),
2696            &IdsConfig::default(),
2697        )
2698        .unwrap();
2699
2700        // Create snapshot with different task
2701        let mut snapshot = Snapshot::new();
2702        snapshot.tables.insert(
2703            "tasks".to_string(),
2704            vec![json!({
2705                "id": "new-task",
2706                "title": "New Task",
2707                "description": null,
2708                "status": "pending",
2709                "priority": "5",
2710                "worker_id": null,
2711                "claimed_at": null,
2712                "needed_tags": null,
2713                "wanted_tags": null,
2714                "tags": "[]",
2715                "points": null,
2716                "time_estimate_ms": null,
2717                "time_actual_ms": null,
2718                "started_at": null,
2719                "completed_at": null,
2720                "current_thought": null,
2721                "metric_0": 0,
2722                "metric_1": 0,
2723                "metric_2": 0,
2724                "metric_3": 0,
2725                "metric_4": 0,
2726                "metric_5": 0,
2727                "metric_6": 0,
2728                "metric_7": 0,
2729                "cost_usd": 0.0,
2730                "deleted_at": null,
2731                "deleted_by": null,
2732                "deleted_reason": null,
2733                "created_at": 1700000000000_i64,
2734                "updated_at": 1700000000000_i64
2735            })],
2736        );
2737
2738        let options = ImportOptions::replace();
2739        let preview = db.preview_import(&snapshot, &options);
2740
2741        assert!(preview.would_succeed);
2742        assert!(!preview.database_is_empty);
2743        assert_eq!(preview.mode, ImportMode::Replace);
2744        // Would delete 2 existing tasks
2745        assert_eq!(preview.would_delete.get("tasks"), Some(&2));
2746        // Would insert 1 new task
2747        assert_eq!(preview.would_insert.get("tasks"), Some(&1));
2748        assert_eq!(preview.total_would_skip(), 0);
2749    }
2750
2751    #[test]
2752    fn test_preview_merge_mode() {
2753        let db = Database::open_in_memory().unwrap();
2754
2755        // Create existing task
2756        use crate::config::StatesConfig;
2757        db.create_task(
2758            Some("existing-task".to_string()),
2759            "Existing Task".to_string(),
2760            None,
2761            None,
2762            None,
2763            None,
2764            None,
2765            None,
2766            None,
2767            None,
2768            None, // tags
2769            &StatesConfig::default(),
2770            &IdsConfig::default(),
2771        )
2772        .unwrap();
2773
2774        // Create snapshot with existing and new tasks
2775        let mut snapshot = Snapshot::new();
2776        snapshot.tables.insert(
2777            "tasks".to_string(),
2778            vec![
2779                json!({
2780                    "id": "existing-task", // Will be skipped
2781                    "title": "Should Skip",
2782                    "description": null,
2783                    "status": "pending",
2784                    "priority": "5",
2785                    "worker_id": null,
2786                    "claimed_at": null,
2787                    "needed_tags": null,
2788                    "wanted_tags": null,
2789                    "tags": "[]",
2790                    "points": null,
2791                    "time_estimate_ms": null,
2792                    "time_actual_ms": null,
2793                    "started_at": null,
2794                    "completed_at": null,
2795                    "current_thought": null,
2796                    "metric_0": 0,
2797                    "metric_1": 0,
2798                    "metric_2": 0,
2799                    "metric_3": 0,
2800                    "metric_4": 0,
2801                    "metric_5": 0,
2802                    "metric_6": 0,
2803                    "metric_7": 0,
2804                    "cost_usd": 0.0,
2805                    "deleted_at": null,
2806                    "deleted_by": null,
2807                    "deleted_reason": null,
2808                    "created_at": 1700000000000_i64,
2809                    "updated_at": 1700000000000_i64
2810                }),
2811                json!({
2812                    "id": "new-task", // Will be inserted
2813                    "title": "New Task",
2814                    "description": null,
2815                    "status": "pending",
2816                    "priority": "5",
2817                    "worker_id": null,
2818                    "claimed_at": null,
2819                    "needed_tags": null,
2820                    "wanted_tags": null,
2821                    "tags": "[]",
2822                    "points": null,
2823                    "time_estimate_ms": null,
2824                    "time_actual_ms": null,
2825                    "started_at": null,
2826                    "completed_at": null,
2827                    "current_thought": null,
2828                    "metric_0": 0,
2829                    "metric_1": 0,
2830                    "metric_2": 0,
2831                    "metric_3": 0,
2832                    "metric_4": 0,
2833                    "metric_5": 0,
2834                    "metric_6": 0,
2835                    "metric_7": 0,
2836                    "cost_usd": 0.0,
2837                    "deleted_at": null,
2838                    "deleted_by": null,
2839                    "deleted_reason": null,
2840                    "created_at": 1700000000000_i64,
2841                    "updated_at": 1700000000000_i64
2842                }),
2843            ],
2844        );
2845
2846        let options = ImportOptions::merge();
2847        let preview = db.preview_import(&snapshot, &options);
2848
2849        assert!(preview.would_succeed);
2850        assert!(!preview.database_is_empty);
2851        assert_eq!(preview.mode, ImportMode::Merge);
2852        // Would skip 1 existing task
2853        assert_eq!(preview.would_skip.get("tasks"), Some(&1));
2854        // Would insert 1 new task
2855        assert_eq!(preview.would_insert.get("tasks"), Some(&1));
2856        // No deletions in merge mode
2857        assert_eq!(preview.total_would_delete(), 0);
2858    }
2859
2860    #[test]
2861    fn test_preview_schema_version_mismatch() {
2862        let db = Database::open_in_memory().unwrap();
2863        let mut snapshot = Snapshot::new();
2864        snapshot.schema_version = 999; // Invalid version
2865
2866        let options = ImportOptions::fresh();
2867        let preview = db.preview_import(&snapshot, &options);
2868
2869        assert!(!preview.would_succeed);
2870        assert!(preview.failure_reason.is_some());
2871        assert!(
2872            preview
2873                .failure_reason
2874                .unwrap()
2875                .contains("Schema version mismatch")
2876        );
2877    }
2878
2879    #[test]
2880    fn test_dry_run_result_totals() {
2881        let mut result = DryRunResult::new(ImportMode::Replace);
2882        result.existing_rows.insert("tasks".to_string(), 5);
2883        result.existing_rows.insert("dependencies".to_string(), 3);
2884        result.would_delete.insert("tasks".to_string(), 5);
2885        result.would_delete.insert("dependencies".to_string(), 3);
2886        result.would_insert.insert("tasks".to_string(), 2);
2887        result.would_skip.insert("attachments".to_string(), 1);
2888
2889        assert_eq!(result.total_existing(), 8);
2890        assert_eq!(result.total_would_delete(), 8);
2891        assert_eq!(result.total_would_insert(), 2);
2892        assert_eq!(result.total_would_skip(), 1);
2893    }
2894
2895    // ============================================================================
2896    // ID remapping tests
2897    // ============================================================================
2898
2899    #[test]
2900    fn test_remap_snapshot_generates_new_ids() {
2901        let mut snapshot = Snapshot::new();
2902        snapshot.tables.insert(
2903            "tasks".to_string(),
2904            vec![
2905                json!({
2906                    "id": "old-task-1",
2907                    "title": "Task 1",
2908                    "description": null,
2909                    "status": "pending",
2910                    "priority": "5",
2911                    "worker_id": null,
2912                    "claimed_at": null,
2913                    "needed_tags": null,
2914                    "wanted_tags": null,
2915                    "tags": "[]",
2916                    "points": null,
2917                    "time_estimate_ms": null,
2918                    "time_actual_ms": null,
2919                    "started_at": null,
2920                    "completed_at": null,
2921                    "current_thought": null,
2922                    "metric_0": 0, "metric_1": 0, "metric_2": 0, "metric_3": 0,
2923                    "metric_4": 0, "metric_5": 0, "metric_6": 0, "metric_7": 0,
2924                    "cost_usd": 0.0,
2925                    "deleted_at": null, "deleted_by": null, "deleted_reason": null,
2926                    "created_at": 1700000000000_i64,
2927                    "updated_at": 1700000000000_i64
2928                }),
2929                json!({
2930                    "id": "old-task-2",
2931                    "title": "Task 2",
2932                    "description": null,
2933                    "status": "pending",
2934                    "priority": "5",
2935                    "worker_id": null,
2936                    "claimed_at": null,
2937                    "needed_tags": null,
2938                    "wanted_tags": null,
2939                    "tags": "[]",
2940                    "points": null,
2941                    "time_estimate_ms": null,
2942                    "time_actual_ms": null,
2943                    "started_at": null,
2944                    "completed_at": null,
2945                    "current_thought": null,
2946                    "metric_0": 0, "metric_1": 0, "metric_2": 0, "metric_3": 0,
2947                    "metric_4": 0, "metric_5": 0, "metric_6": 0, "metric_7": 0,
2948                    "cost_usd": 0.0,
2949                    "deleted_at": null, "deleted_by": null, "deleted_reason": null,
2950                    "created_at": 1700000000000_i64,
2951                    "updated_at": 1700000000000_i64
2952                }),
2953            ],
2954        );
2955
2956        let ids_config = IdsConfig::default();
2957        let (remapped, id_map) = remap_snapshot(&snapshot, &ids_config).unwrap();
2958
2959        // Should have 2 entries in the mapping
2960        assert_eq!(id_map.len(), 2);
2961        assert!(id_map.contains_key("old-task-1"));
2962        assert!(id_map.contains_key("old-task-2"));
2963
2964        // New IDs should be different from old IDs
2965        assert_ne!(id_map["old-task-1"], "old-task-1");
2966        assert_ne!(id_map["old-task-2"], "old-task-2");
2967
2968        // New IDs should be unique
2969        assert_ne!(id_map["old-task-1"], id_map["old-task-2"]);
2970
2971        // Remapped snapshot tasks should have the new IDs
2972        let tasks = remapped.tables.get("tasks").unwrap();
2973        let task1_id = tasks[0].get("id").unwrap().as_str().unwrap();
2974        let task2_id = tasks[1].get("id").unwrap().as_str().unwrap();
2975        assert_eq!(task1_id, id_map["old-task-1"]);
2976        assert_eq!(task2_id, id_map["old-task-2"]);
2977    }
2978
2979    #[test]
2980    fn test_remap_snapshot_remaps_dependencies() {
2981        let mut snapshot = Snapshot::new();
2982        snapshot.tables.insert(
2983            "tasks".to_string(),
2984            vec![
2985                json!({
2986                    "id": "parent",
2987                    "title": "Parent",
2988                    "description": null,
2989                    "status": "pending",
2990                    "priority": "5",
2991                    "worker_id": null,
2992                    "claimed_at": null,
2993                    "needed_tags": null,
2994                    "wanted_tags": null,
2995                    "tags": "[]",
2996                    "points": null,
2997                    "time_estimate_ms": null,
2998                    "time_actual_ms": null,
2999                    "started_at": null,
3000                    "completed_at": null,
3001                    "current_thought": null,
3002                    "metric_0": 0, "metric_1": 0, "metric_2": 0, "metric_3": 0,
3003                    "metric_4": 0, "metric_5": 0, "metric_6": 0, "metric_7": 0,
3004                    "cost_usd": 0.0,
3005                    "deleted_at": null, "deleted_by": null, "deleted_reason": null,
3006                    "created_at": 1700000000000_i64,
3007                    "updated_at": 1700000000000_i64
3008                }),
3009                json!({
3010                    "id": "child",
3011                    "title": "Child",
3012                    "description": null,
3013                    "status": "pending",
3014                    "priority": "5",
3015                    "worker_id": null,
3016                    "claimed_at": null,
3017                    "needed_tags": null,
3018                    "wanted_tags": null,
3019                    "tags": "[]",
3020                    "points": null,
3021                    "time_estimate_ms": null,
3022                    "time_actual_ms": null,
3023                    "started_at": null,
3024                    "completed_at": null,
3025                    "current_thought": null,
3026                    "metric_0": 0, "metric_1": 0, "metric_2": 0, "metric_3": 0,
3027                    "metric_4": 0, "metric_5": 0, "metric_6": 0, "metric_7": 0,
3028                    "cost_usd": 0.0,
3029                    "deleted_at": null, "deleted_by": null, "deleted_reason": null,
3030                    "created_at": 1700000000000_i64,
3031                    "updated_at": 1700000000000_i64
3032                }),
3033            ],
3034        );
3035
3036        // Add a contains dependency (parent -> child) and a blocks dependency
3037        snapshot.tables.insert(
3038            "dependencies".to_string(),
3039            vec![
3040                json!({
3041                    "from_task_id": "parent",
3042                    "to_task_id": "child",
3043                    "dep_type": "contains"
3044                }),
3045                json!({
3046                    "from_task_id": "child",
3047                    "to_task_id": "parent",
3048                    "dep_type": "blocks"
3049                }),
3050            ],
3051        );
3052
3053        let ids_config = IdsConfig::default();
3054        let (remapped, id_map) = remap_snapshot(&snapshot, &ids_config).unwrap();
3055
3056        let new_parent = &id_map["parent"];
3057        let new_child = &id_map["child"];
3058
3059        // Verify dependencies reference the new IDs
3060        let deps = remapped.tables.get("dependencies").unwrap();
3061        assert_eq!(deps.len(), 2);
3062
3063        let dep0 = deps[0].as_object().unwrap();
3064        assert_eq!(dep0["from_task_id"].as_str().unwrap(), new_parent.as_str());
3065        assert_eq!(dep0["to_task_id"].as_str().unwrap(), new_child.as_str());
3066        assert_eq!(dep0["dep_type"].as_str().unwrap(), "contains");
3067
3068        let dep1 = deps[1].as_object().unwrap();
3069        assert_eq!(dep1["from_task_id"].as_str().unwrap(), new_child.as_str());
3070        assert_eq!(dep1["to_task_id"].as_str().unwrap(), new_parent.as_str());
3071        assert_eq!(dep1["dep_type"].as_str().unwrap(), "blocks");
3072    }
3073
3074    #[test]
3075    fn test_remap_snapshot_remaps_attachments_and_tags() {
3076        let mut snapshot = Snapshot::new();
3077        snapshot.tables.insert(
3078            "tasks".to_string(),
3079            vec![json!({
3080                "id": "my-task",
3081                "title": "My Task",
3082                "description": null,
3083                "status": "pending",
3084                "priority": "5",
3085                "worker_id": null,
3086                "claimed_at": null,
3087                "needed_tags": null,
3088                "wanted_tags": null,
3089                "tags": "[]",
3090                "points": null,
3091                "time_estimate_ms": null,
3092                "time_actual_ms": null,
3093                "started_at": null,
3094                "completed_at": null,
3095                "current_thought": null,
3096                "metric_0": 0, "metric_1": 0, "metric_2": 0, "metric_3": 0,
3097                "metric_4": 0, "metric_5": 0, "metric_6": 0, "metric_7": 0,
3098                "cost_usd": 0.0,
3099                "deleted_at": null, "deleted_by": null, "deleted_reason": null,
3100                "created_at": 1700000000000_i64,
3101                "updated_at": 1700000000000_i64
3102            })],
3103        );
3104        snapshot.tables.insert(
3105            "attachments".to_string(),
3106            vec![json!({
3107                "task_id": "my-task",
3108                "attachment_type": "note",
3109                "sequence": 1,
3110                "name": "test-note",
3111                "mime_type": "text/plain",
3112                "content": "Hello world",
3113                "file_path": null,
3114                "created_at": 1700000000000_i64
3115            })],
3116        );
3117        snapshot.tables.insert(
3118            "task_tags".to_string(),
3119            vec![json!({
3120                "task_id": "my-task",
3121                "tag": "rust"
3122            })],
3123        );
3124        snapshot.tables.insert(
3125            "task_needed_tags".to_string(),
3126            vec![json!({
3127                "task_id": "my-task",
3128                "tag": "implementer"
3129            })],
3130        );
3131        snapshot.tables.insert(
3132            "task_wanted_tags".to_string(),
3133            vec![json!({
3134                "task_id": "my-task",
3135                "tag": "code"
3136            })],
3137        );
3138        snapshot.tables.insert(
3139            "task_sequence".to_string(),
3140            vec![json!({
3141                "id": 1,
3142                "task_id": "my-task",
3143                "worker_id": null,
3144                "status": "pending",
3145                "phase": null,
3146                "reason": null,
3147                "timestamp": 1700000000000_i64,
3148                "end_timestamp": null
3149            })],
3150        );
3151
3152        let ids_config = IdsConfig::default();
3153        let (remapped, id_map) = remap_snapshot(&snapshot, &ids_config).unwrap();
3154        let new_id = &id_map["my-task"];
3155
3156        // Attachments should use new task_id
3157        let atts = remapped.tables.get("attachments").unwrap();
3158        assert_eq!(atts[0]["task_id"].as_str().unwrap(), new_id.as_str());
3159
3160        // Tags should use new task_id
3161        let tags = remapped.tables.get("task_tags").unwrap();
3162        assert_eq!(tags[0]["task_id"].as_str().unwrap(), new_id.as_str());
3163
3164        let needed = remapped.tables.get("task_needed_tags").unwrap();
3165        assert_eq!(needed[0]["task_id"].as_str().unwrap(), new_id.as_str());
3166
3167        let wanted = remapped.tables.get("task_wanted_tags").unwrap();
3168        assert_eq!(wanted[0]["task_id"].as_str().unwrap(), new_id.as_str());
3169
3170        // State history should use new task_id
3171        let events = remapped.tables.get("task_sequence").unwrap();
3172        assert_eq!(events[0]["task_id"].as_str().unwrap(), new_id.as_str());
3173    }
3174
3175    #[test]
3176    fn test_remap_snapshot_empty() {
3177        // Empty snapshot should produce empty mapping
3178        let snapshot = Snapshot::new();
3179        let ids_config = IdsConfig::default();
3180        let (remapped, id_map) = remap_snapshot(&snapshot, &ids_config).unwrap();
3181
3182        assert!(id_map.is_empty());
3183        assert!(remapped.tables.is_empty());
3184    }
3185
3186    #[test]
3187    fn test_remap_import_round_trip() {
3188        // Test that a remapped snapshot can be imported successfully
3189        let db = Database::open_in_memory().unwrap();
3190        let mut snapshot = Snapshot::new();
3191
3192        snapshot.tables.insert(
3193            "tasks".to_string(),
3194            vec![
3195                json!({
3196                    "id": "task-alpha",
3197                    "title": "Alpha Task",
3198                    "description": "First task",
3199                    "status": "pending",
3200                    "priority": "5",
3201                    "worker_id": null,
3202                    "claimed_at": null,
3203                    "needed_tags": null,
3204                    "wanted_tags": null,
3205                    "tags": "[]",
3206                    "points": null,
3207                    "time_estimate_ms": null,
3208                    "time_actual_ms": null,
3209                    "started_at": null,
3210                    "completed_at": null,
3211                    "current_thought": null,
3212                    "metric_0": 0, "metric_1": 0, "metric_2": 0, "metric_3": 0,
3213                    "metric_4": 0, "metric_5": 0, "metric_6": 0, "metric_7": 0,
3214                    "cost_usd": 0.0,
3215                    "deleted_at": null, "deleted_by": null, "deleted_reason": null,
3216                    "created_at": 1700000000000_i64,
3217                    "updated_at": 1700000000000_i64
3218                }),
3219                json!({
3220                    "id": "task-beta",
3221                    "title": "Beta Task",
3222                    "description": "Second task",
3223                    "status": "pending",
3224                    "priority": "3",
3225                    "worker_id": null,
3226                    "claimed_at": null,
3227                    "needed_tags": null,
3228                    "wanted_tags": null,
3229                    "tags": "[]",
3230                    "points": null,
3231                    "time_estimate_ms": null,
3232                    "time_actual_ms": null,
3233                    "started_at": null,
3234                    "completed_at": null,
3235                    "current_thought": null,
3236                    "metric_0": 0, "metric_1": 0, "metric_2": 0, "metric_3": 0,
3237                    "metric_4": 0, "metric_5": 0, "metric_6": 0, "metric_7": 0,
3238                    "cost_usd": 0.0,
3239                    "deleted_at": null, "deleted_by": null, "deleted_reason": null,
3240                    "created_at": 1700000000000_i64,
3241                    "updated_at": 1700000000000_i64
3242                }),
3243            ],
3244        );
3245        snapshot.tables.insert(
3246            "dependencies".to_string(),
3247            vec![json!({
3248                "from_task_id": "task-alpha",
3249                "to_task_id": "task-beta",
3250                "dep_type": "contains"
3251            })],
3252        );
3253
3254        // Remap IDs
3255        let ids_config = IdsConfig::default();
3256        let (remapped, id_map) = remap_snapshot(&snapshot, &ids_config).unwrap();
3257
3258        // Import the remapped snapshot
3259        let options = ImportOptions::fresh();
3260        let result = db.import_snapshot(&remapped, &options).unwrap();
3261
3262        assert_eq!(result.rows_imported.get("tasks"), Some(&2));
3263        assert_eq!(result.rows_imported.get("dependencies"), Some(&1));
3264
3265        // Verify tasks exist with new IDs
3266        let new_alpha = &id_map["task-alpha"];
3267        let new_beta = &id_map["task-beta"];
3268
3269        // Search for the tasks in the database
3270        let alpha_results = db.search_tasks("Alpha", None, 0, false, None).unwrap();
3271        assert_eq!(alpha_results.len(), 1);
3272        assert_eq!(alpha_results[0].task_id, *new_alpha);
3273
3274        let beta_results = db.search_tasks("Beta", None, 0, false, None).unwrap();
3275        assert_eq!(beta_results.len(), 1);
3276        assert_eq!(beta_results[0].task_id, *new_beta);
3277    }
3278
3279    // ============================================================================
3280    // Parent attachment tests
3281    // ============================================================================
3282
3283    #[test]
3284    fn test_snapshot_root_task_ids_all_roots() {
3285        // Snapshot with two tasks and no "contains" deps: both are roots
3286        let mut snapshot = Snapshot::new();
3287        snapshot.tables.insert(
3288            "tasks".to_string(),
3289            vec![
3290                json!({"id": "a", "title": "A"}),
3291                json!({"id": "b", "title": "B"}),
3292            ],
3293        );
3294        let roots = snapshot_root_task_ids(&snapshot);
3295        assert_eq!(roots.len(), 2);
3296        assert!(roots.contains(&"a".to_string()));
3297        assert!(roots.contains(&"b".to_string()));
3298    }
3299
3300    #[test]
3301    fn test_snapshot_root_task_ids_with_contains() {
3302        // "a" contains "b" -> only "a" is a root
3303        let mut snapshot = Snapshot::new();
3304        snapshot.tables.insert(
3305            "tasks".to_string(),
3306            vec![
3307                json!({"id": "a", "title": "A"}),
3308                json!({"id": "b", "title": "B"}),
3309            ],
3310        );
3311        snapshot.tables.insert(
3312            "dependencies".to_string(),
3313            vec![json!({"from_task_id": "a", "to_task_id": "b", "dep_type": "contains"})],
3314        );
3315        let roots = snapshot_root_task_ids(&snapshot);
3316        assert_eq!(roots.len(), 1);
3317        assert!(roots.contains(&"a".to_string()));
3318    }
3319
3320    #[test]
3321    fn test_snapshot_root_task_ids_non_contains_dep_ignored() {
3322        // "a" blocks "b" -> both are roots (only "contains" matters)
3323        let mut snapshot = Snapshot::new();
3324        snapshot.tables.insert(
3325            "tasks".to_string(),
3326            vec![
3327                json!({"id": "a", "title": "A"}),
3328                json!({"id": "b", "title": "B"}),
3329            ],
3330        );
3331        snapshot.tables.insert(
3332            "dependencies".to_string(),
3333            vec![json!({"from_task_id": "a", "to_task_id": "b", "dep_type": "blocks"})],
3334        );
3335        let roots = snapshot_root_task_ids(&snapshot);
3336        assert_eq!(roots.len(), 2);
3337    }
3338
3339    #[test]
3340    fn test_snapshot_root_task_ids_empty_snapshot() {
3341        let snapshot = Snapshot::new();
3342        let roots = snapshot_root_task_ids(&snapshot);
3343        assert!(roots.is_empty());
3344    }
3345
3346    /// Helper to create a full task JSON value for import tests.
3347    fn make_task_json(id: &str, title: &str) -> serde_json::Value {
3348        json!({
3349            "id": id,
3350            "title": title,
3351            "description": "",
3352            "status": "pending",
3353            "priority": "5",
3354            "worker_id": null,
3355            "claimed_at": null,
3356            "needed_tags": null,
3357            "wanted_tags": null,
3358            "tags": "[]",
3359            "points": null,
3360            "time_estimate_ms": null,
3361            "time_actual_ms": null,
3362            "started_at": null,
3363            "completed_at": null,
3364            "current_thought": null,
3365            "metric_0": 0, "metric_1": 0, "metric_2": 0, "metric_3": 0,
3366            "metric_4": 0, "metric_5": 0, "metric_6": 0, "metric_7": 0,
3367            "cost_usd": 0.0,
3368            "deleted_at": null, "deleted_by": null, "deleted_reason": null,
3369            "created_at": 1700000000000_i64,
3370            "updated_at": 1700000000000_i64
3371        })
3372    }
3373
3374    #[test]
3375    fn test_import_with_parent_attaches_root_tasks() {
3376        use crate::config::StatesConfig;
3377
3378        let db = Database::open_in_memory().unwrap();
3379
3380        // Pre-create the parent task in the database
3381        db.create_task(
3382            Some("parent-task".to_string()),
3383            "Parent".to_string(),
3384            None,
3385            None,
3386            None,
3387            None,
3388            None,
3389            None,
3390            None,
3391            None,
3392            None,
3393            &StatesConfig::default(),
3394            &IdsConfig::default(),
3395        )
3396        .unwrap();
3397
3398        // Create snapshot with two root tasks and one child
3399        let mut snapshot = Snapshot::new();
3400        snapshot.tables.insert(
3401            "tasks".to_string(),
3402            vec![
3403                make_task_json("root-a", "Root A"),
3404                make_task_json("root-b", "Root B"),
3405                make_task_json("child-c", "Child C"),
3406            ],
3407        );
3408        snapshot.tables.insert(
3409            "dependencies".to_string(),
3410            vec![
3411                json!({"from_task_id": "root-a", "to_task_id": "child-c", "dep_type": "contains"}),
3412            ],
3413        );
3414
3415        // Import with parent -- use merge mode since parent task already exists in DB
3416        let options = ImportOptions::merge().with_parent("parent-task".to_string());
3417        let result = db.import_snapshot(&snapshot, &options).unwrap();
3418
3419        // Verify root tasks were linked
3420        assert_eq!(result.parent_linked_roots.len(), 2);
3421        assert!(result.parent_linked_roots.contains(&"root-a".to_string()));
3422        assert!(result.parent_linked_roots.contains(&"root-b".to_string()));
3423        // child-c should NOT be in roots (it has a contains parent)
3424        assert!(!result.parent_linked_roots.contains(&"child-c".to_string()));
3425
3426        // Verify "contains" dependencies exist in DB
3427        let parent_a = db.get_parent("root-a").unwrap();
3428        assert_eq!(parent_a, Some("parent-task".to_string()));
3429
3430        let parent_b = db.get_parent("root-b").unwrap();
3431        assert_eq!(parent_b, Some("parent-task".to_string()));
3432
3433        // child-c should have root-a as parent (from the snapshot)
3434        let parent_c = db.get_parent("child-c").unwrap();
3435        assert_eq!(parent_c, Some("root-a".to_string()));
3436    }
3437
3438    #[test]
3439    fn test_import_with_parent_not_found_fails() {
3440        let db = Database::open_in_memory().unwrap();
3441
3442        let mut snapshot = Snapshot::new();
3443        snapshot.tables.insert(
3444            "tasks".to_string(),
3445            vec![make_task_json("task-x", "Task X")],
3446        );
3447
3448        // Import with nonexistent parent
3449        let options = ImportOptions::fresh().with_parent("nonexistent".to_string());
3450        let result = db.import_snapshot(&snapshot, &options);
3451
3452        assert!(result.is_err());
3453        let err_msg = result.unwrap_err().to_string();
3454        assert!(
3455            err_msg.contains("not found"),
3456            "Expected 'not found' in: {}",
3457            err_msg
3458        );
3459    }
3460
3461    #[test]
3462    fn test_import_without_parent_does_not_link() {
3463        let db = Database::open_in_memory().unwrap();
3464
3465        let mut snapshot = Snapshot::new();
3466        snapshot.tables.insert(
3467            "tasks".to_string(),
3468            vec![make_task_json("task-y", "Task Y")],
3469        );
3470
3471        // Import without parent
3472        let options = ImportOptions::fresh();
3473        let result = db.import_snapshot(&snapshot, &options).unwrap();
3474
3475        assert!(result.parent_linked_roots.is_empty());
3476
3477        // Verify no parent exists
3478        let parent = db.get_parent("task-y").unwrap();
3479        assert_eq!(parent, None);
3480    }
3481
3482    #[test]
3483    fn test_import_with_parent_and_empty_snapshot() {
3484        use crate::config::StatesConfig;
3485
3486        let db = Database::open_in_memory().unwrap();
3487
3488        // Pre-create the parent task
3489        db.create_task(
3490            Some("parent-task".to_string()),
3491            "Parent".to_string(),
3492            None,
3493            None,
3494            None,
3495            None,
3496            None,
3497            None,
3498            None,
3499            None,
3500            None,
3501            &StatesConfig::default(),
3502            &IdsConfig::default(),
3503        )
3504        .unwrap();
3505
3506        // Import empty snapshot with parent -- use merge since parent already exists
3507        let snapshot = Snapshot::new();
3508        let options = ImportOptions::merge().with_parent("parent-task".to_string());
3509        let result = db.import_snapshot(&snapshot, &options).unwrap();
3510
3511        // No roots to link
3512        assert!(result.parent_linked_roots.is_empty());
3513    }
3514
3515    #[test]
3516    fn test_import_options_with_parent_builder() {
3517        let options = ImportOptions::merge().with_parent("my-parent".to_string());
3518        assert_eq!(options.mode, ImportMode::Merge);
3519        assert_eq!(options.parent_id, Some("my-parent".to_string()));
3520        assert!(!options.remap_ids);
3521    }
3522}