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