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