1use std::collections::{HashSet, VecDeque};
3
4use objects::{
5 object::{ChangeId, ContentHash, EntryType},
6 store::ObjectStore,
7};
8use serde::{Deserialize, Serialize};
9
10use crate::{ProtocolError, Result};
11
12#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
13pub enum ObjectId {
14 Hash(ContentHash),
15 ChangeId(ChangeId),
16}
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct ObjectInfo {
20 pub id: ObjectId,
21 pub obj_type: ObjectType,
22 pub size: u64,
23 pub delta_base: Option<ContentHash>,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
27pub struct PlannedObject {
28 pub id: ObjectId,
29 pub obj_type: ObjectType,
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
33pub enum ObjectType {
34 Blob,
35 Tree,
36 State,
37 Action,
38 Redaction,
44 StateVisibility,
51}
52
53#[derive(Debug, Clone, Default)]
54pub struct StateClosureOptions {
55 pub depth: Option<u32>,
56 pub exclude_states: Vec<ChangeId>,
57}
58
59pub fn enumerate_state_closure(
60 store: &impl ObjectStore,
61 state_id: ChangeId,
62) -> Result<Vec<ObjectInfo>> {
63 enumerate_state_closure_with_options(store, state_id, StateClosureOptions::default())
64}
65
66pub fn enumerate_state_closure_with_options(
67 store: &impl ObjectStore,
68 state_id: ChangeId,
69 options: StateClosureOptions,
70) -> Result<Vec<ObjectInfo>> {
71 let (excluded_states, excluded_hashes) = collect_excluded(store, &options.exclude_states)?;
72
73 let mut out = Vec::new();
74 let mut seen_states: HashSet<ChangeId> = HashSet::new();
75 let mut seen_hashes: HashSet<ContentHash> = HashSet::new();
76 let mut queue: VecDeque<(ChangeId, u32)> = VecDeque::new();
77 queue.push_back((state_id, 0));
78
79 while let Some((id, depth)) = queue.pop_front() {
80 if excluded_states.contains(&id) {
81 continue;
82 }
83 if !seen_states.insert(id) {
84 continue;
85 }
86
87 let state = store
88 .get_state(&id)?
89 .ok_or_else(|| ProtocolError::ObjectNotFound(id.to_string()))?;
90
91 let state_bytes = rmp_serde::to_vec_named(&state)?;
92 out.push(ObjectInfo {
93 id: ObjectId::ChangeId(id),
94 obj_type: ObjectType::State,
95 size: state_bytes.len() as u64,
96 delta_base: None,
97 });
98 emit_state_visibility_info(store, &id, &mut out)?;
99
100 if options.depth.map(|max| depth < max).unwrap_or(true) {
101 for parent in &state.parents {
102 queue.push_back((*parent, depth + 1));
103 }
104 }
105
106 enumerate_tree_closure_filtered(
107 store,
108 state.tree,
109 &excluded_hashes,
110 &mut seen_hashes,
111 &mut out,
112 )?;
113 if let Some(provenance_root) = state.provenance {
114 enumerate_tree_closure_filtered(
115 store,
116 provenance_root,
117 &excluded_hashes,
118 &mut seen_hashes,
119 &mut out,
120 )?;
121 }
122 if let Some(context_root) = state.context {
123 enumerate_tree_closure_filtered(
124 store,
125 context_root,
126 &excluded_hashes,
127 &mut seen_hashes,
128 &mut out,
129 )?;
130 }
131 }
132
133 Ok(out)
134}
135
136pub fn enumerate_state_closure_plan(
137 store: &impl ObjectStore,
138 state_id: ChangeId,
139) -> Result<Vec<PlannedObject>> {
140 enumerate_state_closure_plan_with_options(store, state_id, StateClosureOptions::default())
141}
142
143pub fn enumerate_state_closure_plan_with_options(
144 store: &impl ObjectStore,
145 state_id: ChangeId,
146 options: StateClosureOptions,
147) -> Result<Vec<PlannedObject>> {
148 let (excluded_states, excluded_hashes) = collect_excluded(store, &options.exclude_states)?;
149
150 let mut out = Vec::new();
151 let mut seen_states: HashSet<ChangeId> = HashSet::new();
152 let mut seen_hashes: HashSet<ContentHash> = HashSet::new();
153 let mut queue: VecDeque<(ChangeId, u32)> = VecDeque::new();
154 queue.push_back((state_id, 0));
155
156 while let Some((id, depth)) = queue.pop_front() {
157 if excluded_states.contains(&id) {
158 continue;
159 }
160 if !seen_states.insert(id) {
161 continue;
162 }
163
164 let state = store
165 .get_state(&id)?
166 .ok_or_else(|| ProtocolError::ObjectNotFound(id.to_string()))?;
167
168 out.push(PlannedObject {
169 id: ObjectId::ChangeId(id),
170 obj_type: ObjectType::State,
171 });
172 emit_state_visibility_plan(store, &id, &mut out)?;
173
174 if options.depth.map(|max| depth < max).unwrap_or(true) {
175 for parent in &state.parents {
176 queue.push_back((*parent, depth + 1));
177 }
178 }
179
180 enumerate_tree_plan_filtered(
181 store,
182 state.tree,
183 &excluded_hashes,
184 &mut seen_hashes,
185 &mut out,
186 )?;
187 if let Some(provenance_root) = state.provenance {
188 enumerate_tree_plan_filtered(
189 store,
190 provenance_root,
191 &excluded_hashes,
192 &mut seen_hashes,
193 &mut out,
194 )?;
195 }
196 if let Some(context_root) = state.context {
197 enumerate_tree_plan_filtered(
198 store,
199 context_root,
200 &excluded_hashes,
201 &mut seen_hashes,
202 &mut out,
203 )?;
204 }
205 }
206
207 Ok(out)
208}
209
210fn enumerate_tree_closure_filtered(
211 store: &impl ObjectStore,
212 tree_hash: ContentHash,
213 excluded: &HashSet<ContentHash>,
214 seen: &mut HashSet<ContentHash>,
215 out: &mut Vec<ObjectInfo>,
216) -> Result<()> {
217 if excluded.contains(&tree_hash) {
218 return Ok(());
219 }
220 if !seen.insert(tree_hash) {
221 return Ok(());
222 }
223
224 let tree = store
225 .get_tree(&tree_hash)?
226 .ok_or_else(|| ProtocolError::ObjectNotFound(tree_hash.to_hex()))?;
227
228 let tree_bytes = rmp_serde::to_vec_named(&tree)?;
229 out.push(ObjectInfo {
230 id: ObjectId::Hash(tree_hash),
231 obj_type: ObjectType::Tree,
232 size: tree_bytes.len() as u64,
233 delta_base: None,
234 });
235
236 for entry in tree.entries() {
237 match entry.entry_type {
238 EntryType::Blob => {
239 if excluded.contains(&entry.hash) {
240 continue;
241 }
242 if !seen.insert(entry.hash) {
243 continue;
244 }
245 let blob = store
246 .get_blob(&entry.hash)?
247 .ok_or_else(|| ProtocolError::ObjectNotFound(entry.hash.to_hex()))?;
248 out.push(ObjectInfo {
249 id: ObjectId::Hash(entry.hash),
250 obj_type: ObjectType::Blob,
251 size: blob.size() as u64,
252 delta_base: None,
253 });
254 emit_redaction_info(store, &entry.hash, out)?;
255 }
256 EntryType::Tree => {
257 enumerate_tree_closure_filtered(store, entry.hash, excluded, seen, out)?;
258 }
259 EntryType::Symlink => {
260 if excluded.contains(&entry.hash) {
261 continue;
262 }
263 if !seen.insert(entry.hash) {
264 continue;
265 }
266 let blob = store
267 .get_blob(&entry.hash)?
268 .ok_or_else(|| ProtocolError::ObjectNotFound(entry.hash.to_hex()))?;
269 out.push(ObjectInfo {
270 id: ObjectId::Hash(entry.hash),
271 obj_type: ObjectType::Blob,
272 size: blob.size() as u64,
273 delta_base: None,
274 });
275 emit_redaction_info(store, &entry.hash, out)?;
276 }
277 }
278 }
279
280 Ok(())
281}
282
283fn emit_state_visibility_info(
287 store: &impl ObjectStore,
288 state: &ChangeId,
289 out: &mut Vec<ObjectInfo>,
290) -> Result<()> {
291 if let Some(bytes) = store.get_state_visibility_bytes_for_state(state)? {
292 out.push(ObjectInfo {
293 id: ObjectId::ChangeId(*state),
294 obj_type: ObjectType::StateVisibility,
295 size: bytes.len() as u64,
296 delta_base: None,
297 });
298 }
299 Ok(())
300}
301
302fn emit_state_visibility_plan(
303 store: &impl ObjectStore,
304 state: &ChangeId,
305 out: &mut Vec<PlannedObject>,
306) -> Result<()> {
307 if store.has_state_visibility_for_state(state)? {
308 out.push(PlannedObject {
309 id: ObjectId::ChangeId(*state),
310 obj_type: ObjectType::StateVisibility,
311 });
312 }
313 Ok(())
314}
315
316fn emit_redaction_info(
326 store: &impl ObjectStore,
327 blob: &ContentHash,
328 out: &mut Vec<ObjectInfo>,
329) -> Result<()> {
330 if let Some(bytes) = store.get_redactions_bytes_for_blob(blob)? {
331 out.push(ObjectInfo {
332 id: ObjectId::Hash(*blob),
333 obj_type: ObjectType::Redaction,
334 size: bytes.len() as u64,
335 delta_base: None,
336 });
337 }
338 Ok(())
339}
340
341fn enumerate_tree_plan_filtered(
342 store: &impl ObjectStore,
343 tree_hash: ContentHash,
344 excluded: &HashSet<ContentHash>,
345 seen: &mut HashSet<ContentHash>,
346 out: &mut Vec<PlannedObject>,
347) -> Result<()> {
348 if excluded.contains(&tree_hash) {
349 return Ok(());
350 }
351 if !seen.insert(tree_hash) {
352 return Ok(());
353 }
354
355 let tree = store
356 .get_tree(&tree_hash)?
357 .ok_or_else(|| ProtocolError::ObjectNotFound(tree_hash.to_hex()))?;
358
359 out.push(PlannedObject {
360 id: ObjectId::Hash(tree_hash),
361 obj_type: ObjectType::Tree,
362 });
363
364 for entry in tree.entries() {
365 match entry.entry_type {
366 EntryType::Blob | EntryType::Symlink => {
367 if excluded.contains(&entry.hash) {
368 continue;
369 }
370 if !seen.insert(entry.hash) {
371 continue;
372 }
373 out.push(PlannedObject {
374 id: ObjectId::Hash(entry.hash),
375 obj_type: ObjectType::Blob,
376 });
377 emit_redaction_plan(store, &entry.hash, out)?;
378 }
379 EntryType::Tree => {
380 enumerate_tree_plan_filtered(store, entry.hash, excluded, seen, out)?;
381 }
382 }
383 }
384
385 Ok(())
386}
387
388fn emit_redaction_plan(
389 store: &impl ObjectStore,
390 blob: &ContentHash,
391 out: &mut Vec<PlannedObject>,
392) -> Result<()> {
393 if store.has_redactions_for_blob(blob)? {
394 out.push(PlannedObject {
395 id: ObjectId::Hash(*blob),
396 obj_type: ObjectType::Redaction,
397 });
398 }
399 Ok(())
400}
401
402fn collect_excluded(
403 store: &impl ObjectStore,
404 roots: &[ChangeId],
405) -> Result<(HashSet<ChangeId>, HashSet<ContentHash>)> {
406 if roots.is_empty() {
407 return Ok((HashSet::new(), HashSet::new()));
408 }
409
410 let mut excluded_states: HashSet<ChangeId> = HashSet::new();
411 let mut excluded_hashes: HashSet<ContentHash> = HashSet::new();
412 let mut queue: VecDeque<ChangeId> = VecDeque::new();
413
414 for id in roots {
415 queue.push_back(*id);
416 }
417
418 while let Some(id) = queue.pop_front() {
419 if !excluded_states.insert(id) {
420 continue;
421 }
422
423 let state = match store.get_state(&id)? {
424 Some(state) => state,
425 None => continue,
426 };
427
428 for parent in &state.parents {
429 queue.push_back(*parent);
430 }
431
432 collect_tree_hashes(store, state.tree, &mut excluded_hashes)?;
433 if let Some(provenance_root) = state.provenance {
434 collect_tree_hashes(store, provenance_root, &mut excluded_hashes)?;
435 }
436 if let Some(context_root) = state.context {
437 collect_tree_hashes(store, context_root, &mut excluded_hashes)?;
438 }
439 }
440
441 Ok((excluded_states, excluded_hashes))
442}
443
444fn collect_tree_hashes(
445 store: &impl ObjectStore,
446 tree_hash: ContentHash,
447 excluded: &mut HashSet<ContentHash>,
448) -> Result<()> {
449 if !excluded.insert(tree_hash) {
450 return Ok(());
451 }
452
453 let tree = match store.get_tree(&tree_hash)? {
454 Some(tree) => tree,
455 None => return Ok(()),
456 };
457
458 for entry in tree.entries() {
459 match entry.entry_type {
460 EntryType::Blob | EntryType::Symlink => {
461 excluded.insert(entry.hash);
462 }
463 EntryType::Tree => {
464 collect_tree_hashes(store, entry.hash, excluded)?;
465 }
466 }
467 }
468
469 Ok(())
470}
471
472pub fn is_ancestor(
473 store: &impl ObjectStore,
474 ancestor: ChangeId,
475 descendant: ChangeId,
476) -> Result<bool> {
477 if ancestor == descendant {
478 return Ok(true);
479 }
480
481 let mut seen: HashSet<ChangeId> = HashSet::new();
482 let mut queue: VecDeque<ChangeId> = VecDeque::new();
483 queue.push_back(descendant);
484
485 while let Some(id) = queue.pop_front() {
486 if !seen.insert(id) {
487 continue;
488 }
489 let state = match store.get_state(&id)? {
490 Some(s) => s,
491 None => return Ok(false),
492 };
493 for parent in state.parents {
494 if parent == ancestor {
495 return Ok(true);
496 }
497 queue.push_back(parent);
498 }
499 }
500
501 Ok(false)
502}
503
504#[cfg(test)]
505mod tests {
506 use std::collections::HashSet;
507
508 use chrono::Utc;
509 use objects::{
510 object::{
511 Attribution, Blob, ChangeId, Principal, Redaction, State, StateVisibility, Tree,
512 TreeEntry, VisibilityTier,
513 },
514 store::ObjectStore,
515 };
516 use repo::Repository;
517 use tempfile::TempDir;
518
519 use super::{
520 ObjectId, ObjectInfo, ObjectType, PlannedObject, StateClosureOptions,
521 enumerate_state_closure_plan_with_options, enumerate_state_closure_with_options,
522 };
523
524 fn pairs_from_full(objects: &[ObjectInfo]) -> HashSet<(ObjectId, ObjectType)> {
525 objects
526 .iter()
527 .map(|info| (info.id.clone(), info.obj_type))
528 .collect()
529 }
530
531 fn pairs_from_plan(objects: &[PlannedObject]) -> HashSet<(ObjectId, ObjectType)> {
532 objects
533 .iter()
534 .map(|info| (info.id.clone(), info.obj_type))
535 .collect()
536 }
537
538 fn assert_plan_parity(
539 repo: &Repository,
540 state_id: ChangeId,
541 options: StateClosureOptions,
542 ) -> HashSet<(ObjectId, ObjectType)> {
543 let full =
544 enumerate_state_closure_with_options(repo.store(), state_id, options.clone()).unwrap();
545 let plan =
546 enumerate_state_closure_plan_with_options(repo.store(), state_id, options).unwrap();
547
548 let full_pairs = pairs_from_full(&full);
549 let plan_pairs = pairs_from_plan(&plan);
550 assert_eq!(full_pairs, plan_pairs);
551 full_pairs
552 }
553
554 fn test_attribution() -> Attribution {
555 Attribution::human(Principal::new("Graph Tester", "graph@example.com"))
556 }
557
558 #[test]
559 fn lean_closure_planner_matches_object_info_ids_and_types() {
560 let temp = TempDir::new().unwrap();
561 let repo = Repository::init_default(temp.path()).unwrap();
562 std::fs::create_dir_all(temp.path().join("src")).unwrap();
563 std::fs::write(temp.path().join("README.md"), "hello\n").unwrap();
564 std::fs::write(temp.path().join("src/lib.rs"), "pub fn hi() {}\n").unwrap();
565 let state = repo.snapshot(Some("seed".to_string()), None).unwrap();
566
567 let full = enumerate_state_closure_with_options(
568 repo.store(),
569 state.change_id,
570 StateClosureOptions::default(),
571 )
572 .unwrap();
573 let lean = enumerate_state_closure_plan_with_options(
574 repo.store(),
575 state.change_id,
576 StateClosureOptions::default(),
577 )
578 .unwrap();
579
580 let full_pairs = full
581 .into_iter()
582 .map(|info| (info.id, info.obj_type))
583 .collect::<std::collections::HashSet<_>>();
584 let lean_pairs = lean
585 .into_iter()
586 .map(|info| (info.id, info.obj_type))
587 .collect::<std::collections::HashSet<_>>();
588
589 assert_eq!(full_pairs, lean_pairs);
590 assert!(
591 full_pairs
592 .iter()
593 .any(|(id, _)| matches!(id, ObjectId::ChangeId(_)))
594 );
595 }
596
597 #[test]
598 fn depth_and_exclude_options_match_between_full_and_plan() {
599 let temp = TempDir::new().unwrap();
600 let repo = Repository::init_default(temp.path()).unwrap();
601 let path = temp.path().join("story.txt");
602
603 std::fs::write(&path, "base\n").unwrap();
604 let base = repo.snapshot(Some("base".to_string()), None).unwrap();
605 std::fs::write(&path, "middle\n").unwrap();
606 let middle = repo.snapshot(Some("middle".to_string()), None).unwrap();
607 std::fs::write(&path, "tip\n").unwrap();
608 let tip = repo.snapshot(Some("tip".to_string()), None).unwrap();
609
610 let depth_zero = assert_plan_parity(
611 &repo,
612 tip.change_id,
613 StateClosureOptions {
614 depth: Some(0),
615 exclude_states: Vec::new(),
616 },
617 );
618 assert!(depth_zero.contains(&(ObjectId::ChangeId(tip.change_id), ObjectType::State)));
619 assert!(!depth_zero.contains(&(ObjectId::ChangeId(middle.change_id), ObjectType::State)));
620 assert!(!depth_zero.contains(&(ObjectId::ChangeId(base.change_id), ObjectType::State)));
621
622 let depth_one = assert_plan_parity(
623 &repo,
624 tip.change_id,
625 StateClosureOptions {
626 depth: Some(1),
627 exclude_states: Vec::new(),
628 },
629 );
630 assert!(depth_one.contains(&(ObjectId::ChangeId(tip.change_id), ObjectType::State)));
631 assert!(depth_one.contains(&(ObjectId::ChangeId(middle.change_id), ObjectType::State)));
632 assert!(!depth_one.contains(&(ObjectId::ChangeId(base.change_id), ObjectType::State)));
633
634 let exclude_middle = assert_plan_parity(
635 &repo,
636 tip.change_id,
637 StateClosureOptions {
638 depth: None,
639 exclude_states: vec![middle.change_id],
640 },
641 );
642 assert!(exclude_middle.contains(&(ObjectId::ChangeId(tip.change_id), ObjectType::State)));
643 assert!(
644 !exclude_middle.contains(&(ObjectId::ChangeId(middle.change_id), ObjectType::State))
645 );
646 assert!(!exclude_middle.contains(&(ObjectId::ChangeId(base.change_id), ObjectType::State)));
647 }
648
649 #[test]
650 fn shared_tree_and_blob_references_are_emitted_once() {
651 let temp = TempDir::new().unwrap();
652 let repo = Repository::init_default(temp.path()).unwrap();
653
654 let shared_blob = Blob::from("shared contents\n");
655 let shared_blob_hash = repo.store().put_blob(&shared_blob).unwrap();
656 let shared_tree = Tree::from_entries(vec![
657 TreeEntry::file("shared.txt", shared_blob_hash, false).unwrap(),
658 ]);
659 let shared_tree_hash = repo.store().put_tree(&shared_tree).unwrap();
660 let root = Tree::from_entries(vec![
661 TreeEntry::directory("left", shared_tree_hash).unwrap(),
662 TreeEntry::directory("right", shared_tree_hash).unwrap(),
663 ]);
664 let root_hash = repo.store().put_tree(&root).unwrap();
665 let state = State::new(root_hash, Vec::new(), test_attribution());
666 repo.store().put_state(&state).unwrap();
667
668 let full = enumerate_state_closure_with_options(
669 repo.store(),
670 state.change_id,
671 StateClosureOptions::default(),
672 )
673 .unwrap();
674 let plan = enumerate_state_closure_plan_with_options(
675 repo.store(),
676 state.change_id,
677 StateClosureOptions::default(),
678 )
679 .unwrap();
680
681 assert_eq!(
682 pairs_from_full(&full),
683 pairs_from_plan(&plan),
684 "full and lean closure enumerators must dedup the same objects"
685 );
686
687 assert_eq!(
688 full.iter()
689 .filter(|info| info.id == ObjectId::Hash(root_hash)
690 && info.obj_type == ObjectType::Tree)
691 .count(),
692 1
693 );
694 assert_eq!(
695 full.iter()
696 .filter(|info| info.id == ObjectId::Hash(shared_tree_hash)
697 && info.obj_type == ObjectType::Tree)
698 .count(),
699 1
700 );
701 assert_eq!(
702 full.iter()
703 .filter(|info| info.id == ObjectId::Hash(shared_blob_hash)
704 && info.obj_type == ObjectType::Blob)
705 .count(),
706 1
707 );
708 }
709
710 #[test]
715 fn enumerate_state_closure_emits_redaction_for_redacted_blob() {
716 let temp = TempDir::new().unwrap();
717 let repo = Repository::init_default(temp.path()).unwrap();
718 std::fs::write(temp.path().join("secret.toml"), "api_token = \"x\"\n").unwrap();
719 let state = repo.snapshot(Some("seed".to_string()), None).unwrap();
720
721 let tree = repo
723 .store()
724 .get_tree(&state.tree)
725 .unwrap()
726 .expect("tree present");
727 let blob_hash = tree
728 .iter()
729 .find(|e| e.name == "secret.toml")
730 .expect("entry present")
731 .hash;
732
733 let redaction = Redaction {
734 redacted_blob: blob_hash,
735 state: state.change_id,
736 path: "secret.toml".to_string(),
737 reason: "test leak".to_string(),
738 redactor: Principal {
739 name: "Tester".into(),
740 email: "tester@heddle.sh".into(),
741 },
742 redacted_at: Utc::now(),
743 signature: None,
744 purged_at: None,
745 supersedes: None,
746 };
747 repo.put_redaction(redaction).unwrap();
748
749 let full = enumerate_state_closure_with_options(
750 repo.store(),
751 state.change_id,
752 StateClosureOptions::default(),
753 )
754 .unwrap();
755 let plan = enumerate_state_closure_plan_with_options(
756 repo.store(),
757 state.change_id,
758 StateClosureOptions::default(),
759 )
760 .unwrap();
761
762 assert!(
763 full.iter()
764 .any(|info| info.obj_type == ObjectType::Redaction
765 && info.id == ObjectId::Hash(blob_hash)),
766 "full closure must include a Redaction entry for the redacted blob"
767 );
768 assert!(
769 plan.iter()
770 .any(|p| p.obj_type == ObjectType::Redaction && p.id == ObjectId::Hash(blob_hash)),
771 "plan closure must include a Redaction entry for the redacted blob"
772 );
773 }
774
775 #[test]
776 fn enumerate_state_closure_emits_state_visibility_for_visible_state() {
777 let temp = TempDir::new().unwrap();
778 let repo = Repository::init_default(temp.path()).unwrap();
779 std::fs::write(temp.path().join("README.md"), "hello\n").unwrap();
780 let state = repo.snapshot(Some("seed".to_string()), None).unwrap();
781
782 repo.put_state_visibility(StateVisibility {
783 state: state.change_id,
784 tier: VisibilityTier::Restricted {
785 scope_label: "security-embargo".into(),
786 },
787 embargo_until: None,
788 declarer: Principal {
789 name: "Tester".into(),
790 email: "tester@heddle.sh".into(),
791 },
792 declared_at: Utc::now(),
793 signature: None,
794 supersedes: None,
795 })
796 .unwrap();
797
798 let full = enumerate_state_closure_with_options(
799 repo.store(),
800 state.change_id,
801 StateClosureOptions::default(),
802 )
803 .unwrap();
804 let plan = enumerate_state_closure_plan_with_options(
805 repo.store(),
806 state.change_id,
807 StateClosureOptions::default(),
808 )
809 .unwrap();
810
811 assert!(
812 full.iter()
813 .any(|info| info.obj_type == ObjectType::StateVisibility
814 && info.id == ObjectId::ChangeId(state.change_id)),
815 "full closure must include a StateVisibility entry for the visible state"
816 );
817 assert!(
818 plan.iter()
819 .any(|p| p.obj_type == ObjectType::StateVisibility
820 && p.id == ObjectId::ChangeId(state.change_id)),
821 "plan closure must include a StateVisibility entry for the visible state"
822 );
823 }
824}