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