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)
1478         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
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        ])?;
1497        count += 1;
1498    }
1499
1500    Ok(count)
1501}
1502
1503// ============================================================================
1504// JSON value extraction helpers
1505// ============================================================================
1506
1507/// Get a required string value from a JSON object.
1508fn get_string(obj: &serde_json::Map<String, Value>, key: &str) -> Result<String> {
1509    obj.get(key)
1510        .and_then(|v| v.as_str())
1511        .map(|s| s.to_string())
1512        .ok_or_else(|| anyhow!("Missing or invalid string field: {}", key))
1513}
1514
1515/// Get an optional string value from a JSON object.
1516fn get_opt_string(obj: &serde_json::Map<String, Value>, key: &str) -> Option<String> {
1517    obj.get(key).and_then(|v| {
1518        if v.is_null() {
1519            None
1520        } else {
1521            v.as_str().map(|s| s.to_string())
1522        }
1523    })
1524}
1525
1526/// Get a required i64 value from a JSON object.
1527fn get_i64(obj: &serde_json::Map<String, Value>, key: &str) -> Result<i64> {
1528    obj.get(key)
1529        .and_then(|v| v.as_i64())
1530        .ok_or_else(|| anyhow!("Missing or invalid i64 field: {}", key))
1531}
1532
1533/// Get an optional i64 value from a JSON object.
1534fn get_opt_i64(obj: &serde_json::Map<String, Value>, key: &str) -> Option<i64> {
1535    obj.get(key)
1536        .and_then(|v| if v.is_null() { None } else { v.as_i64() })
1537}
1538
1539/// Get an i64 value with a default of 0.
1540fn get_i64_or_default(obj: &serde_json::Map<String, Value>, key: &str) -> i64 {
1541    get_opt_i64(obj, key).unwrap_or(0)
1542}
1543
1544/// Get a required i32 value from a JSON object.
1545fn get_i32(obj: &serde_json::Map<String, Value>, key: &str) -> Result<i32> {
1546    obj.get(key)
1547        .and_then(|v| v.as_i64())
1548        .map(|i| i as i32)
1549        .ok_or_else(|| anyhow!("Missing or invalid i32 field: {}", key))
1550}
1551
1552/// Get an optional i32 value from a JSON object.
1553#[allow(dead_code)]
1554fn get_opt_i32(obj: &serde_json::Map<String, Value>, key: &str) -> Option<i32> {
1555    obj.get(key).and_then(|v| {
1556        if v.is_null() {
1557            None
1558        } else {
1559            v.as_i64().map(|i| i as i32)
1560        }
1561    })
1562}
1563
1564/// Get an f64 value with a default of 0.0.
1565fn get_f64_or_default(obj: &serde_json::Map<String, Value>, key: &str) -> f64 {
1566    obj.get(key)
1567        .and_then(|v| if v.is_null() { None } else { v.as_f64() })
1568        .unwrap_or(0.0)
1569}
1570
1571#[cfg(test)]
1572mod tests {
1573    use super::*;
1574    use crate::config::IdsConfig;
1575    use crate::export::Snapshot;
1576    use serde_json::json;
1577
1578    #[test]
1579    fn test_import_empty_snapshot() {
1580        let db = Database::open_in_memory().unwrap();
1581        let snapshot = Snapshot::new();
1582        let options = ImportOptions::default();
1583
1584        let result = db.import_snapshot(&snapshot, &options).unwrap();
1585        assert_eq!(result.total_rows(), 0);
1586        assert!(result.fts_rebuilt);
1587    }
1588
1589    #[test]
1590    fn test_import_tasks() {
1591        let db = Database::open_in_memory().unwrap();
1592        let mut snapshot = Snapshot::new();
1593
1594        snapshot.tables.insert(
1595            "tasks".to_string(),
1596            vec![json!({
1597                "id": "task-1",
1598                "title": "Test Task",
1599                "description": "A test task",
1600                "status": "pending",
1601                "priority": "5",
1602                "worker_id": null,
1603                "claimed_at": null,
1604                "needed_tags": null,
1605                "wanted_tags": null,
1606                "tags": "[]",
1607                "points": null,
1608                "time_estimate_ms": null,
1609                "time_actual_ms": null,
1610                "started_at": null,
1611                "completed_at": null,
1612                "current_thought": null,
1613                "metric_0": 0,
1614                "metric_1": 0,
1615                "metric_2": 0,
1616                "metric_3": 0,
1617                "metric_4": 0,
1618                "metric_5": 0,
1619                "metric_6": 0,
1620                "metric_7": 0,
1621                "cost_usd": 0.0,
1622                "deleted_at": null,
1623                "deleted_by": null,
1624                "deleted_reason": null,
1625                "created_at": 1700000000000_i64,
1626                "updated_at": 1700000000000_i64
1627            })],
1628        );
1629
1630        let options = ImportOptions::default();
1631        let result = db.import_snapshot(&snapshot, &options).unwrap();
1632
1633        assert_eq!(result.rows_imported.get("tasks"), Some(&1));
1634        assert!(result.fts_rebuilt);
1635
1636        // Verify FTS was populated
1637        let results = db.search_tasks("Test", None, 0, false, None).unwrap();
1638        assert_eq!(results.len(), 1);
1639        assert_eq!(results[0].task_id, "task-1");
1640    }
1641
1642    #[test]
1643    fn test_import_with_dependencies() {
1644        let db = Database::open_in_memory().unwrap();
1645        let mut snapshot = Snapshot::new();
1646
1647        // Add tasks
1648        snapshot.tables.insert(
1649            "tasks".to_string(),
1650            vec![
1651                json!({
1652                    "id": "task-a",
1653                    "title": "Task A",
1654                    "description": null,
1655                    "status": "pending",
1656                    "priority": "5",
1657                    "worker_id": null,
1658                    "claimed_at": null,
1659                    "needed_tags": null,
1660                    "wanted_tags": null,
1661                    "tags": "[]",
1662                    "points": null,
1663                    "time_estimate_ms": null,
1664                    "time_actual_ms": null,
1665                    "started_at": null,
1666                    "completed_at": null,
1667                    "current_thought": null,
1668                    "metric_0": 0,
1669                    "metric_1": 0,
1670                    "metric_2": 0,
1671                    "metric_3": 0,
1672                    "metric_4": 0,
1673                    "metric_5": 0,
1674                    "metric_6": 0,
1675                    "metric_7": 0,
1676                    "cost_usd": 0.0,
1677                    "deleted_at": null,
1678                    "deleted_by": null,
1679                    "deleted_reason": null,
1680                    "created_at": 1700000000000_i64,
1681                    "updated_at": 1700000000000_i64
1682                }),
1683                json!({
1684                    "id": "task-b",
1685                    "title": "Task B",
1686                    "description": null,
1687                    "status": "pending",
1688                    "priority": "5",
1689                    "worker_id": null,
1690                    "claimed_at": null,
1691                    "needed_tags": null,
1692                    "wanted_tags": null,
1693                    "tags": "[]",
1694                    "points": null,
1695                    "time_estimate_ms": null,
1696                    "time_actual_ms": null,
1697                    "started_at": null,
1698                    "completed_at": null,
1699                    "current_thought": null,
1700                    "metric_0": 0,
1701                    "metric_1": 0,
1702                    "metric_2": 0,
1703                    "metric_3": 0,
1704                    "metric_4": 0,
1705                    "metric_5": 0,
1706                    "metric_6": 0,
1707                    "metric_7": 0,
1708                    "cost_usd": 0.0,
1709                    "deleted_at": null,
1710                    "deleted_by": null,
1711                    "deleted_reason": null,
1712                    "created_at": 1700000000000_i64,
1713                    "updated_at": 1700000000000_i64
1714                }),
1715            ],
1716        );
1717
1718        // Add dependency
1719        snapshot.tables.insert(
1720            "dependencies".to_string(),
1721            vec![json!({
1722                "from_task_id": "task-a",
1723                "to_task_id": "task-b",
1724                "dep_type": "blocks"
1725            })],
1726        );
1727
1728        let options = ImportOptions::default();
1729        let result = db.import_snapshot(&snapshot, &options).unwrap();
1730
1731        assert_eq!(result.rows_imported.get("tasks"), Some(&2));
1732        assert_eq!(result.rows_imported.get("dependencies"), Some(&1));
1733    }
1734
1735    #[test]
1736    fn test_import_fails_on_non_empty_database() {
1737        let db = Database::open_in_memory().unwrap();
1738
1739        // Create a task first
1740        use crate::config::StatesConfig;
1741        db.create_task(
1742            None,
1743            "Existing task".to_string(),
1744            None,
1745            None,
1746            None,
1747            None,
1748            None,
1749            None,
1750            None,
1751            None,
1752            None, // tags
1753            &StatesConfig::default(),
1754            &IdsConfig::default(),
1755        )
1756        .unwrap();
1757
1758        let snapshot = Snapshot::new();
1759        let options = ImportOptions::fresh(); // Explicitly use fresh mode
1760
1761        let result = db.import_snapshot(&snapshot, &options);
1762        assert!(result.is_err());
1763        assert!(result.unwrap_err().to_string().contains("not empty"));
1764    }
1765
1766    #[test]
1767    fn test_import_replace_mode() {
1768        let db = Database::open_in_memory().unwrap();
1769
1770        // Create existing task
1771        use crate::config::StatesConfig;
1772        let existing_id = db
1773            .create_task(
1774                None,
1775                "Existing task".to_string(),
1776                None,
1777                None,
1778                None,
1779                None,
1780                None,
1781                None,
1782                None,
1783                None,
1784                None, // tags
1785                &StatesConfig::default(),
1786                &IdsConfig::default(),
1787            )
1788            .unwrap();
1789
1790        // Verify task exists
1791        let task = db.get_task(&existing_id.id).unwrap();
1792        assert!(task.is_some());
1793        assert_eq!(task.unwrap().title, "Existing task");
1794
1795        // Create snapshot with different task
1796        let mut snapshot = Snapshot::new();
1797        snapshot.tables.insert(
1798            "tasks".to_string(),
1799            vec![json!({
1800                "id": "imported-task",
1801                "title": "Imported Task",
1802                "description": null,
1803                "status": "pending",
1804                "priority": "5",
1805                "worker_id": null,
1806                "claimed_at": null,
1807                "needed_tags": null,
1808                "wanted_tags": null,
1809                "tags": "[]",
1810                "points": null,
1811                "time_estimate_ms": null,
1812                "time_actual_ms": null,
1813                "started_at": null,
1814                "completed_at": null,
1815                "current_thought": null,
1816                "metric_0": 0,
1817                "metric_1": 0,
1818                "metric_2": 0,
1819                "metric_3": 0,
1820                "metric_4": 0,
1821                "metric_5": 0,
1822                "metric_6": 0,
1823                "metric_7": 0,
1824                "cost_usd": 0.0,
1825                "deleted_at": null,
1826                "deleted_by": null,
1827                "deleted_reason": null,
1828                "created_at": 1700000000000_i64,
1829                "updated_at": 1700000000000_i64
1830            })],
1831        );
1832
1833        // Import in replace mode
1834        let options = ImportOptions::replace();
1835        let result = db.import_snapshot(&snapshot, &options).unwrap();
1836
1837        // Verify old task was deleted and new task imported
1838        assert_eq!(result.rows_deleted.get("tasks"), Some(&1));
1839        assert_eq!(result.rows_imported.get("tasks"), Some(&1));
1840
1841        // Old task should be gone
1842        let old_task = db.get_task(&existing_id.id).unwrap();
1843        assert!(old_task.is_none());
1844
1845        // New task should exist
1846        let new_task = db.get_task("imported-task").unwrap();
1847        assert!(new_task.is_some());
1848        assert_eq!(new_task.unwrap().title, "Imported Task");
1849    }
1850
1851    #[test]
1852    fn test_replace_mode_preserves_workers() {
1853        let db = Database::open_in_memory().unwrap();
1854
1855        // Register a worker
1856        db.register_worker(
1857            Some("test-worker".to_string()),
1858            vec!["rust".to_string(), "test".to_string()],
1859            false,
1860            &IdsConfig::default(),
1861            None,
1862            vec![],
1863        )
1864        .unwrap();
1865
1866        // Verify worker exists
1867        let workers = db.list_workers().unwrap();
1868        assert_eq!(workers.len(), 1);
1869        assert_eq!(workers[0].id, "test-worker");
1870
1871        // Create a task
1872        use crate::config::StatesConfig;
1873        db.create_task(
1874            None,
1875            "Task to replace".to_string(),
1876            None,
1877            None,
1878            None,
1879            None,
1880            None,
1881            None,
1882            None,
1883            None,
1884            None, // tags
1885            &StatesConfig::default(),
1886            &IdsConfig::default(),
1887        )
1888        .unwrap();
1889
1890        // Import empty snapshot in replace mode
1891        let snapshot = Snapshot::new();
1892        let options = ImportOptions::replace();
1893        let result = db.import_snapshot(&snapshot, &options).unwrap();
1894
1895        // Task should be deleted
1896        assert_eq!(result.rows_deleted.get("tasks"), Some(&1));
1897
1898        // Worker should still exist (preserved)
1899        let workers = db.list_workers().unwrap();
1900        assert_eq!(workers.len(), 1);
1901        assert_eq!(workers[0].id, "test-worker");
1902    }
1903
1904    #[test]
1905    fn test_clear_project_data() {
1906        let db = Database::open_in_memory().unwrap();
1907
1908        // Create tasks with dependencies and tags
1909        use crate::config::{DependenciesConfig, StatesConfig};
1910        let task_a = db
1911            .create_task(
1912                None,
1913                "Task A".to_string(),
1914                None,
1915                None,
1916                None, // phase
1917                None,
1918                None,
1919                None,
1920                None,
1921                None,
1922                Some(vec!["rust".to_string(), "test".to_string()]), // tags
1923                &StatesConfig::default(),
1924                &IdsConfig::default(),
1925            )
1926            .unwrap();
1927
1928        let task_b = db
1929            .create_task(
1930                None,
1931                "Task B".to_string(),
1932                None,
1933                None,
1934                None,
1935                None,
1936                None,
1937                None,
1938                None,
1939                None,
1940                None, // tags
1941                &StatesConfig::default(),
1942                &IdsConfig::default(),
1943            )
1944            .unwrap();
1945
1946        // Add dependency
1947        db.add_dependency(
1948            &task_a.id,
1949            &task_b.id,
1950            "blocks",
1951            &DependenciesConfig::default(),
1952        )
1953        .unwrap();
1954
1955        // Clear all project data
1956        let deleted = db.clear_project_data().unwrap();
1957
1958        // Verify counts
1959        assert_eq!(deleted.get("tasks"), Some(&2));
1960        assert_eq!(deleted.get("dependencies"), Some(&1));
1961        assert_eq!(deleted.get("task_tags"), Some(&2));
1962
1963        // Verify tables are empty
1964        db.with_conn(|conn| {
1965            for table in IMPORT_ORDER {
1966                let count: i64 =
1967                    conn.query_row(&format!("SELECT COUNT(*) FROM {}", table), [], |row| {
1968                        row.get(0)
1969                    })?;
1970                assert_eq!(count, 0, "Table {} should be empty", table);
1971            }
1972            Ok(())
1973        })
1974        .unwrap();
1975    }
1976
1977    #[test]
1978    fn test_import_schema_version_mismatch() {
1979        let db = Database::open_in_memory().unwrap();
1980        let mut snapshot = Snapshot::new();
1981        snapshot.schema_version = 999; // Invalid version
1982
1983        let options = ImportOptions::default();
1984        let result = db.import_snapshot(&snapshot, &options);
1985
1986        assert!(result.is_err());
1987        assert!(
1988            result
1989                .unwrap_err()
1990                .to_string()
1991                .contains("Schema version mismatch")
1992        );
1993    }
1994
1995    #[test]
1996    fn test_import_with_attachments() {
1997        let db = Database::open_in_memory().unwrap();
1998        let mut snapshot = Snapshot::new();
1999
2000        // Add task
2001        snapshot.tables.insert(
2002            "tasks".to_string(),
2003            vec![json!({
2004                "id": "task-1",
2005                "title": "Task with attachment",
2006                "description": null,
2007                "status": "pending",
2008                "priority": "5",
2009                "worker_id": null,
2010                "claimed_at": null,
2011                "needed_tags": null,
2012                "wanted_tags": null,
2013                "tags": "[]",
2014                "points": null,
2015                "time_estimate_ms": null,
2016                "time_actual_ms": null,
2017                "started_at": null,
2018                "completed_at": null,
2019                "current_thought": null,
2020                "metric_0": 0,
2021                "metric_1": 0,
2022                "metric_2": 0,
2023                "metric_3": 0,
2024                "metric_4": 0,
2025                "metric_5": 0,
2026                "metric_6": 0,
2027                "metric_7": 0,
2028                "cost_usd": 0.0,
2029                "deleted_at": null,
2030                "deleted_by": null,
2031                "deleted_reason": null,
2032                "created_at": 1700000000000_i64,
2033                "updated_at": 1700000000000_i64
2034            })],
2035        );
2036
2037        // Add attachment
2038        snapshot.tables.insert(
2039            "attachments".to_string(),
2040            vec![json!({
2041                "task_id": "task-1",
2042                "attachment_type": "notes",
2043                "sequence": 0,
2044                "name": "",
2045                "mime_type": "text/plain",
2046                "content": "Some searchable notes content",
2047                "file_path": null,
2048                "created_at": 1700000000000_i64
2049            })],
2050        );
2051
2052        let options = ImportOptions::default();
2053        let result = db.import_snapshot(&snapshot, &options).unwrap();
2054
2055        assert_eq!(result.rows_imported.get("tasks"), Some(&1));
2056        assert_eq!(result.rows_imported.get("attachments"), Some(&1));
2057
2058        // Verify attachment FTS was populated
2059        let results = db.search_tasks("searchable", None, 0, true, None).unwrap();
2060        assert_eq!(results.len(), 1);
2061        assert_eq!(results[0].attachment_matches.len(), 1);
2062    }
2063
2064    #[test]
2065    fn test_import_with_tags() {
2066        let db = Database::open_in_memory().unwrap();
2067        let mut snapshot = Snapshot::new();
2068
2069        // Add task
2070        snapshot.tables.insert(
2071            "tasks".to_string(),
2072            vec![json!({
2073                "id": "task-1",
2074                "title": "Tagged Task",
2075                "description": null,
2076                "status": "pending",
2077                "priority": "5",
2078                "worker_id": null,
2079                "claimed_at": null,
2080                "needed_tags": null,
2081                "wanted_tags": null,
2082                "tags": "[]",
2083                "points": null,
2084                "time_estimate_ms": null,
2085                "time_actual_ms": null,
2086                "started_at": null,
2087                "completed_at": null,
2088                "current_thought": null,
2089                "metric_0": 0,
2090                "metric_1": 0,
2091                "metric_2": 0,
2092                "metric_3": 0,
2093                "metric_4": 0,
2094                "metric_5": 0,
2095                "metric_6": 0,
2096                "metric_7": 0,
2097                "cost_usd": 0.0,
2098                "deleted_at": null,
2099                "deleted_by": null,
2100                "deleted_reason": null,
2101                "created_at": 1700000000000_i64,
2102                "updated_at": 1700000000000_i64
2103            })],
2104        );
2105
2106        // Add tags
2107        snapshot.tables.insert(
2108            "task_tags".to_string(),
2109            vec![
2110                json!({"task_id": "task-1", "tag": "rust"}),
2111                json!({"task_id": "task-1", "tag": "backend"}),
2112            ],
2113        );
2114
2115        snapshot.tables.insert(
2116            "task_needed_tags".to_string(),
2117            vec![json!({"task_id": "task-1", "tag": "senior"})],
2118        );
2119
2120        snapshot.tables.insert(
2121            "task_wanted_tags".to_string(),
2122            vec![json!({"task_id": "task-1", "tag": "rust-expert"})],
2123        );
2124
2125        let options = ImportOptions::default();
2126        let result = db.import_snapshot(&snapshot, &options).unwrap();
2127
2128        assert_eq!(result.rows_imported.get("task_tags"), Some(&2));
2129        assert_eq!(result.rows_imported.get("task_needed_tags"), Some(&1));
2130        assert_eq!(result.rows_imported.get("task_wanted_tags"), Some(&1));
2131    }
2132
2133    #[test]
2134    fn test_import_task_sequence() {
2135        let db = Database::open_in_memory().unwrap();
2136        let mut snapshot = Snapshot::new();
2137
2138        // Add task
2139        snapshot.tables.insert(
2140            "tasks".to_string(),
2141            vec![json!({
2142                "id": "task-1",
2143                "title": "Task with history",
2144                "description": null,
2145                "status": "completed",
2146                "priority": "5",
2147                "worker_id": null,
2148                "claimed_at": null,
2149                "needed_tags": null,
2150                "wanted_tags": null,
2151                "tags": "[]",
2152                "points": null,
2153                "time_estimate_ms": null,
2154                "time_actual_ms": null,
2155                "started_at": null,
2156                "completed_at": 1700000001000_i64,
2157                "current_thought": null,
2158                "metric_0": 0,
2159                "metric_1": 0,
2160                "metric_2": 0,
2161                "metric_3": 0,
2162                "metric_4": 0,
2163                "metric_5": 0,
2164                "metric_6": 0,
2165                "metric_7": 0,
2166                "cost_usd": 0.0,
2167                "deleted_at": null,
2168                "deleted_by": null,
2169                "deleted_reason": null,
2170                "created_at": 1700000000000_i64,
2171                "updated_at": 1700000001000_i64
2172            })],
2173        );
2174
2175        // Add state history
2176        snapshot.tables.insert(
2177            "task_sequence".to_string(),
2178            vec![
2179                json!({
2180                    "id": 1,
2181                    "task_id": "task-1",
2182                    "worker_id": null,
2183                    "event": "pending",
2184                    "reason": "Task created",
2185                    "timestamp": 1700000000000_i64,
2186                    "end_timestamp": 1700000000500_i64
2187                }),
2188                json!({
2189                    "id": 2,
2190                    "task_id": "task-1",
2191                    "worker_id": "worker-1",
2192                    "event": "working",
2193                    "reason": "Started work",
2194                    "timestamp": 1700000000500_i64,
2195                    "end_timestamp": 1700000001000_i64
2196                }),
2197                json!({
2198                    "id": 3,
2199                    "task_id": "task-1",
2200                    "worker_id": "worker-1",
2201                    "event": "completed",
2202                    "reason": "Done",
2203                    "timestamp": 1700000001000_i64,
2204                    "end_timestamp": null
2205                }),
2206            ],
2207        );
2208
2209        let options = ImportOptions::default();
2210        let result = db.import_snapshot(&snapshot, &options).unwrap();
2211
2212        assert_eq!(result.rows_imported.get("task_sequence"), Some(&3));
2213    }
2214
2215    #[test]
2216    fn test_rebuild_fts_indexes() {
2217        let db = Database::open_in_memory().unwrap();
2218
2219        // First, insert a task normally (trigger will fire)
2220        db.with_conn(|conn| {
2221            conn.execute(
2222                "INSERT INTO tasks (id, title, description, status, priority, created_at, updated_at)
2223                 VALUES ('test-task', 'Manual Insert Test', 'Bypass trigger', 'pending', '5', 1700000000000, 1700000000000)",
2224                [],
2225            )?;
2226            Ok(())
2227        }).unwrap();
2228
2229        // FTS should have the task due to triggers
2230        let results = db.search_tasks("Manual", None, 0, false, None).unwrap();
2231        assert_eq!(results.len(), 1);
2232
2233        // Now delete from FTS to simulate a corrupted/empty FTS state
2234        db.with_conn(|conn| {
2235            conn.execute("DELETE FROM tasks_fts", [])?;
2236            Ok(())
2237        })
2238        .unwrap();
2239
2240        // Search should now find nothing
2241        let results = db.search_tasks("Manual", None, 0, false, None).unwrap();
2242        assert!(results.is_empty());
2243
2244        // Rebuild FTS
2245        db.rebuild_fts_indexes().unwrap();
2246
2247        // Now search should work again
2248        let results = db.search_tasks("Manual", None, 0, false, None).unwrap();
2249        assert_eq!(results.len(), 1);
2250        assert_eq!(results[0].task_id, "test-task");
2251    }
2252
2253    #[test]
2254    fn test_import_mode_default() {
2255        // Default mode should be Fresh
2256        let options = ImportOptions::default();
2257        assert_eq!(options.mode, ImportMode::Fresh);
2258    }
2259
2260    #[test]
2261    fn test_import_result_total_deleted() {
2262        let mut result = ImportResult::new();
2263        result.rows_deleted.insert("tasks".to_string(), 5);
2264        result.rows_deleted.insert("dependencies".to_string(), 3);
2265        assert_eq!(result.total_deleted(), 8);
2266    }
2267
2268    #[test]
2269    fn test_import_result_total_skipped() {
2270        let mut result = ImportResult::new();
2271        result.rows_skipped.insert("tasks".to_string(), 3);
2272        result.rows_skipped.insert("dependencies".to_string(), 2);
2273        assert_eq!(result.total_skipped(), 5);
2274    }
2275
2276    #[test]
2277    fn test_merge_mode_skips_existing_tasks() {
2278        let db = Database::open_in_memory().unwrap();
2279
2280        // Create existing task with specific ID
2281        use crate::config::StatesConfig;
2282        db.create_task(
2283            Some("existing-task".to_string()),
2284            "Existing task".to_string(),
2285            None,
2286            None,
2287            None, // phase
2288            None,
2289            None,
2290            None,
2291            None,
2292            None,
2293            None, // tags
2294            &StatesConfig::default(),
2295            &IdsConfig::default(),
2296        )
2297        .unwrap();
2298
2299        // Create snapshot with same ID task and a new task
2300        let mut snapshot = Snapshot::new();
2301        snapshot.tables.insert(
2302            "tasks".to_string(),
2303            vec![
2304                json!({
2305                    "id": "existing-task", // This should be skipped
2306                    "title": "Should Be Skipped",
2307                    "description": null,
2308                    "status": "pending",
2309                    "priority": "5",
2310                    "worker_id": null,
2311                    "claimed_at": null,
2312                    "needed_tags": null,
2313                    "wanted_tags": null,
2314                    "tags": "[]",
2315                    "points": null,
2316                    "time_estimate_ms": null,
2317                    "time_actual_ms": null,
2318                    "started_at": null,
2319                    "completed_at": null,
2320                    "current_thought": null,
2321                    "metric_0": 0,
2322                    "metric_1": 0,
2323                    "metric_2": 0,
2324                    "metric_3": 0,
2325                    "metric_4": 0,
2326                    "metric_5": 0,
2327                    "metric_6": 0,
2328                    "metric_7": 0,
2329                    "cost_usd": 0.0,
2330                    "deleted_at": null,
2331                    "deleted_by": null,
2332                    "deleted_reason": null,
2333                    "created_at": 1700000000000_i64,
2334                    "updated_at": 1700000000000_i64
2335                }),
2336                json!({
2337                    "id": "new-task", // This should be imported
2338                    "title": "New Task",
2339                    "description": null,
2340                    "status": "pending",
2341                    "priority": "5",
2342                    "worker_id": null,
2343                    "claimed_at": null,
2344                    "needed_tags": null,
2345                    "wanted_tags": null,
2346                    "tags": "[]",
2347                    "points": null,
2348                    "time_estimate_ms": null,
2349                    "time_actual_ms": null,
2350                    "started_at": null,
2351                    "completed_at": null,
2352                    "current_thought": null,
2353                    "metric_0": 0,
2354                    "metric_1": 0,
2355                    "metric_2": 0,
2356                    "metric_3": 0,
2357                    "metric_4": 0,
2358                    "metric_5": 0,
2359                    "metric_6": 0,
2360                    "metric_7": 0,
2361                    "cost_usd": 0.0,
2362                    "deleted_at": null,
2363                    "deleted_by": null,
2364                    "deleted_reason": null,
2365                    "created_at": 1700000000000_i64,
2366                    "updated_at": 1700000000000_i64
2367                }),
2368            ],
2369        );
2370
2371        // Import in merge mode
2372        let options = ImportOptions::merge();
2373        let result = db.import_snapshot(&snapshot, &options).unwrap();
2374
2375        // 1 imported (new-task), 1 skipped (existing-task)
2376        assert_eq!(result.rows_imported.get("tasks"), Some(&1));
2377        assert_eq!(result.rows_skipped.get("tasks"), Some(&1));
2378
2379        // Existing task should still have original title
2380        let existing = db.get_task("existing-task").unwrap().unwrap();
2381        assert_eq!(existing.title, "Existing task");
2382
2383        // New task should be imported
2384        let new_task = db.get_task("new-task").unwrap();
2385        assert!(new_task.is_some());
2386        assert_eq!(new_task.unwrap().title, "New Task");
2387    }
2388
2389    #[test]
2390    fn test_merge_mode_skips_existing_dependencies() {
2391        let db = Database::open_in_memory().unwrap();
2392
2393        // Create tasks and dependency
2394        use crate::config::{DependenciesConfig, StatesConfig};
2395        db.create_task(
2396            Some("task-a".to_string()),
2397            "Task A".to_string(),
2398            None,
2399            None,
2400            None,
2401            None,
2402            None,
2403            None,
2404            None,
2405            None,
2406            None, // tags
2407            &StatesConfig::default(),
2408            &IdsConfig::default(),
2409        )
2410        .unwrap();
2411        db.create_task(
2412            Some("task-b".to_string()),
2413            "Task B".to_string(),
2414            None,
2415            None,
2416            None,
2417            None,
2418            None,
2419            None,
2420            None,
2421            None,
2422            None, // tags
2423            &StatesConfig::default(),
2424            &IdsConfig::default(),
2425        )
2426        .unwrap();
2427        db.create_task(
2428            Some("task-c".to_string()),
2429            "Task C".to_string(),
2430            None,
2431            None,
2432            None,
2433            None,
2434            None,
2435            None,
2436            None,
2437            None,
2438            None, // tags
2439            &StatesConfig::default(),
2440            &IdsConfig::default(),
2441        )
2442        .unwrap();
2443        db.add_dependency("task-a", "task-b", "blocks", &DependenciesConfig::default())
2444            .unwrap();
2445
2446        // Create snapshot with existing and new dependencies
2447        let mut snapshot = Snapshot::new();
2448        snapshot.tables.insert(
2449            "dependencies".to_string(),
2450            vec![
2451                json!({
2452                    "from_task_id": "task-a",
2453                    "to_task_id": "task-b",
2454                    "dep_type": "blocks" // Existing - should be skipped
2455                }),
2456                json!({
2457                    "from_task_id": "task-b",
2458                    "to_task_id": "task-c",
2459                    "dep_type": "blocks" // New - should be imported
2460                }),
2461            ],
2462        );
2463
2464        // Import in merge mode
2465        let options = ImportOptions::merge();
2466        let result = db.import_snapshot(&snapshot, &options).unwrap();
2467
2468        // 1 imported (b->c), 1 skipped (a->b)
2469        assert_eq!(result.rows_imported.get("dependencies"), Some(&1));
2470        assert_eq!(result.rows_skipped.get("dependencies"), Some(&1));
2471    }
2472
2473    #[test]
2474    fn test_merge_mode_skips_state_sequence() {
2475        let db = Database::open_in_memory().unwrap();
2476
2477        // Create a task (will have initial state history)
2478        use crate::config::StatesConfig;
2479        db.create_task(
2480            Some("task-1".to_string()),
2481            "Task 1".to_string(),
2482            None,
2483            None,
2484            None,
2485            None,
2486            None,
2487            None,
2488            None,
2489            None,
2490            None, // tags
2491            &StatesConfig::default(),
2492            &IdsConfig::default(),
2493        )
2494        .unwrap();
2495
2496        // Snapshot with state history
2497        let mut snapshot = Snapshot::new();
2498        snapshot.tables.insert(
2499            "task_sequence".to_string(),
2500            vec![json!({
2501                "id": 999,
2502                "task_id": "task-1",
2503                "worker_id": null,
2504                "event": "pending",
2505                "reason": "Imported history",
2506                "timestamp": 1700000000000_i64,
2507                "end_timestamp": null
2508            })],
2509        );
2510
2511        // Import in merge mode
2512        let options = ImportOptions::merge();
2513        let result = db.import_snapshot(&snapshot, &options).unwrap();
2514
2515        // State sequence should be all skipped in merge mode
2516        assert_eq!(result.rows_imported.get("task_sequence"), Some(&0));
2517        assert_eq!(result.rows_skipped.get("task_sequence"), Some(&1));
2518    }
2519
2520    #[test]
2521    fn test_merge_mode_adds_new_tags() {
2522        let db = Database::open_in_memory().unwrap();
2523
2524        // Create task with tags
2525        use crate::config::StatesConfig;
2526        db.create_task(
2527            Some("task-1".to_string()),
2528            "Task 1".to_string(),
2529            None,
2530            None,
2531            None,
2532            None,
2533            None,
2534            None,
2535            None,
2536            None,
2537            Some(vec!["existing-tag".to_string()]), // tags
2538            &StatesConfig::default(),
2539            &IdsConfig::default(),
2540        )
2541        .unwrap();
2542
2543        // Snapshot with existing and new tags
2544        let mut snapshot = Snapshot::new();
2545        snapshot.tables.insert(
2546            "task_tags".to_string(),
2547            vec![
2548                json!({"task_id": "task-1", "tag": "existing-tag"}), // Existing - skip
2549                json!({"task_id": "task-1", "tag": "new-tag"}),      // New - import
2550            ],
2551        );
2552
2553        // Import in merge mode
2554        let options = ImportOptions::merge();
2555        let result = db.import_snapshot(&snapshot, &options).unwrap();
2556
2557        // 1 imported (new-tag), 1 skipped (existing-tag)
2558        assert_eq!(result.rows_imported.get("task_tags"), Some(&1));
2559        assert_eq!(result.rows_skipped.get("task_tags"), Some(&1));
2560    }
2561
2562    #[test]
2563    fn test_import_options_merge() {
2564        let options = ImportOptions::merge();
2565        assert_eq!(options.mode, ImportMode::Merge);
2566    }
2567
2568    // ============================================================================
2569    // Dry-run (preview_import) tests
2570    // ============================================================================
2571
2572    #[test]
2573    fn test_preview_fresh_mode_empty_db() {
2574        let db = Database::open_in_memory().unwrap();
2575        let mut snapshot = Snapshot::new();
2576
2577        // Add a task to the snapshot
2578        snapshot.tables.insert(
2579            "tasks".to_string(),
2580            vec![json!({
2581                "id": "task-1",
2582                "title": "Test Task",
2583                "description": null,
2584                "status": "pending",
2585                "priority": "5",
2586                "worker_id": null,
2587                "claimed_at": null,
2588                "needed_tags": null,
2589                "wanted_tags": null,
2590                "tags": "[]",
2591                "points": null,
2592                "time_estimate_ms": null,
2593                "time_actual_ms": null,
2594                "started_at": null,
2595                "completed_at": null,
2596                "current_thought": null,
2597                "metric_0": 0,
2598                "metric_1": 0,
2599                "metric_2": 0,
2600                "metric_3": 0,
2601                "metric_4": 0,
2602                "metric_5": 0,
2603                "metric_6": 0,
2604                "metric_7": 0,
2605                "cost_usd": 0.0,
2606                "deleted_at": null,
2607                "deleted_by": null,
2608                "deleted_reason": null,
2609                "created_at": 1700000000000_i64,
2610                "updated_at": 1700000000000_i64
2611            })],
2612        );
2613
2614        let options = ImportOptions::fresh();
2615        let preview = db.preview_import(&snapshot, &options);
2616
2617        assert!(preview.would_succeed);
2618        assert!(preview.database_is_empty);
2619        assert_eq!(preview.mode, ImportMode::Fresh);
2620        assert_eq!(preview.total_would_insert(), 1);
2621        assert_eq!(preview.total_would_delete(), 0);
2622        assert_eq!(preview.total_would_skip(), 0);
2623    }
2624
2625    #[test]
2626    fn test_preview_fresh_mode_non_empty_db() {
2627        let db = Database::open_in_memory().unwrap();
2628
2629        // Create existing task
2630        use crate::config::StatesConfig;
2631        db.create_task(
2632            None,
2633            "Existing task".to_string(),
2634            None,
2635            None,
2636            None,
2637            None,
2638            None,
2639            None,
2640            None,
2641            None,
2642            None, // tags
2643            &StatesConfig::default(),
2644            &IdsConfig::default(),
2645        )
2646        .unwrap();
2647
2648        let snapshot = Snapshot::new();
2649        let options = ImportOptions::fresh();
2650        let preview = db.preview_import(&snapshot, &options);
2651
2652        // Should fail because database is not empty
2653        assert!(!preview.would_succeed);
2654        assert!(!preview.database_is_empty);
2655        assert!(preview.failure_reason.is_some());
2656        assert!(preview.failure_reason.unwrap().contains("not empty"));
2657    }
2658
2659    #[test]
2660    fn test_preview_replace_mode() {
2661        let db = Database::open_in_memory().unwrap();
2662
2663        // Create existing tasks
2664        use crate::config::StatesConfig;
2665        db.create_task(
2666            Some("existing-1".to_string()),
2667            "Existing 1".to_string(),
2668            None,
2669            None,
2670            None,
2671            None,
2672            None,
2673            None,
2674            None,
2675            None,
2676            None, // tags
2677            &StatesConfig::default(),
2678            &IdsConfig::default(),
2679        )
2680        .unwrap();
2681        db.create_task(
2682            Some("existing-2".to_string()),
2683            "Existing 2".to_string(),
2684            None,
2685            None,
2686            None,
2687            None,
2688            None,
2689            None,
2690            None,
2691            None,
2692            None, // tags
2693            &StatesConfig::default(),
2694            &IdsConfig::default(),
2695        )
2696        .unwrap();
2697
2698        // Create snapshot with different task
2699        let mut snapshot = Snapshot::new();
2700        snapshot.tables.insert(
2701            "tasks".to_string(),
2702            vec![json!({
2703                "id": "new-task",
2704                "title": "New Task",
2705                "description": null,
2706                "status": "pending",
2707                "priority": "5",
2708                "worker_id": null,
2709                "claimed_at": null,
2710                "needed_tags": null,
2711                "wanted_tags": null,
2712                "tags": "[]",
2713                "points": null,
2714                "time_estimate_ms": null,
2715                "time_actual_ms": null,
2716                "started_at": null,
2717                "completed_at": null,
2718                "current_thought": null,
2719                "metric_0": 0,
2720                "metric_1": 0,
2721                "metric_2": 0,
2722                "metric_3": 0,
2723                "metric_4": 0,
2724                "metric_5": 0,
2725                "metric_6": 0,
2726                "metric_7": 0,
2727                "cost_usd": 0.0,
2728                "deleted_at": null,
2729                "deleted_by": null,
2730                "deleted_reason": null,
2731                "created_at": 1700000000000_i64,
2732                "updated_at": 1700000000000_i64
2733            })],
2734        );
2735
2736        let options = ImportOptions::replace();
2737        let preview = db.preview_import(&snapshot, &options);
2738
2739        assert!(preview.would_succeed);
2740        assert!(!preview.database_is_empty);
2741        assert_eq!(preview.mode, ImportMode::Replace);
2742        // Would delete 2 existing tasks
2743        assert_eq!(preview.would_delete.get("tasks"), Some(&2));
2744        // Would insert 1 new task
2745        assert_eq!(preview.would_insert.get("tasks"), Some(&1));
2746        assert_eq!(preview.total_would_skip(), 0);
2747    }
2748
2749    #[test]
2750    fn test_preview_merge_mode() {
2751        let db = Database::open_in_memory().unwrap();
2752
2753        // Create existing task
2754        use crate::config::StatesConfig;
2755        db.create_task(
2756            Some("existing-task".to_string()),
2757            "Existing Task".to_string(),
2758            None,
2759            None,
2760            None,
2761            None,
2762            None,
2763            None,
2764            None,
2765            None,
2766            None, // tags
2767            &StatesConfig::default(),
2768            &IdsConfig::default(),
2769        )
2770        .unwrap();
2771
2772        // Create snapshot with existing and new tasks
2773        let mut snapshot = Snapshot::new();
2774        snapshot.tables.insert(
2775            "tasks".to_string(),
2776            vec![
2777                json!({
2778                    "id": "existing-task", // Will be skipped
2779                    "title": "Should Skip",
2780                    "description": null,
2781                    "status": "pending",
2782                    "priority": "5",
2783                    "worker_id": null,
2784                    "claimed_at": null,
2785                    "needed_tags": null,
2786                    "wanted_tags": null,
2787                    "tags": "[]",
2788                    "points": null,
2789                    "time_estimate_ms": null,
2790                    "time_actual_ms": null,
2791                    "started_at": null,
2792                    "completed_at": null,
2793                    "current_thought": null,
2794                    "metric_0": 0,
2795                    "metric_1": 0,
2796                    "metric_2": 0,
2797                    "metric_3": 0,
2798                    "metric_4": 0,
2799                    "metric_5": 0,
2800                    "metric_6": 0,
2801                    "metric_7": 0,
2802                    "cost_usd": 0.0,
2803                    "deleted_at": null,
2804                    "deleted_by": null,
2805                    "deleted_reason": null,
2806                    "created_at": 1700000000000_i64,
2807                    "updated_at": 1700000000000_i64
2808                }),
2809                json!({
2810                    "id": "new-task", // Will be inserted
2811                    "title": "New Task",
2812                    "description": null,
2813                    "status": "pending",
2814                    "priority": "5",
2815                    "worker_id": null,
2816                    "claimed_at": null,
2817                    "needed_tags": null,
2818                    "wanted_tags": null,
2819                    "tags": "[]",
2820                    "points": null,
2821                    "time_estimate_ms": null,
2822                    "time_actual_ms": null,
2823                    "started_at": null,
2824                    "completed_at": null,
2825                    "current_thought": null,
2826                    "metric_0": 0,
2827                    "metric_1": 0,
2828                    "metric_2": 0,
2829                    "metric_3": 0,
2830                    "metric_4": 0,
2831                    "metric_5": 0,
2832                    "metric_6": 0,
2833                    "metric_7": 0,
2834                    "cost_usd": 0.0,
2835                    "deleted_at": null,
2836                    "deleted_by": null,
2837                    "deleted_reason": null,
2838                    "created_at": 1700000000000_i64,
2839                    "updated_at": 1700000000000_i64
2840                }),
2841            ],
2842        );
2843
2844        let options = ImportOptions::merge();
2845        let preview = db.preview_import(&snapshot, &options);
2846
2847        assert!(preview.would_succeed);
2848        assert!(!preview.database_is_empty);
2849        assert_eq!(preview.mode, ImportMode::Merge);
2850        // Would skip 1 existing task
2851        assert_eq!(preview.would_skip.get("tasks"), Some(&1));
2852        // Would insert 1 new task
2853        assert_eq!(preview.would_insert.get("tasks"), Some(&1));
2854        // No deletions in merge mode
2855        assert_eq!(preview.total_would_delete(), 0);
2856    }
2857
2858    #[test]
2859    fn test_preview_schema_version_mismatch() {
2860        let db = Database::open_in_memory().unwrap();
2861        let mut snapshot = Snapshot::new();
2862        snapshot.schema_version = 999; // Invalid version
2863
2864        let options = ImportOptions::fresh();
2865        let preview = db.preview_import(&snapshot, &options);
2866
2867        assert!(!preview.would_succeed);
2868        assert!(preview.failure_reason.is_some());
2869        assert!(
2870            preview
2871                .failure_reason
2872                .unwrap()
2873                .contains("Schema version mismatch")
2874        );
2875    }
2876
2877    #[test]
2878    fn test_dry_run_result_totals() {
2879        let mut result = DryRunResult::new(ImportMode::Replace);
2880        result.existing_rows.insert("tasks".to_string(), 5);
2881        result.existing_rows.insert("dependencies".to_string(), 3);
2882        result.would_delete.insert("tasks".to_string(), 5);
2883        result.would_delete.insert("dependencies".to_string(), 3);
2884        result.would_insert.insert("tasks".to_string(), 2);
2885        result.would_skip.insert("attachments".to_string(), 1);
2886
2887        assert_eq!(result.total_existing(), 8);
2888        assert_eq!(result.total_would_delete(), 8);
2889        assert_eq!(result.total_would_insert(), 2);
2890        assert_eq!(result.total_would_skip(), 1);
2891    }
2892
2893    // ============================================================================
2894    // ID remapping tests
2895    // ============================================================================
2896
2897    #[test]
2898    fn test_remap_snapshot_generates_new_ids() {
2899        let mut snapshot = Snapshot::new();
2900        snapshot.tables.insert(
2901            "tasks".to_string(),
2902            vec![
2903                json!({
2904                    "id": "old-task-1",
2905                    "title": "Task 1",
2906                    "description": null,
2907                    "status": "pending",
2908                    "priority": "5",
2909                    "worker_id": null,
2910                    "claimed_at": null,
2911                    "needed_tags": null,
2912                    "wanted_tags": null,
2913                    "tags": "[]",
2914                    "points": null,
2915                    "time_estimate_ms": null,
2916                    "time_actual_ms": null,
2917                    "started_at": null,
2918                    "completed_at": null,
2919                    "current_thought": null,
2920                    "metric_0": 0, "metric_1": 0, "metric_2": 0, "metric_3": 0,
2921                    "metric_4": 0, "metric_5": 0, "metric_6": 0, "metric_7": 0,
2922                    "cost_usd": 0.0,
2923                    "deleted_at": null, "deleted_by": null, "deleted_reason": null,
2924                    "created_at": 1700000000000_i64,
2925                    "updated_at": 1700000000000_i64
2926                }),
2927                json!({
2928                    "id": "old-task-2",
2929                    "title": "Task 2",
2930                    "description": null,
2931                    "status": "pending",
2932                    "priority": "5",
2933                    "worker_id": null,
2934                    "claimed_at": null,
2935                    "needed_tags": null,
2936                    "wanted_tags": null,
2937                    "tags": "[]",
2938                    "points": null,
2939                    "time_estimate_ms": null,
2940                    "time_actual_ms": null,
2941                    "started_at": null,
2942                    "completed_at": null,
2943                    "current_thought": null,
2944                    "metric_0": 0, "metric_1": 0, "metric_2": 0, "metric_3": 0,
2945                    "metric_4": 0, "metric_5": 0, "metric_6": 0, "metric_7": 0,
2946                    "cost_usd": 0.0,
2947                    "deleted_at": null, "deleted_by": null, "deleted_reason": null,
2948                    "created_at": 1700000000000_i64,
2949                    "updated_at": 1700000000000_i64
2950                }),
2951            ],
2952        );
2953
2954        let ids_config = IdsConfig::default();
2955        let (remapped, id_map) = remap_snapshot(&snapshot, &ids_config).unwrap();
2956
2957        // Should have 2 entries in the mapping
2958        assert_eq!(id_map.len(), 2);
2959        assert!(id_map.contains_key("old-task-1"));
2960        assert!(id_map.contains_key("old-task-2"));
2961
2962        // New IDs should be different from old IDs
2963        assert_ne!(id_map["old-task-1"], "old-task-1");
2964        assert_ne!(id_map["old-task-2"], "old-task-2");
2965
2966        // New IDs should be unique
2967        assert_ne!(id_map["old-task-1"], id_map["old-task-2"]);
2968
2969        // Remapped snapshot tasks should have the new IDs
2970        let tasks = remapped.tables.get("tasks").unwrap();
2971        let task1_id = tasks[0].get("id").unwrap().as_str().unwrap();
2972        let task2_id = tasks[1].get("id").unwrap().as_str().unwrap();
2973        assert_eq!(task1_id, id_map["old-task-1"]);
2974        assert_eq!(task2_id, id_map["old-task-2"]);
2975    }
2976
2977    #[test]
2978    fn test_remap_snapshot_remaps_dependencies() {
2979        let mut snapshot = Snapshot::new();
2980        snapshot.tables.insert(
2981            "tasks".to_string(),
2982            vec![
2983                json!({
2984                    "id": "parent",
2985                    "title": "Parent",
2986                    "description": null,
2987                    "status": "pending",
2988                    "priority": "5",
2989                    "worker_id": null,
2990                    "claimed_at": null,
2991                    "needed_tags": null,
2992                    "wanted_tags": null,
2993                    "tags": "[]",
2994                    "points": null,
2995                    "time_estimate_ms": null,
2996                    "time_actual_ms": null,
2997                    "started_at": null,
2998                    "completed_at": null,
2999                    "current_thought": null,
3000                    "metric_0": 0, "metric_1": 0, "metric_2": 0, "metric_3": 0,
3001                    "metric_4": 0, "metric_5": 0, "metric_6": 0, "metric_7": 0,
3002                    "cost_usd": 0.0,
3003                    "deleted_at": null, "deleted_by": null, "deleted_reason": null,
3004                    "created_at": 1700000000000_i64,
3005                    "updated_at": 1700000000000_i64
3006                }),
3007                json!({
3008                    "id": "child",
3009                    "title": "Child",
3010                    "description": null,
3011                    "status": "pending",
3012                    "priority": "5",
3013                    "worker_id": null,
3014                    "claimed_at": null,
3015                    "needed_tags": null,
3016                    "wanted_tags": null,
3017                    "tags": "[]",
3018                    "points": null,
3019                    "time_estimate_ms": null,
3020                    "time_actual_ms": null,
3021                    "started_at": null,
3022                    "completed_at": null,
3023                    "current_thought": null,
3024                    "metric_0": 0, "metric_1": 0, "metric_2": 0, "metric_3": 0,
3025                    "metric_4": 0, "metric_5": 0, "metric_6": 0, "metric_7": 0,
3026                    "cost_usd": 0.0,
3027                    "deleted_at": null, "deleted_by": null, "deleted_reason": null,
3028                    "created_at": 1700000000000_i64,
3029                    "updated_at": 1700000000000_i64
3030                }),
3031            ],
3032        );
3033
3034        // Add a contains dependency (parent -> child) and a blocks dependency
3035        snapshot.tables.insert(
3036            "dependencies".to_string(),
3037            vec![
3038                json!({
3039                    "from_task_id": "parent",
3040                    "to_task_id": "child",
3041                    "dep_type": "contains"
3042                }),
3043                json!({
3044                    "from_task_id": "child",
3045                    "to_task_id": "parent",
3046                    "dep_type": "blocks"
3047                }),
3048            ],
3049        );
3050
3051        let ids_config = IdsConfig::default();
3052        let (remapped, id_map) = remap_snapshot(&snapshot, &ids_config).unwrap();
3053
3054        let new_parent = &id_map["parent"];
3055        let new_child = &id_map["child"];
3056
3057        // Verify dependencies reference the new IDs
3058        let deps = remapped.tables.get("dependencies").unwrap();
3059        assert_eq!(deps.len(), 2);
3060
3061        let dep0 = deps[0].as_object().unwrap();
3062        assert_eq!(dep0["from_task_id"].as_str().unwrap(), new_parent.as_str());
3063        assert_eq!(dep0["to_task_id"].as_str().unwrap(), new_child.as_str());
3064        assert_eq!(dep0["dep_type"].as_str().unwrap(), "contains");
3065
3066        let dep1 = deps[1].as_object().unwrap();
3067        assert_eq!(dep1["from_task_id"].as_str().unwrap(), new_child.as_str());
3068        assert_eq!(dep1["to_task_id"].as_str().unwrap(), new_parent.as_str());
3069        assert_eq!(dep1["dep_type"].as_str().unwrap(), "blocks");
3070    }
3071
3072    #[test]
3073    fn test_remap_snapshot_remaps_attachments_and_tags() {
3074        let mut snapshot = Snapshot::new();
3075        snapshot.tables.insert(
3076            "tasks".to_string(),
3077            vec![json!({
3078                "id": "my-task",
3079                "title": "My Task",
3080                "description": null,
3081                "status": "pending",
3082                "priority": "5",
3083                "worker_id": null,
3084                "claimed_at": null,
3085                "needed_tags": null,
3086                "wanted_tags": null,
3087                "tags": "[]",
3088                "points": null,
3089                "time_estimate_ms": null,
3090                "time_actual_ms": null,
3091                "started_at": null,
3092                "completed_at": null,
3093                "current_thought": null,
3094                "metric_0": 0, "metric_1": 0, "metric_2": 0, "metric_3": 0,
3095                "metric_4": 0, "metric_5": 0, "metric_6": 0, "metric_7": 0,
3096                "cost_usd": 0.0,
3097                "deleted_at": null, "deleted_by": null, "deleted_reason": null,
3098                "created_at": 1700000000000_i64,
3099                "updated_at": 1700000000000_i64
3100            })],
3101        );
3102        snapshot.tables.insert(
3103            "attachments".to_string(),
3104            vec![json!({
3105                "task_id": "my-task",
3106                "attachment_type": "note",
3107                "sequence": 1,
3108                "name": "test-note",
3109                "mime_type": "text/plain",
3110                "content": "Hello world",
3111                "file_path": null,
3112                "created_at": 1700000000000_i64
3113            })],
3114        );
3115        snapshot.tables.insert(
3116            "task_tags".to_string(),
3117            vec![json!({
3118                "task_id": "my-task",
3119                "tag": "rust"
3120            })],
3121        );
3122        snapshot.tables.insert(
3123            "task_needed_tags".to_string(),
3124            vec![json!({
3125                "task_id": "my-task",
3126                "tag": "implementer"
3127            })],
3128        );
3129        snapshot.tables.insert(
3130            "task_wanted_tags".to_string(),
3131            vec![json!({
3132                "task_id": "my-task",
3133                "tag": "code"
3134            })],
3135        );
3136        snapshot.tables.insert(
3137            "task_sequence".to_string(),
3138            vec![json!({
3139                "id": 1,
3140                "task_id": "my-task",
3141                "worker_id": null,
3142                "status": "pending",
3143                "phase": null,
3144                "reason": null,
3145                "timestamp": 1700000000000_i64,
3146                "end_timestamp": null
3147            })],
3148        );
3149
3150        let ids_config = IdsConfig::default();
3151        let (remapped, id_map) = remap_snapshot(&snapshot, &ids_config).unwrap();
3152        let new_id = &id_map["my-task"];
3153
3154        // Attachments should use new task_id
3155        let atts = remapped.tables.get("attachments").unwrap();
3156        assert_eq!(atts[0]["task_id"].as_str().unwrap(), new_id.as_str());
3157
3158        // Tags should use new task_id
3159        let tags = remapped.tables.get("task_tags").unwrap();
3160        assert_eq!(tags[0]["task_id"].as_str().unwrap(), new_id.as_str());
3161
3162        let needed = remapped.tables.get("task_needed_tags").unwrap();
3163        assert_eq!(needed[0]["task_id"].as_str().unwrap(), new_id.as_str());
3164
3165        let wanted = remapped.tables.get("task_wanted_tags").unwrap();
3166        assert_eq!(wanted[0]["task_id"].as_str().unwrap(), new_id.as_str());
3167
3168        // State history should use new task_id
3169        let events = remapped.tables.get("task_sequence").unwrap();
3170        assert_eq!(events[0]["task_id"].as_str().unwrap(), new_id.as_str());
3171    }
3172
3173    #[test]
3174    fn test_remap_snapshot_empty() {
3175        // Empty snapshot should produce empty mapping
3176        let snapshot = Snapshot::new();
3177        let ids_config = IdsConfig::default();
3178        let (remapped, id_map) = remap_snapshot(&snapshot, &ids_config).unwrap();
3179
3180        assert!(id_map.is_empty());
3181        assert!(remapped.tables.is_empty());
3182    }
3183
3184    #[test]
3185    fn test_remap_import_round_trip() {
3186        // Test that a remapped snapshot can be imported successfully
3187        let db = Database::open_in_memory().unwrap();
3188        let mut snapshot = Snapshot::new();
3189
3190        snapshot.tables.insert(
3191            "tasks".to_string(),
3192            vec![
3193                json!({
3194                    "id": "task-alpha",
3195                    "title": "Alpha Task",
3196                    "description": "First task",
3197                    "status": "pending",
3198                    "priority": "5",
3199                    "worker_id": null,
3200                    "claimed_at": null,
3201                    "needed_tags": null,
3202                    "wanted_tags": null,
3203                    "tags": "[]",
3204                    "points": null,
3205                    "time_estimate_ms": null,
3206                    "time_actual_ms": null,
3207                    "started_at": null,
3208                    "completed_at": null,
3209                    "current_thought": null,
3210                    "metric_0": 0, "metric_1": 0, "metric_2": 0, "metric_3": 0,
3211                    "metric_4": 0, "metric_5": 0, "metric_6": 0, "metric_7": 0,
3212                    "cost_usd": 0.0,
3213                    "deleted_at": null, "deleted_by": null, "deleted_reason": null,
3214                    "created_at": 1700000000000_i64,
3215                    "updated_at": 1700000000000_i64
3216                }),
3217                json!({
3218                    "id": "task-beta",
3219                    "title": "Beta Task",
3220                    "description": "Second task",
3221                    "status": "pending",
3222                    "priority": "3",
3223                    "worker_id": null,
3224                    "claimed_at": null,
3225                    "needed_tags": null,
3226                    "wanted_tags": null,
3227                    "tags": "[]",
3228                    "points": null,
3229                    "time_estimate_ms": null,
3230                    "time_actual_ms": null,
3231                    "started_at": null,
3232                    "completed_at": null,
3233                    "current_thought": null,
3234                    "metric_0": 0, "metric_1": 0, "metric_2": 0, "metric_3": 0,
3235                    "metric_4": 0, "metric_5": 0, "metric_6": 0, "metric_7": 0,
3236                    "cost_usd": 0.0,
3237                    "deleted_at": null, "deleted_by": null, "deleted_reason": null,
3238                    "created_at": 1700000000000_i64,
3239                    "updated_at": 1700000000000_i64
3240                }),
3241            ],
3242        );
3243        snapshot.tables.insert(
3244            "dependencies".to_string(),
3245            vec![json!({
3246                "from_task_id": "task-alpha",
3247                "to_task_id": "task-beta",
3248                "dep_type": "contains"
3249            })],
3250        );
3251
3252        // Remap IDs
3253        let ids_config = IdsConfig::default();
3254        let (remapped, id_map) = remap_snapshot(&snapshot, &ids_config).unwrap();
3255
3256        // Import the remapped snapshot
3257        let options = ImportOptions::fresh();
3258        let result = db.import_snapshot(&remapped, &options).unwrap();
3259
3260        assert_eq!(result.rows_imported.get("tasks"), Some(&2));
3261        assert_eq!(result.rows_imported.get("dependencies"), Some(&1));
3262
3263        // Verify tasks exist with new IDs
3264        let new_alpha = &id_map["task-alpha"];
3265        let new_beta = &id_map["task-beta"];
3266
3267        // Search for the tasks in the database
3268        let alpha_results = db.search_tasks("Alpha", None, 0, false, None).unwrap();
3269        assert_eq!(alpha_results.len(), 1);
3270        assert_eq!(alpha_results[0].task_id, *new_alpha);
3271
3272        let beta_results = db.search_tasks("Beta", None, 0, false, None).unwrap();
3273        assert_eq!(beta_results.len(), 1);
3274        assert_eq!(beta_results[0].task_id, *new_beta);
3275    }
3276
3277    // ============================================================================
3278    // Parent attachment tests
3279    // ============================================================================
3280
3281    #[test]
3282    fn test_snapshot_root_task_ids_all_roots() {
3283        // Snapshot with two tasks and no "contains" deps: both are roots
3284        let mut snapshot = Snapshot::new();
3285        snapshot.tables.insert(
3286            "tasks".to_string(),
3287            vec![
3288                json!({"id": "a", "title": "A"}),
3289                json!({"id": "b", "title": "B"}),
3290            ],
3291        );
3292        let roots = snapshot_root_task_ids(&snapshot);
3293        assert_eq!(roots.len(), 2);
3294        assert!(roots.contains(&"a".to_string()));
3295        assert!(roots.contains(&"b".to_string()));
3296    }
3297
3298    #[test]
3299    fn test_snapshot_root_task_ids_with_contains() {
3300        // "a" contains "b" -> only "a" is a root
3301        let mut snapshot = Snapshot::new();
3302        snapshot.tables.insert(
3303            "tasks".to_string(),
3304            vec![
3305                json!({"id": "a", "title": "A"}),
3306                json!({"id": "b", "title": "B"}),
3307            ],
3308        );
3309        snapshot.tables.insert(
3310            "dependencies".to_string(),
3311            vec![json!({"from_task_id": "a", "to_task_id": "b", "dep_type": "contains"})],
3312        );
3313        let roots = snapshot_root_task_ids(&snapshot);
3314        assert_eq!(roots.len(), 1);
3315        assert!(roots.contains(&"a".to_string()));
3316    }
3317
3318    #[test]
3319    fn test_snapshot_root_task_ids_non_contains_dep_ignored() {
3320        // "a" blocks "b" -> both are roots (only "contains" matters)
3321        let mut snapshot = Snapshot::new();
3322        snapshot.tables.insert(
3323            "tasks".to_string(),
3324            vec![
3325                json!({"id": "a", "title": "A"}),
3326                json!({"id": "b", "title": "B"}),
3327            ],
3328        );
3329        snapshot.tables.insert(
3330            "dependencies".to_string(),
3331            vec![json!({"from_task_id": "a", "to_task_id": "b", "dep_type": "blocks"})],
3332        );
3333        let roots = snapshot_root_task_ids(&snapshot);
3334        assert_eq!(roots.len(), 2);
3335    }
3336
3337    #[test]
3338    fn test_snapshot_root_task_ids_empty_snapshot() {
3339        let snapshot = Snapshot::new();
3340        let roots = snapshot_root_task_ids(&snapshot);
3341        assert!(roots.is_empty());
3342    }
3343
3344    /// Helper to create a full task JSON value for import tests.
3345    fn make_task_json(id: &str, title: &str) -> serde_json::Value {
3346        json!({
3347            "id": id,
3348            "title": title,
3349            "description": "",
3350            "status": "pending",
3351            "priority": "5",
3352            "worker_id": null,
3353            "claimed_at": null,
3354            "needed_tags": null,
3355            "wanted_tags": null,
3356            "tags": "[]",
3357            "points": null,
3358            "time_estimate_ms": null,
3359            "time_actual_ms": null,
3360            "started_at": null,
3361            "completed_at": null,
3362            "current_thought": null,
3363            "metric_0": 0, "metric_1": 0, "metric_2": 0, "metric_3": 0,
3364            "metric_4": 0, "metric_5": 0, "metric_6": 0, "metric_7": 0,
3365            "cost_usd": 0.0,
3366            "deleted_at": null, "deleted_by": null, "deleted_reason": null,
3367            "created_at": 1700000000000_i64,
3368            "updated_at": 1700000000000_i64
3369        })
3370    }
3371
3372    #[test]
3373    fn test_import_with_parent_attaches_root_tasks() {
3374        use crate::config::StatesConfig;
3375
3376        let db = Database::open_in_memory().unwrap();
3377
3378        // Pre-create the parent task in the database
3379        db.create_task(
3380            Some("parent-task".to_string()),
3381            "Parent".to_string(),
3382            None,
3383            None,
3384            None,
3385            None,
3386            None,
3387            None,
3388            None,
3389            None,
3390            None,
3391            &StatesConfig::default(),
3392            &IdsConfig::default(),
3393        )
3394        .unwrap();
3395
3396        // Create snapshot with two root tasks and one child
3397        let mut snapshot = Snapshot::new();
3398        snapshot.tables.insert(
3399            "tasks".to_string(),
3400            vec![
3401                make_task_json("root-a", "Root A"),
3402                make_task_json("root-b", "Root B"),
3403                make_task_json("child-c", "Child C"),
3404            ],
3405        );
3406        snapshot.tables.insert(
3407            "dependencies".to_string(),
3408            vec![
3409                json!({"from_task_id": "root-a", "to_task_id": "child-c", "dep_type": "contains"}),
3410            ],
3411        );
3412
3413        // Import with parent -- use merge mode since parent task already exists in DB
3414        let options = ImportOptions::merge().with_parent("parent-task".to_string());
3415        let result = db.import_snapshot(&snapshot, &options).unwrap();
3416
3417        // Verify root tasks were linked
3418        assert_eq!(result.parent_linked_roots.len(), 2);
3419        assert!(result.parent_linked_roots.contains(&"root-a".to_string()));
3420        assert!(result.parent_linked_roots.contains(&"root-b".to_string()));
3421        // child-c should NOT be in roots (it has a contains parent)
3422        assert!(!result.parent_linked_roots.contains(&"child-c".to_string()));
3423
3424        // Verify "contains" dependencies exist in DB
3425        let parent_a = db.get_parent("root-a").unwrap();
3426        assert_eq!(parent_a, Some("parent-task".to_string()));
3427
3428        let parent_b = db.get_parent("root-b").unwrap();
3429        assert_eq!(parent_b, Some("parent-task".to_string()));
3430
3431        // child-c should have root-a as parent (from the snapshot)
3432        let parent_c = db.get_parent("child-c").unwrap();
3433        assert_eq!(parent_c, Some("root-a".to_string()));
3434    }
3435
3436    #[test]
3437    fn test_import_with_parent_not_found_fails() {
3438        let db = Database::open_in_memory().unwrap();
3439
3440        let mut snapshot = Snapshot::new();
3441        snapshot.tables.insert(
3442            "tasks".to_string(),
3443            vec![make_task_json("task-x", "Task X")],
3444        );
3445
3446        // Import with nonexistent parent
3447        let options = ImportOptions::fresh().with_parent("nonexistent".to_string());
3448        let result = db.import_snapshot(&snapshot, &options);
3449
3450        assert!(result.is_err());
3451        let err_msg = result.unwrap_err().to_string();
3452        assert!(
3453            err_msg.contains("not found"),
3454            "Expected 'not found' in: {}",
3455            err_msg
3456        );
3457    }
3458
3459    #[test]
3460    fn test_import_without_parent_does_not_link() {
3461        let db = Database::open_in_memory().unwrap();
3462
3463        let mut snapshot = Snapshot::new();
3464        snapshot.tables.insert(
3465            "tasks".to_string(),
3466            vec![make_task_json("task-y", "Task Y")],
3467        );
3468
3469        // Import without parent
3470        let options = ImportOptions::fresh();
3471        let result = db.import_snapshot(&snapshot, &options).unwrap();
3472
3473        assert!(result.parent_linked_roots.is_empty());
3474
3475        // Verify no parent exists
3476        let parent = db.get_parent("task-y").unwrap();
3477        assert_eq!(parent, None);
3478    }
3479
3480    #[test]
3481    fn test_import_with_parent_and_empty_snapshot() {
3482        use crate::config::StatesConfig;
3483
3484        let db = Database::open_in_memory().unwrap();
3485
3486        // Pre-create the parent task
3487        db.create_task(
3488            Some("parent-task".to_string()),
3489            "Parent".to_string(),
3490            None,
3491            None,
3492            None,
3493            None,
3494            None,
3495            None,
3496            None,
3497            None,
3498            None,
3499            &StatesConfig::default(),
3500            &IdsConfig::default(),
3501        )
3502        .unwrap();
3503
3504        // Import empty snapshot with parent -- use merge since parent already exists
3505        let snapshot = Snapshot::new();
3506        let options = ImportOptions::merge().with_parent("parent-task".to_string());
3507        let result = db.import_snapshot(&snapshot, &options).unwrap();
3508
3509        // No roots to link
3510        assert!(result.parent_linked_roots.is_empty());
3511    }
3512
3513    #[test]
3514    fn test_import_options_with_parent_builder() {
3515        let options = ImportOptions::merge().with_parent("my-parent".to_string());
3516        assert_eq!(options.mode, ImportMode::Merge);
3517        assert_eq!(options.parent_id, Some("my-parent".to_string()));
3518        assert!(!options.remap_ids);
3519    }
3520}