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