1use std::collections::{HashMap, HashSet};
2
3use gen_core::{HashId, Workspace};
4use gen_models::{
5 block_group::BlockGroup, changesets::ChangesetModels, db::OperationsConnection,
6 errors::OperationError, operations::Operation, session_operations::DependencyModels,
7 traits::Query,
8};
9use petgraph::Direction;
10use thiserror::Error;
11
12use crate::graph::{DiffGenGraph, get_diff_graph};
13
14#[derive(Debug, Error)]
15pub enum OperationDiffError {
16 #[error("No current operation is checked out.")]
17 NoCurrentOperation,
18 #[error("Operation {0} not found.")]
19 OperationMissing(HashId),
20 #[error("Unable to find path between {0} and {1}.")]
21 PathNotFound(HashId, HashId),
22 #[error("Missing changeset data for operation {0}.")]
23 MissingChangeset(HashId),
24 #[error(transparent)]
25 OperationError(#[from] OperationError),
26}
27
28#[derive(Clone, Debug)]
29pub struct BlockGroupDiff {
30 pub id: HashId,
31 pub block_group: Option<BlockGroup>,
32 pub graph: DiffGenGraph,
33}
34
35#[derive(Clone, Debug)]
36pub struct OperationDiff {
37 pub operations: Vec<HashId>,
38 pub dbs: HashMap<String, DbDiff>,
39}
40
41#[derive(Clone, Debug)]
42pub struct DbDiff {
43 pub db_path: String,
44 pub added_block_groups: Vec<BlockGroupDiff>,
45 pub removed_block_groups: Vec<BlockGroupDiff>,
46}
47
48#[derive(Clone, Debug, Default)]
49pub struct BlockGroupDiffs {
50 pub operations: Vec<HashId>,
51 pub block_group_diffs: Vec<BlockGroupDiff>,
52}
53
54fn build_operation_diffs(
55 operations_in_order: &[HashId],
56 added_graphs: &HashMap<String, BlockGroupDiffs>,
57 removed_graphs: &HashMap<String, BlockGroupDiffs>,
58) -> HashMap<String, OperationDiff> {
59 let mut db_paths = HashSet::new();
60 db_paths.extend(added_graphs.keys().cloned());
61 db_paths.extend(removed_graphs.keys().cloned());
62
63 let mut diffs = HashMap::new();
64 for db_path in db_paths {
65 let mut op_set = HashSet::new();
66 if let Some(diffs) = added_graphs.get(&db_path) {
67 op_set.extend(diffs.operations.iter().copied());
68 }
69 if let Some(diffs) = removed_graphs.get(&db_path) {
70 op_set.extend(diffs.operations.iter().copied());
71 }
72 let operations = operations_in_order
73 .iter()
74 .copied()
75 .filter(|hash| op_set.contains(hash))
76 .collect::<Vec<_>>();
77 let db_diff = DbDiff {
78 db_path: db_path.clone(),
79 added_block_groups: added_graphs
80 .get(&db_path)
81 .map(|diffs| diffs.block_group_diffs.clone())
82 .unwrap_or_default(),
83 removed_block_groups: removed_graphs
84 .get(&db_path)
85 .map(|diffs| diffs.block_group_diffs.clone())
86 .unwrap_or_default(),
87 };
88 let mut dbs = HashMap::new();
89 dbs.insert(db_path.clone(), db_diff);
90 diffs.insert(db_path, OperationDiff { operations, dbs });
91 }
92
93 diffs
94}
95
96pub fn collect_operation_diff(
97 workspace: &Workspace,
98 op_conn: &OperationsConnection,
99 from_hash: Option<HashId>,
100 to_hash: HashId,
101 db_path: Option<&str>,
102) -> Result<HashMap<String, OperationDiff>, OperationDiffError> {
103 let (operations_in_order, added_ops, removed_ops) = if let Some(from_hash) = from_hash {
104 if from_hash == to_hash {
105 return Ok(HashMap::new());
106 }
107
108 let path = Operation::get_path_between(op_conn, from_hash, to_hash);
109 if path.is_empty() {
110 return Err(OperationDiffError::PathNotFound(from_hash, to_hash));
111 }
112
113 let mut operations_in_order = vec![];
114 let mut added_ops = vec![];
115 let mut removed_ops = vec![];
116 for (src, direction, dest) in path {
117 let op_hash = match direction {
118 Direction::Outgoing => dest,
119 Direction::Incoming => src,
120 };
121 operations_in_order.push(op_hash);
122 match direction {
123 Direction::Outgoing => {
124 added_ops.push(op_hash);
125 }
126 Direction::Incoming => {
127 removed_ops.push(op_hash);
128 }
129 }
130 }
131
132 (operations_in_order, added_ops, removed_ops)
133 } else {
134 (vec![to_hash], vec![to_hash], vec![])
135 };
136
137 let added_graphs = build_block_group_diffs(workspace, op_conn, &added_ops, db_path)?;
138 let removed_graphs = build_block_group_diffs(workspace, op_conn, &removed_ops, db_path)?;
139
140 Ok(build_operation_diffs(
141 &operations_in_order,
142 &added_graphs,
143 &removed_graphs,
144 ))
145}
146
147fn build_block_group_diffs(
151 workspace: &Workspace,
152 op_conn: &OperationsConnection,
153 operations: &[HashId],
154 db_path: Option<&str>,
155) -> Result<HashMap<String, BlockGroupDiffs>, OperationDiffError> {
156 if operations.is_empty() {
157 return Ok(HashMap::new());
158 }
159
160 #[derive(Default)]
161 struct DbAccumulator {
162 operations: Vec<HashId>,
163 block_group_info: HashMap<HashId, BlockGroup>,
164 block_groups: HashSet<gen_models::block_group::BlockGroup>,
165 edges: HashSet<gen_models::edge::Edge>,
166 block_group_edges: HashSet<gen_models::block_group_edge::BlockGroupEdge>,
167 nodes: HashSet<gen_models::node::Node>,
168 sequences: HashSet<gen_models::sequence::Sequence>,
169 dep_edges: HashSet<gen_models::edge::Edge>,
170 dep_nodes: HashSet<gen_models::node::Node>,
171 dep_sequences: HashSet<gen_models::sequence::Sequence>,
172 }
173
174 let mut accumulators: HashMap<String, DbAccumulator> = HashMap::new();
175
176 for op_hash in operations {
177 let operation = Operation::get_by_id(op_conn, op_hash)
178 .ok_or_else(|| OperationDiffError::OperationMissing(*op_hash))?;
179 let changeset = operation.get_changeset(workspace);
180 if let Some(db_path) = db_path
181 && changeset.db_path != db_path
182 {
183 continue;
184 }
185 let changeset_db = changeset.db_path.clone();
186 let changeset = changeset.changes;
187 let dependencies = operation.get_changeset_dependencies(workspace);
188
189 let entry = accumulators.entry(changeset_db).or_default();
190 entry.operations.push(*op_hash);
191
192 for block_group in changeset
193 .block_groups
194 .iter()
195 .chain(dependencies.block_group.iter())
196 {
197 entry
198 .block_group_info
199 .entry(block_group.id)
200 .or_insert_with(|| block_group.clone());
201 }
202
203 entry.block_groups.extend(changeset.block_groups);
204 entry.edges.extend(changeset.edges);
205 entry.block_group_edges.extend(changeset.block_group_edges);
206 entry.nodes.extend(changeset.nodes);
207 entry.sequences.extend(changeset.sequences);
208 entry.dep_edges.extend(dependencies.edges);
209 entry.dep_nodes.extend(dependencies.nodes);
210 entry.dep_sequences.extend(dependencies.sequences);
211 }
212
213 let mut results = HashMap::new();
214 for (db_path, acc) in accumulators {
215 let merged_graphs = get_diff_graph(
216 &ChangesetModels {
217 block_groups: acc.block_groups.into_iter().collect(),
218 edges: acc.edges.into_iter().collect(),
219 block_group_edges: acc.block_group_edges.into_iter().collect(),
220 nodes: acc.nodes.into_iter().collect(),
221 sequences: acc.sequences.into_iter().collect(),
222 ..Default::default()
223 },
224 &DependencyModels {
225 edges: acc.dep_edges.into_iter().collect(),
226 nodes: acc.dep_nodes.into_iter().collect(),
227 sequences: acc.dep_sequences.into_iter().collect(),
228 ..Default::default()
229 },
230 );
231
232 let mut block_groups = merged_graphs
233 .into_iter()
234 .map(|(id, graph)| {
235 let block_group = acc.block_group_info.get(&id).cloned();
236 BlockGroupDiff {
237 id,
238 block_group,
239 graph,
240 }
241 })
242 .collect::<Vec<_>>();
243 block_groups.sort_by_key(|a| {
244 if let Some(bg) = &a.block_group {
245 (
246 bg.collection_name.clone(),
247 bg.sample_name
248 .clone()
249 .unwrap_or_else(|| "Reference".to_string()),
250 bg.name.clone(),
251 format!("{id}", id = a.id),
252 )
253 } else {
254 (
255 String::new(),
256 String::new(),
257 String::new(),
258 format!("{id}", id = a.id),
259 )
260 }
261 });
262 results.insert(
263 db_path,
264 BlockGroupDiffs {
265 operations: acc.operations,
266 block_group_diffs: block_groups,
267 },
268 );
269 }
270
271 Ok(results)
272}
273
274#[cfg(test)]
275mod tests {
276 use gen_core::{HashId, Strand};
277 use gen_models::{
278 block_group::BlockGroup,
279 block_group_edge::BlockGroupEdge,
280 changesets::{ChangesetModels, DatabaseChangeset, write_changeset},
281 edge::Edge,
282 node::Node,
283 operations::{Branch, Operation, OperationState},
284 sequence::{NewSequence, Sequence},
285 };
286
287 use super::*;
288 use crate::test_helpers::setup_gen;
289
290 fn get_db_diff<'a>(diffs: &'a HashMap<String, OperationDiff>, db_path: &str) -> &'a DbDiff {
291 diffs
292 .get(db_path)
293 .and_then(|diff| diff.dbs.get(db_path))
294 .expect("db diff")
295 }
296
297 fn base_dependencies(start_node: &Node, end_node: &Node) -> DependencyModels {
298 let mut start_sequence = Sequence::new()
299 .sequence_type("DNA")
300 .sequence("")
301 .name("start")
302 .build();
303 start_sequence.hash = start_node.sequence_hash;
304 let mut end_sequence = Sequence::new()
305 .sequence_type("DNA")
306 .sequence("")
307 .name("end")
308 .build();
309 end_sequence.hash = end_node.sequence_hash;
310 DependencyModels {
311 collections: vec![],
312 samples: vec![],
313 sequences: vec![start_sequence, end_sequence],
314 block_group: vec![],
315 nodes: vec![start_node.clone(), end_node.clone()],
316 edges: vec![],
317 paths: vec![],
318 accessions: vec![],
319 accession_edges: vec![],
320 }
321 }
322
323 fn simple_changeset(
324 block_group: &BlockGroup,
325 node: &Node,
326 seq: &Sequence,
327 start_node: &Node,
328 end_node: &Node,
329 ) -> (ChangesetModels, DependencyModels) {
330 let edges = vec![
331 Edge {
332 id: HashId::convert_str(&format!("{}-{}-start", block_group.id, node.id)),
333 source_node_id: start_node.id,
334 source_coordinate: 0,
335 source_strand: Strand::Forward,
336 target_node_id: node.id,
337 target_coordinate: 0,
338 target_strand: Strand::Forward,
339 },
340 Edge {
341 id: HashId::convert_str(&format!("{}-{}-end", block_group.id, node.id)),
342 source_node_id: node.id,
343 source_coordinate: seq.length,
344 source_strand: Strand::Forward,
345 target_node_id: end_node.id,
346 target_coordinate: 0,
347 target_strand: Strand::Forward,
348 },
349 ];
350 let block_group_edges = vec![
351 BlockGroupEdge {
352 id: HashId::convert_str(&format!("{}-{}-start-bge", block_group.id, node.id)),
353 block_group_id: block_group.id,
354 edge_id: edges[0].id,
355 chromosome_index: 0,
356 phased: 0,
357 created_on: 0,
358 },
359 BlockGroupEdge {
360 id: HashId::convert_str(&format!("{}-{}-end-bge", block_group.id, node.id)),
361 block_group_id: block_group.id,
362 edge_id: edges[1].id,
363 chromosome_index: 0,
364 phased: 0,
365 created_on: 0,
366 },
367 ];
368 let changeset = ChangesetModels {
369 collections: vec![],
370 samples: vec![],
371 sequences: vec![seq.clone()],
372 block_groups: vec![block_group.clone()],
373 nodes: vec![node.clone()],
374 edges,
375 block_group_edges,
376 paths: vec![],
377 path_edges: vec![],
378 accessions: vec![],
379 accession_edges: vec![],
380 accession_paths: vec![],
381 annotation_groups: vec![],
382 annotations: vec![],
383 annotation_group_samples: vec![],
384 };
385 let dependencies = base_dependencies(start_node, end_node);
386 (changeset, dependencies)
387 }
388
389 #[test]
390 fn one_operation_diff() {
391 let context = setup_gen();
392 let op_conn = context.operations().conn();
393 let workspace = context.workspace();
394 let start_node = Node::get_start_node();
395 let end_node = Node::get_end_node();
396
397 let base_op =
398 Operation::create(op_conn, "seed", &HashId::pad_str(1)).expect("create base op");
399
400 let seq_one = NewSequence::new()
401 .sequence_type("dna")
402 .sequence("AAAAA")
403 .name("one")
404 .build();
405 let node_one = Node {
406 id: HashId::pad_str(10),
407 sequence_hash: seq_one.hash,
408 };
409 let block_group = BlockGroup {
410 id: HashId::pad_str(3),
411 collection_name: "c".to_string(),
412 sample_name: Some("s".to_string()),
413 name: "bg".to_string(),
414 created_on: 0,
415 };
416
417 let head = Operation::create(op_conn, "add", &HashId::pad_str(2)).expect("create op");
418 let (changeset, dependencies) =
419 simple_changeset(&block_group, &node_one, &seq_one, &start_node, &end_node);
420 write_changeset(
421 workspace,
422 &head,
423 DatabaseChangeset {
424 db_path: "diff.db".to_string(),
425 changes: changeset,
426 },
427 &dependencies,
428 );
429
430 let diffs = collect_operation_diff(workspace, op_conn, Some(base_op.hash), head.hash, None)
431 .expect("diff");
432 let diff = diffs.get("diff.db").expect("diff db");
433 let db_diff = get_db_diff(&diffs, "diff.db");
434 assert_eq!(diff.operations, vec![head.hash]);
435 assert_eq!(db_diff.added_block_groups.len(), 1);
436 assert!(db_diff.removed_block_groups.is_empty());
437 let graph = &db_diff.added_block_groups[0].graph;
438 assert_eq!(graph.nodes().count(), 3);
439 assert_eq!(graph.all_edges().count(), 2);
440 }
441
442 #[test]
443 fn initial_operation_diff_contains_added_block_groups() {
444 let context = setup_gen();
445 let op_conn = context.operations().conn();
446 let workspace = context.workspace();
447 let start_node = Node::get_start_node();
448 let end_node = Node::get_end_node();
449
450 let seq_one = NewSequence::new()
451 .sequence_type("dna")
452 .sequence("AAAAA")
453 .name("one")
454 .build();
455 let node_one = Node {
456 id: HashId::pad_str(10),
457 sequence_hash: seq_one.hash,
458 };
459 let block_group = BlockGroup {
460 id: HashId::pad_str(3),
461 collection_name: "c".to_string(),
462 sample_name: Some("s".to_string()),
463 name: "bg".to_string(),
464 created_on: 0,
465 };
466
467 let head = Operation::create(op_conn, "add", &HashId::pad_str(2)).expect("create op");
468 let (changeset, dependencies) =
469 simple_changeset(&block_group, &node_one, &seq_one, &start_node, &end_node);
470 write_changeset(
471 workspace,
472 &head,
473 DatabaseChangeset {
474 db_path: "diff.db".to_string(),
475 changes: changeset,
476 },
477 &dependencies,
478 );
479
480 let diffs =
481 collect_operation_diff(workspace, op_conn, None, head.hash, None).expect("diff");
482 let diff = diffs.get("diff.db").expect("diff db");
483 let db_diff = get_db_diff(&diffs, "diff.db");
484 assert_eq!(diff.operations, vec![head.hash]);
485 assert_eq!(db_diff.added_block_groups.len(), 1);
486 assert!(db_diff.removed_block_groups.is_empty());
487 let graph = &db_diff.added_block_groups[0].graph;
488 assert_eq!(graph.nodes().count(), 3);
489 assert_eq!(graph.all_edges().count(), 2);
490 }
491
492 #[test]
493 fn merges_multiple_operations() {
494 let context = setup_gen();
495 let op_conn = context.operations().conn();
496 let workspace = context.workspace();
497 let start_node = Node::get_start_node();
498 let end_node = Node::get_end_node();
499
500 let op1 = Operation::create(op_conn, "seed", &HashId::pad_str(1)).expect("create base op");
501
502 let bg_one = BlockGroup {
503 id: HashId::pad_str(3),
504 collection_name: "c".to_string(),
505 sample_name: Some("s".to_string()),
506 name: "bg1".to_string(),
507 created_on: 0,
508 };
509 let seq_one = NewSequence::new()
510 .sequence_type("dna")
511 .sequence("AAAAA")
512 .name("one")
513 .build();
514 let node_one = Node {
515 id: HashId::pad_str(10),
516 sequence_hash: seq_one.hash,
517 };
518 let op2 = Operation::create(op_conn, "add", &HashId::pad_str(2)).expect("create op2");
519 let (changeset_one, dependencies_one) =
520 simple_changeset(&bg_one, &node_one, &seq_one, &start_node, &end_node);
521 write_changeset(
522 workspace,
523 &op2,
524 DatabaseChangeset {
525 db_path: "diff.db".to_string(),
526 changes: changeset_one,
527 },
528 &dependencies_one,
529 );
530
531 let bg_two = BlockGroup {
532 id: HashId::pad_str(4),
533 collection_name: "c".to_string(),
534 sample_name: Some("s".to_string()),
535 name: "bg2".to_string(),
536 created_on: 0,
537 };
538 let seq_two = NewSequence::new()
539 .sequence_type("dna")
540 .sequence("CCCCC")
541 .name("two")
542 .build();
543 let node_two = Node {
544 id: HashId::pad_str(11),
545 sequence_hash: seq_two.hash,
546 };
547 let op3 = Operation::create(op_conn, "add", &HashId::pad_str(3)).expect("create op3");
548 let (changeset_two, dependencies_two) =
549 simple_changeset(&bg_two, &node_two, &seq_two, &start_node, &end_node);
550 write_changeset(
551 workspace,
552 &op3,
553 DatabaseChangeset {
554 db_path: "diff.db".to_string(),
555 changes: changeset_two,
556 },
557 &dependencies_two,
558 );
559
560 let diffs = collect_operation_diff(workspace, op_conn, Some(op1.hash), op3.hash, None)
561 .expect("diff");
562 let diff = diffs.get("diff.db").expect("diff db");
563 let db_diff = get_db_diff(&diffs, "diff.db");
564 assert_eq!(diff.operations, vec![op2.hash, op3.hash]);
565 assert_eq!(db_diff.added_block_groups.len(), 2);
566 }
567
568 #[test]
569 fn diff_against_itself_is_empty() {
570 let context = setup_gen();
571 let op_conn = context.operations().conn();
572 let workspace = context.workspace();
573 let base = Operation::create(op_conn, "seed", &HashId::pad_str(1)).expect("create base op");
574 let diffs = collect_operation_diff(workspace, op_conn, Some(base.hash), base.hash, None)
575 .expect("diff");
576 assert!(diffs.is_empty());
577 }
578
579 #[test]
580 fn diffs_across_branches() {
581 let context = setup_gen();
582 let op_conn = context.operations().conn();
583 let workspace = context.workspace();
584 let start_node = Node::get_start_node();
585 let end_node = Node::get_end_node();
586
587 let base = Operation::create(op_conn, "seed", &HashId::pad_str(1)).expect("base op");
588
589 let main_block_group = BlockGroup {
590 id: HashId::pad_str(20),
591 collection_name: "c".to_string(),
592 sample_name: Some("s".to_string()),
593 name: "main".to_string(),
594 created_on: 0,
595 };
596 let main_seq = NewSequence::new()
597 .sequence_type("dna")
598 .sequence("AAAAA")
599 .name("main")
600 .build();
601 let main_node = Node {
602 id: HashId::pad_str(21),
603 sequence_hash: main_seq.hash,
604 };
605 let op_main = Operation::create(op_conn, "add", &HashId::pad_str(2)).expect("main op");
606 let (main_changeset, main_deps) = simple_changeset(
607 &main_block_group,
608 &main_node,
609 &main_seq,
610 &start_node,
611 &end_node,
612 );
613 write_changeset(
614 workspace,
615 &op_main,
616 DatabaseChangeset {
617 db_path: "diff.db".to_string(),
618 changes: main_changeset,
619 },
620 &main_deps,
621 );
622
623 let feature_branch = Branch::create_with_remote(op_conn, "feature", None).unwrap();
624 OperationState::set_branch(op_conn, &feature_branch.name);
625 OperationState::set_operation(op_conn, &base.hash);
626
627 let feature_block_group = BlockGroup {
628 id: HashId::pad_str(30),
629 collection_name: "c".to_string(),
630 sample_name: Some("s".to_string()),
631 name: "feature".to_string(),
632 created_on: 0,
633 };
634 let feature_seq = NewSequence::new()
635 .sequence_type("dna")
636 .sequence("CCCCC")
637 .name("feature")
638 .build();
639 let feature_node = Node {
640 id: HashId::pad_str(31),
641 sequence_hash: feature_seq.hash,
642 };
643 let op_feature =
644 Operation::create(op_conn, "add", &HashId::pad_str(3)).expect("feature op");
645 let (feature_changeset, feature_deps) = simple_changeset(
646 &feature_block_group,
647 &feature_node,
648 &feature_seq,
649 &start_node,
650 &end_node,
651 );
652 write_changeset(
653 workspace,
654 &op_feature,
655 DatabaseChangeset {
656 db_path: "diff.db".to_string(),
657 changes: feature_changeset,
658 },
659 &feature_deps,
660 );
661
662 let diffs = collect_operation_diff(
663 workspace,
664 op_conn,
665 Some(op_main.hash),
666 op_feature.hash,
667 None,
668 )
669 .expect("diff");
670 let diff = diffs.get("diff.db").expect("diff db");
671 let db_diff = get_db_diff(&diffs, "diff.db");
672 assert_eq!(diff.operations, vec![op_main.hash, op_feature.hash]);
673 assert_eq!(db_diff.added_block_groups.len(), 1);
674 assert_eq!(db_diff.removed_block_groups.len(), 1);
675 assert_eq!(db_diff.added_block_groups[0].id, feature_block_group.id);
676 assert_eq!(db_diff.removed_block_groups[0].id, main_block_group.id);
677 }
678
679 #[test]
680 fn filters_by_database_path() {
681 let context = setup_gen();
682 let op_conn = context.operations().conn();
683 let workspace = context.workspace();
684 let start_node = Node::get_start_node();
685 let end_node = Node::get_end_node();
686
687 let base = Operation::create(op_conn, "seed", &HashId::pad_str(1)).expect("base op");
688
689 let block_group_one = BlockGroup {
690 id: HashId::pad_str(40),
691 collection_name: "c".to_string(),
692 sample_name: Some("s".to_string()),
693 name: "db-one".to_string(),
694 created_on: 0,
695 };
696 let seq_one = NewSequence::new()
697 .sequence_type("dna")
698 .sequence("AAAAA")
699 .name("one")
700 .build();
701 let node_one = Node {
702 id: HashId::pad_str(41),
703 sequence_hash: seq_one.hash,
704 };
705 let op_one = Operation::create(op_conn, "add", &HashId::pad_str(2)).expect("op one");
706 let (changeset_one, deps_one) = simple_changeset(
707 &block_group_one,
708 &node_one,
709 &seq_one,
710 &start_node,
711 &end_node,
712 );
713 write_changeset(
714 workspace,
715 &op_one,
716 DatabaseChangeset {
717 db_path: "db-one.db".to_string(),
718 changes: changeset_one,
719 },
720 &deps_one,
721 );
722
723 let block_group_two = BlockGroup {
724 id: HashId::pad_str(50),
725 collection_name: "c".to_string(),
726 sample_name: Some("s".to_string()),
727 name: "db-two".to_string(),
728 created_on: 0,
729 };
730 let seq_two = NewSequence::new()
731 .sequence_type("dna")
732 .sequence("CCCCC")
733 .name("two")
734 .build();
735 let node_two = Node {
736 id: HashId::pad_str(51),
737 sequence_hash: seq_two.hash,
738 };
739 let op_two = Operation::create(op_conn, "add", &HashId::pad_str(3)).expect("op two");
740 let (changeset_two, deps_two) = simple_changeset(
741 &block_group_two,
742 &node_two,
743 &seq_two,
744 &start_node,
745 &end_node,
746 );
747 write_changeset(
748 workspace,
749 &op_two,
750 DatabaseChangeset {
751 db_path: "db-two.db".to_string(),
752 changes: changeset_two,
753 },
754 &deps_two,
755 );
756
757 let diffs = collect_operation_diff(
758 workspace,
759 op_conn,
760 Some(base.hash),
761 op_two.hash,
762 Some("db-one.db"),
763 )
764 .expect("diff");
765 let diff = diffs.get("db-one.db").expect("diff db");
766 let db_diff = get_db_diff(&diffs, "db-one.db");
767 assert_eq!(diff.operations, vec![op_one.hash]);
768 assert_eq!(db_diff.added_block_groups.len(), 1);
769 assert_eq!(db_diff.added_block_groups[0].id, block_group_one.id);
770
771 let diff_none = collect_operation_diff(
772 workspace,
773 op_conn,
774 Some(base.hash),
775 op_two.hash,
776 Some("missing.db"),
777 )
778 .expect("diff");
779 assert!(diff_none.is_empty());
780 }
781}