1use std::collections::{BTreeMap, BTreeSet};
31
32#[derive(Debug, Clone, PartialEq, Eq, Default)]
39pub struct LayerView {
40 pub includes: Vec<Include>,
41 pub tools: Vec<String>,
43}
44
45pub fn view(bytes: &[u8]) -> Result<LayerView, ComposeError> {
49 let json: serde_json::Value =
50 serde_json::from_slice(bytes).map_err(|e| ComposeError::Unreadable(e.to_string()))?;
51 let mut v = LayerView::default();
52 let Some(entries) = json["manifests"].as_array() else {
53 return Ok(v);
54 };
55 for e in entries {
56 let ann = &e["annotations"];
57 let digest = e["digest"].as_str().unwrap_or_default().to_string();
58 match ann[crate::kind::ANN_KIND].as_str() {
59 Some("layer") => v.includes.push(Include {
65 digest,
66 realm: ann[ANN_INCLUDE_REALM]
67 .as_str()
68 .filter(|s| !s.is_empty())
69 .map(|s| s.to_string()),
70 layer: ann[ANN_INCLUDE_LAYER].as_str().map(|s| s.to_string()),
71 }),
72 None => {
74 if let Some(t) = ann["eu.pulseengine.tool"].as_str() {
75 v.tools.push(t.to_string());
76 }
77 }
78 Some(_) => {}
80 }
81 }
82 Ok(v)
83}
84
85pub const ANN_INCLUDE_REALM: &str = "eu.pulseengine.varve.include.realm";
88pub const ANN_INCLUDE_LAYER: &str = "eu.pulseengine.varve.include.layer";
91
92pub const MAX_DEPTH: usize = 8;
96
97#[derive(Debug, Clone, PartialEq, Eq)]
99pub struct Include {
100 pub digest: String,
102 pub realm: Option<String>,
104 pub layer: Option<String>,
106}
107
108#[derive(Debug, thiserror::Error)]
109pub enum ComposeError {
110 #[error(
111 "composition cycle: layer {digest} includes itself, directly or through \
112 {via} — refusing to follow it"
113 )]
114 Cycle { digest: String, via: String },
115 #[error(
116 "composition is more than {MAX_DEPTH} layers deep — refusing to walk further \
117 (a layer graph this deep is a mistake, not a design)"
118 )]
119 TooDeep,
120 #[error("layer manifest could not be read for composition: {0}")]
121 Unreadable(String),
122 #[error(
131 "tool '{tool}' is provided by more than one layer of this composition — {first} \
132 and {second} — and the pin has not chosen between them. varve does not pick a \
133 winner: what a bare name runs is decided by the pin, never by install order. \
134 {fix}"
135 )]
136 AmbiguousTool {
137 tool: String,
138 first: String,
139 second: String,
140 fix: String,
141 },
142 #[error(
146 "this project's pin selects '{selector}', but no layer of this composition from \
147 realm '{realm}' provides '{tool}' — it is provided by: {providers}. Fix the \
148 qualifier in varve.toml; varve will not substitute another realm's binary for \
149 the one the pin named."
150 )]
151 RealmProvidesNoSuchTool {
152 selector: String,
153 realm: String,
154 tool: String,
155 providers: String,
156 },
157 #[error(transparent)]
160 ConflictingPayload(#[from] Box<PayloadConflict>),
161}
162
163#[derive(Debug, thiserror::Error, PartialEq, Eq)]
166#[error(
167 "{name} {version} is offered by two layers in this composition with DIFFERENT bytes: \
168 {first} has {first_digest}, {second} has {second_digest} — refusing to choose. \
169 Two realms disagreeing about what one name-and-version IS cannot both be exported; \
170 a name at different VERSIONS is legal and both export, but one (name, version) must \
171 be one artifact. Re-deposit one of the layers against the other's bytes, or drop the \
172 duplicate from the composition."
173)]
174pub struct PayloadConflict {
175 pub name: String,
176 pub version: String,
177 pub first: String,
178 pub first_digest: String,
179 pub second: String,
180 pub second_digest: String,
181}
182
183#[derive(Debug, Clone, PartialEq, Eq)]
186pub struct PayloadOrigin {
187 pub name: String,
188 pub version: String,
189 pub digest: String,
191 pub realm: String,
195 pub layer: String,
196}
197
198impl PayloadOrigin {
199 fn describe(&self) -> String {
202 format!("realm '{}' layer {}", self.realm, self.layer)
203 }
204}
205
206pub fn union_payloads<T>(
224 items: Vec<(PayloadOrigin, T)>,
225) -> Result<Vec<(PayloadOrigin, T)>, ComposeError> {
226 let mut first_seen: BTreeMap<(String, String), PayloadOrigin> = BTreeMap::new();
227 let mut out = Vec::new();
228 for (origin, payload) in items {
229 let key = (origin.name.clone(), origin.version.clone());
230 match first_seen.get(&key) {
231 Some(first) if first.digest != origin.digest => {
232 return Err(ComposeError::ConflictingPayload(Box::new(
233 PayloadConflict {
234 name: origin.name.clone(),
235 version: origin.version.clone(),
236 first: first.describe(),
237 first_digest: first.digest.clone(),
238 second: origin.describe(),
239 second_digest: origin.digest,
240 },
241 )));
242 }
243 Some(_) => continue,
245 None => {
246 first_seen.insert(key, origin.clone());
247 out.push((origin, payload));
248 }
249 }
250 }
251 Ok(out)
252}
253
254pub fn includes(v: &LayerView) -> Vec<Include> {
256 v.includes.clone()
257}
258
259#[derive(Debug, Clone, PartialEq, Eq)]
266pub struct Walked {
267 pub digest: String,
268 pub realm: String,
272 pub view: LayerView,
273}
274
275pub fn walk<F>(
286 root_digest: &str,
287 root_realm: &str,
288 root: &LayerView,
289 mut fetch: F,
290) -> Result<Vec<Walked>, ComposeError>
291where
292 F: FnMut(&str) -> Option<LayerView>,
293{
294 let mut out = vec![Walked {
295 digest: root_digest.to_string(),
296 realm: root_realm.to_string(),
297 view: root.clone(),
298 }];
299 let mut emitted: BTreeSet<String> = BTreeSet::new();
300 emitted.insert(root_digest.to_string());
301 let mut stack: Vec<(String, String, LayerView, BTreeSet<String>)> = vec![(
307 root_digest.to_string(),
308 root_realm.to_string(),
309 root.clone(),
310 BTreeSet::from([root_digest.to_string()]),
311 )];
312 while let Some((from, realm, view, path)) = stack.pop() {
313 if path.len() > MAX_DEPTH {
314 return Err(ComposeError::TooDeep);
315 }
316 for inc in includes(&view) {
317 if path.contains(&inc.digest) {
318 return Err(ComposeError::Cycle {
319 digest: inc.digest.clone(),
320 via: from.clone(),
321 });
322 }
323 let Some(child) = fetch(&inc.digest) else {
324 continue;
326 };
327 let child_realm = inc.realm.clone().unwrap_or_else(|| realm.clone());
328 if emitted.insert(inc.digest.clone()) {
330 out.push(Walked {
331 digest: inc.digest.clone(),
332 realm: child_realm.clone(),
333 view: child.clone(),
334 });
335 }
336 let mut child_path = path.clone();
337 child_path.insert(inc.digest.clone());
338 stack.push((inc.digest.clone(), child_realm, child, child_path));
339 }
340 }
341 Ok(out)
342}
343
344#[derive(Debug, Clone, PartialEq, Eq)]
346pub struct ToolProvider {
347 pub tool: String,
348 pub realm: String,
351 pub layer: String,
353 pub digest: String,
355}
356
357impl ToolProvider {
358 pub fn qualified(&self) -> Option<String> {
362 (!self.realm.is_empty()).then(|| format!("{}/{}", self.realm, self.tool))
363 }
364
365 fn describe(&self) -> String {
368 if self.realm.is_empty() {
369 format!("layer {} (no realm named)", self.layer)
370 } else {
371 format!("realm '{}' layer {}", self.realm, self.layer)
372 }
373 }
374}
375
376pub fn select_tools(
390 providers: &[ToolProvider],
391 chosen: &BTreeMap<String, String>,
392) -> Result<BTreeMap<String, ToolProvider>, ComposeError> {
393 let mut by_name: BTreeMap<&str, Vec<&ToolProvider>> = BTreeMap::new();
394 for p in providers {
395 let slot = by_name.entry(p.tool.as_str()).or_default();
396 if !slot.iter().any(|q| q.digest == p.digest) {
399 slot.push(p);
400 }
401 }
402 let mut out = BTreeMap::new();
403 for (tool, offers) in by_name {
404 let picked: Vec<&ToolProvider> = match chosen.get(tool) {
405 Some(realm) => offers
406 .iter()
407 .copied()
408 .filter(|p| &p.realm == realm)
409 .collect(),
410 None => offers.clone(),
411 };
412 match picked.as_slice() {
413 [only] => {
414 out.insert(tool.to_string(), (*only).clone());
415 }
416 [] => {
417 let realm = chosen.get(tool).cloned().unwrap_or_default();
419 return Err(ComposeError::RealmProvidesNoSuchTool {
420 selector: format!("{realm}/{tool}"),
421 realm,
422 tool: tool.to_string(),
423 providers: offers
424 .iter()
425 .map(|p| p.describe())
426 .collect::<Vec<_>>()
427 .join(", "),
428 });
429 }
430 [first, second, ..] => {
431 return Err(ComposeError::AmbiguousTool {
432 tool: tool.to_string(),
433 first: first.describe(),
434 second: second.describe(),
435 fix: fix_for(tool, first, second, chosen.contains_key(tool)),
436 });
437 }
438 }
439 }
440 Ok(out)
441}
442
443fn fix_for(
446 tool: &str,
447 first: &ToolProvider,
448 second: &ToolProvider,
449 already_qualified: bool,
450) -> String {
451 match (first.qualified(), second.qualified()) {
452 (Some(a), Some(b)) if first.realm != second.realm => format!(
453 "Choose one in varve.toml: tools = [\"{a}\"] — or tools = [\"{b}\"]. The layer you \
454 do not choose stays installed and verified, and `varve run {a}` / `varve run {b}` \
455 still reach either one."
456 ),
457 _ if already_qualified || first.realm == second.realm => format!(
460 "Both are in realm '{}', so a realm qualifier cannot separate them — one of those \
461 two layers must stop exposing '{tool}', or pin the layer that provides the one \
462 you want directly.",
463 first.realm
464 ),
465 _ => format!(
466 "One of these layers belongs to no named realm, so there is no qualified form for \
467 it: define its realm in varve-realms.toml and name it in the pin's `realm`, then \
468 choose with tools = [\"<realm>/{tool}\"]."
469 ),
470 }
471}
472
473#[cfg(test)]
474mod tests {
475 use super::*;
476
477 fn manifest(layer: &str, tools: &[&str], includes: &[(&str, &str)]) -> LayerView {
479 let mut entries: Vec<String> = tools
480 .iter()
481 .map(|t| {
482 format!(
483 r#"{{"digest":"sha256:{t}","annotations":{{"eu.pulseengine.tool":"{t}"}}}}"#
484 )
485 })
486 .collect();
487 for (digest, realm) in includes {
488 entries.push(format!(
489 r#"{{"digest":"{digest}","annotations":{{"eu.pulseengine.varve.kind":"layer","{ANN_INCLUDE_REALM}":"{realm}"}}}}"#
490 ));
491 }
492 let json = format!(
493 r#"{{"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json",
494"artifactType":"application/vnd.pulseengine.varve.layer.v1+json",
495"annotations":{{"eu.pulseengine.varve.layer":"{layer}","eu.pulseengine.varve.channel":"qualified",
496"eu.pulseengine.varve.counter":"1","org.opencontainers.image.created":"2026-08-01T00:00:00Z"}},
497"manifests":[{}]}}"#,
498 entries.join(",")
499 );
500 let _ = layer;
501 view(json.as_bytes()).unwrap()
502 }
503
504 fn providers(walked: &[Walked]) -> Vec<ToolProvider> {
508 walked
509 .iter()
510 .flat_map(|w| {
511 w.view.tools.iter().map(|t| ToolProvider {
512 tool: t.clone(),
513 realm: w.realm.clone(),
514 layer: "2026.08.0".into(),
515 digest: w.digest.clone(),
516 })
517 })
518 .collect()
519 }
520
521 fn unchosen(walked: &[Walked]) -> Result<BTreeMap<String, ToolProvider>, ComposeError> {
524 select_tools(&providers(walked), &BTreeMap::new())
525 }
526
527 #[test]
529 fn a_composition_exposes_both_layers_tools() {
530 let upstream = manifest("2026.08.0", &["wasm-tools", "cargo-component"], &[]);
531 let root = manifest(
532 "2026.08.0",
533 &["rivet", "meld"],
534 &[("sha256:up", "bytecodealliance")],
535 );
536 let inc = includes(&root);
537 assert_eq!(inc.len(), 1);
538 assert_eq!(inc[0].digest, "sha256:up");
539 assert_eq!(inc[0].realm.as_deref(), Some("bytecodealliance"));
540
541 let layers = walk("sha256:root", "pulseengine", &root, |d| {
542 (d == "sha256:up").then(|| upstream.clone())
543 })
544 .unwrap();
545 assert_eq!(layers.len(), 2, "root plus the included layer");
546 assert_eq!(layers[0].realm, "pulseengine");
549 assert_eq!(layers[1].realm, "bytecodealliance");
550 let tools = unchosen(&layers).unwrap();
551 for t in ["rivet", "meld", "wasm-tools", "cargo-component"] {
553 assert!(tools.contains_key(t), "{t} missing from the composition");
554 }
555 assert_eq!(tools["wasm-tools"].digest, "sha256:up");
556 assert_eq!(tools["rivet"].digest, "sha256:root");
557 }
558
559 #[test]
561 fn a_tool_in_two_layers_is_an_error_not_a_silent_choice() {
562 let upstream = manifest("2026.08.0", &["wasm-tools"], &[]);
564 let root = manifest(
565 "2026.08.0",
566 &["wasm-tools"],
567 &[("sha256:up", "bytecodealliance")],
568 );
569 let layers = walk("sha256:root", "pulseengine", &root, |d| {
570 (d == "sha256:up").then(|| upstream.clone())
571 })
572 .unwrap();
573 match unchosen(&layers) {
574 Err(ComposeError::AmbiguousTool { tool, .. }) => assert_eq!(tool, "wasm-tools"),
575 other => panic!("expected AmbiguousTool, got {other:?}"),
576 }
577 }
578
579 #[test]
581 fn a_realm_qualifier_settles_a_collision_the_tools_filter_never_could() {
582 let upstream = manifest("2026.08.0", &["wasm-tools"], &[]);
586 let root = manifest(
587 "2026.09.0",
588 &["wasm-tools", "rivet"],
589 &[("sha256:up", "bytecodealliance")],
590 );
591 let layers = walk("sha256:root", "pulseengine", &root, |d| {
592 (d == "sha256:up").then(|| upstream.clone())
593 })
594 .unwrap();
595 let all = providers(&layers);
596
597 for (realm, digest) in [
598 ("bytecodealliance", "sha256:up"),
599 ("pulseengine", "sha256:root"),
600 ] {
601 let chosen = BTreeMap::from([("wasm-tools".to_string(), realm.to_string())]);
602 let picked = select_tools(&all, &chosen).unwrap();
603 assert_eq!(
604 picked["wasm-tools"].digest, digest,
605 "the pin chose realm '{realm}'"
606 );
607 assert_eq!(picked["wasm-tools"].realm, realm);
608 assert_eq!(picked["rivet"].digest, "sha256:root");
610 }
611 }
612
613 #[test]
615 fn a_qualifier_naming_a_realm_that_provides_nothing_is_refused_not_ignored() {
616 let upstream = manifest("2026.08.0", &["wasm-tools"], &[]);
620 let root = manifest(
621 "2026.09.0",
622 &["wasm-tools"],
623 &[("sha256:up", "bytecodealliance")],
624 );
625 let layers = walk("sha256:root", "pulseengine", &root, |d| {
626 (d == "sha256:up").then(|| upstream.clone())
627 })
628 .unwrap();
629 let chosen = BTreeMap::from([("wasm-tools".to_string(), "acme".to_string())]);
630 let err = select_tools(&providers(&layers), &chosen).unwrap_err();
631 let msg = err.to_string();
632 assert!(
633 matches!(err, ComposeError::RealmProvidesNoSuchTool { .. }),
634 "{msg}"
635 );
636 assert!(msg.contains("acme/wasm-tools"), "{msg}");
637 assert!(
639 msg.contains("pulseengine") && msg.contains("bytecodealliance"),
640 "{msg}"
641 );
642 }
643
644 #[test]
646 fn the_refusal_names_both_realms_and_shows_a_qualified_form_that_works() {
647 let upstream = manifest("2026.08.0", &["wasm-tools"], &[]);
651 let root = manifest(
652 "2026.09.0",
653 &["wasm-tools"],
654 &[("sha256:up", "bytecodealliance")],
655 );
656 let layers = walk("sha256:root", "pulseengine", &root, |d| {
657 (d == "sha256:up").then(|| upstream.clone())
658 })
659 .unwrap();
660 let msg = unchosen(&layers).unwrap_err().to_string();
661 assert!(
662 msg.contains("realm 'pulseengine'") && msg.contains("realm 'bytecodealliance'"),
663 "both providers must be named WITH their realms: {msg}"
664 );
665 assert!(
666 msg.contains("tools = [\"pulseengine/wasm-tools\"]")
667 && msg.contains("tools = [\"bytecodealliance/wasm-tools\"]"),
668 "both qualified forms must be there to copy: {msg}"
669 );
670 assert!(
671 !msg.contains("Restrict the pin's `tools`"),
672 "the advice that cannot work must be gone: {msg}"
673 );
674 }
675
676 #[test]
678 fn two_layers_of_one_realm_are_told_a_qualifier_cannot_help_them() {
679 let base = manifest("2026.08.0", &["wasm-tools"], &[]);
684 let root = manifest("2026.09.0", &["wasm-tools"], &[("sha256:base", "")]);
685 let layers = walk("sha256:root", "pulseengine", &root, |d| {
686 (d == "sha256:base").then(|| base.clone())
687 })
688 .unwrap();
689 let msg = unchosen(&layers).unwrap_err().to_string();
690 assert!(
691 msg.contains("a realm qualifier cannot separate them"),
692 "an unusable qualified form must not be offered: {msg}"
693 );
694 }
695
696 #[test]
698 fn one_layer_declaring_a_name_twice_is_not_a_collision_with_itself() {
699 let twice = vec![
703 ToolProvider {
704 tool: "rivet".into(),
705 realm: "pulseengine".into(),
706 layer: "2026.09.0".into(),
707 digest: "sha256:root".into(),
708 },
709 ToolProvider {
710 tool: "rivet".into(),
711 realm: "pulseengine".into(),
712 layer: "2026.09.0".into(),
713 digest: "sha256:root".into(),
714 },
715 ];
716 let picked = select_tools(&twice, &BTreeMap::new()).unwrap();
717 assert_eq!(picked["rivet"].digest, "sha256:root");
718 }
719
720 #[test]
722 fn an_include_inherits_the_including_realm_where_it_names_none() {
723 let base = manifest("2026.08.0", &["base"], &[]);
728 let root = manifest("2026.09.0", &["rivet"], &[("sha256:base", "")]);
729 let layers = walk("sha256:root", "pulseengine", &root, |d| {
730 (d == "sha256:base").then(|| base.clone())
731 })
732 .unwrap();
733 assert_eq!(layers[1].realm, "pulseengine");
734 let picked = unchosen(&layers).unwrap();
735 assert_eq!(
736 picked["base"].qualified().as_deref(),
737 Some("pulseengine/base")
738 );
739 }
740
741 #[test]
743 fn depth_is_bounded_so_a_long_chain_cannot_exhaust_the_walker() {
744 let leaf = manifest("2026.08.0", &["leaf"], &[]);
749 let chain: Vec<LayerView> = (0..=MAX_DEPTH + 2)
751 .map(|i| manifest("2026.08.0", &["t"], &[(&format!("sha256:{}", i + 1), "r")]))
752 .collect();
753 let err = walk("sha256:0", "r", &chain[0], |d: &str| {
754 let n: usize = d.trim_start_matches("sha256:").parse().ok()?;
755 chain.get(n).cloned().or_else(|| Some(leaf.clone()))
756 })
757 .unwrap_err();
758 assert!(matches!(err, ComposeError::TooDeep), "got {err:?}");
759 }
760
761 #[test]
763 fn a_chain_exactly_at_the_bound_is_walked_not_refused() {
764 let chain: Vec<LayerView> = (0..MAX_DEPTH)
769 .map(|i| {
770 let tool = format!("t{i}");
771 if i + 1 == MAX_DEPTH {
772 manifest("2026.08.0", &[&tool], &[])
773 } else {
774 manifest(
775 "2026.08.0",
776 &[&tool],
777 &[(&format!("sha256:{}", i + 1), "r")],
778 )
779 }
780 })
781 .collect();
782 let walked = walk("sha256:0", "r", &chain[0], |d: &str| {
783 let n: usize = d.trim_start_matches("sha256:").parse().ok()?;
784 chain.get(n).cloned()
785 })
786 .expect("a chain exactly MAX_DEPTH long is within the bound");
787 assert_eq!(walked.len(), MAX_DEPTH);
788 }
789
790 #[test]
792 fn a_collision_involving_a_layer_with_no_realm_says_there_is_no_qualified_form() {
793 let upstream = manifest("2026.08.0", &["wasm-tools"], &[]);
798 let root = manifest(
799 "2026.09.0",
800 &["wasm-tools"],
801 &[("sha256:up", "bytecodealliance")],
802 );
803 let layers = walk("sha256:root", "", &root, |d| {
804 (d == "sha256:up").then(|| upstream.clone())
805 })
806 .unwrap();
807 let msg = unchosen(&layers).unwrap_err().to_string();
808 assert!(
809 msg.contains("belongs to no named realm") && msg.contains("varve-realms.toml"),
810 "the refusal must name the fix that exists, not a qualified form that does not: {msg}"
811 );
812 assert!(
813 !msg.contains("Both are in realm"),
814 "these two are NOT in one realm; one has none: {msg}"
815 );
816 }
817
818 #[test]
820 fn a_diamond_is_walked_once_not_refused_as_a_cycle() {
821 let d = manifest("2026.08.0", &["base"], &[]);
826 let b = manifest("2026.08.0", &["b"], &[("sha256:d", "r")]);
827 let c = manifest("2026.08.0", &["c"], &[("sha256:d", "r")]);
828 let a = manifest("2026.08.0", &["a"], &[("sha256:b", "r"), ("sha256:c", "r")]);
829 let walked = walk("sha256:a", "r", &a, |q| match q {
830 "sha256:b" => Some(b.clone()),
831 "sha256:c" => Some(c.clone()),
832 "sha256:d" => Some(d.clone()),
833 _ => None,
834 })
835 .unwrap();
836 assert_eq!(walked.len(), 4, "A, B, C and D each once: {walked:?}");
837 let tools = unchosen(&walked).unwrap();
839 assert_eq!(tools["base"].digest, "sha256:d");
840 }
841
842 #[test]
844 fn a_cycle_is_refused_not_followed() {
845 let a = manifest("2026.08.0", &["x"], &[("sha256:b", "r")]);
847 let b = manifest("2026.08.0", &["y"], &[("sha256:a", "r")]);
848 let (ac, bc) = (a.clone(), b.clone());
849 let err = walk("sha256:a", "r", &a, move |d| match d {
850 "sha256:b" => Some(bc.clone()),
851 "sha256:a" => Some(ac.clone()),
852 _ => None,
853 })
854 .unwrap_err();
855 assert!(matches!(err, ComposeError::Cycle { .. }), "got {err:?}");
856 }
857
858 #[test]
860 fn an_uninstalled_include_is_skipped_for_the_caller_to_report() {
861 let root = manifest("2026.08.0", &["rivet"], &[("sha256:missing", "other")]);
864 let layers = walk("sha256:root", "r", &root, |_| None).unwrap();
865 assert_eq!(layers.len(), 1, "only the root resolved");
866 assert_eq!(
867 includes(&root).len(),
868 1,
869 "but the include is still declared"
870 );
871 }
872
873 fn offered(realm: &str, name: &str, version: &str, digest: &str) -> (PayloadOrigin, ()) {
875 (
876 PayloadOrigin {
877 name: name.into(),
878 version: version.into(),
879 digest: format!("sha256:{digest}"),
880 realm: realm.into(),
881 layer: "2026.08.0".into(),
882 },
883 (),
884 )
885 }
886
887 #[test]
889 fn two_versions_of_one_crate_both_export() {
890 let kept = union_payloads(vec![
895 offered("pulseengine", "serde", "1.0.200", "aa"),
896 offered("bytecodealliance", "serde", "1.0.210", "bb"),
897 ])
898 .unwrap();
899 assert_eq!(kept.len(), 2);
900 let mut vers: Vec<&str> = kept.iter().map(|(o, _)| o.version.as_str()).collect();
901 vers.sort();
902 assert_eq!(vers, ["1.0.200", "1.0.210"]);
903 }
904
905 #[test]
907 fn the_same_name_and_version_with_the_same_bytes_exports_once() {
908 let kept = union_payloads(vec![
912 offered("pulseengine", "cfg-if", "1.0.0", "aa"),
913 offered("bytecodealliance", "cfg-if", "1.0.0", "aa"),
914 ])
915 .unwrap();
916 assert_eq!(kept.len(), 1, "one copy of agreed bytes: {kept:?}");
917 assert_eq!(kept[0].0.realm, "pulseengine", "the first offer wins");
918 }
919
920 #[test]
922 fn the_same_name_and_version_with_different_bytes_names_both_realms() {
923 let err = union_payloads(vec![
928 offered("pulseengine", "cfg-if", "1.0.0", "aa"),
929 offered("bytecodealliance", "cfg-if", "1.0.0", "bb"),
930 ])
931 .unwrap_err();
932 let msg = err.to_string();
933 assert!(matches!(err, ComposeError::ConflictingPayload(_)), "{msg}");
934 assert!(msg.contains("cfg-if") && msg.contains("1.0.0"), "{msg}");
935 assert!(
936 msg.contains("pulseengine") && msg.contains("bytecodealliance"),
937 "both realms must be named: {msg}"
938 );
939 assert!(
940 msg.contains("sha256:aa") && msg.contains("sha256:bb"),
941 "both digests must be named: {msg}"
942 );
943 }
944
945 #[test]
947 fn a_layer_without_includes_composes_to_itself() {
948 let plain = manifest("2026.08.0", &["rivet", "meld"], &[]);
951 assert!(includes(&plain).is_empty());
952 let layers = walk("sha256:root", "r", &plain, |_| None).unwrap();
953 assert_eq!(layers.len(), 1);
954 assert_eq!(unchosen(&layers).unwrap().len(), 2);
955 }
956}