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