1use std::path::PathBuf;
14
15use crate::pin::Pin;
16use crate::store::{InstalledLayer, Store, StoreError};
17
18#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct Resolved {
21 pub layer: InstalledLayer,
22 pub tools: Vec<(String, PathBuf)>,
25 pub runners: std::collections::BTreeMap<String, RunnerContract>,
28}
29
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct RunnerContract {
32 pub tool: String,
33 pub args: Vec<String>,
34 pub arg_prefix: Option<String>,
35}
36
37#[derive(Debug, thiserror::Error)]
39pub enum ResolveError {
40 #[error("layer {layer} is not installed — run `varve install` in this project to lay it down")]
41 NotInstalled { layer: String },
42 #[error(
43 "pin digest {pinned} is not installed — run `varve install` in this project to lay it down"
44 )]
45 DigestNotInstalled { pinned: String },
46 #[error(
47 "pin names layer {named} but pins digest {pinned}, which is layer {found} — the name is a label, the digest is the artifact; refusing to guess. Fix the pin."
48 )]
49 NameDigestMismatch {
50 named: String,
51 pinned: String,
52 found: String,
53 },
54 #[error(
55 "layer {layer} is installed more than once under different digests ({count} entries) and the pin carries no digest to disambiguate — add `digest = \"sha256:…\"` to the pin"
56 )]
57 Ambiguous { layer: String, count: usize },
58 #[error(
59 "layer {layer} is installed but incomplete: missing {missing:?} — run `varve install` to repair it; refusing to fall back to PATH"
60 )]
61 PartialLayer { layer: String, missing: Vec<String> },
62 #[error(
63 "this project's pin restricts `tools` to {missing:?}, which layer {layer} does not \
64 contain. It exposes: {available}. Re-installing cannot help — the layer is complete, \
65 the pin asks for something that was never in it. Fix the `tools` list in varve.toml, \
66 or drop it to expose everything the layer carries."
67 )]
68 PinNamesUnknownTool {
69 layer: String,
70 missing: Vec<String>,
71 available: String,
72 },
73 #[error(transparent)]
74 Store(#[from] StoreError),
75 #[error(
76 "layer {layer} is installed on channel '{installed}', but this project's pin selects \
77 '{pinned}' — refusing. A qualified line carries a support window and qualification \
78 evidence; a rolling one carries neither. Install the {pinned} layer, or change the \
79 pin deliberately."
80 )]
81 ChannelMismatch {
82 layer: String,
83 installed: String,
84 pinned: String,
85 },
86 #[error(transparent)]
87 Compose(#[from] crate::compose::ComposeError),
88 #[error(
89 "layer {layer} composes layer {missing}{realm}, which is not installed — \
90 `varve install` it, then retry"
91 )]
92 IncludeNotInstalled {
93 layer: String,
94 missing: String,
95 realm: String,
96 },
97}
98
99pub fn resolve(pin: &Pin, store: &Store) -> Result<Resolved, ResolveError> {
102 let layer = match &pin.digest {
103 Some(digest) => {
104 let entry = store
105 .get(digest)?
106 .ok_or_else(|| ResolveError::DigestNotInstalled {
107 pinned: digest.clone(),
108 })?;
109 if entry.layer != pin.layer {
110 return Err(ResolveError::NameDigestMismatch {
111 named: pin.layer.to_string(),
112 pinned: digest.clone(),
113 found: entry.layer.to_string(),
114 });
115 }
116 entry
117 }
118 None => {
119 let matching: Vec<InstalledLayer> = store
120 .list()?
121 .into_iter()
122 .filter(|entry| entry.layer == pin.layer)
123 .collect();
124 match matching.len() {
125 0 => {
126 return Err(ResolveError::NotInstalled {
127 layer: pin.layer.to_string(),
128 });
129 }
130 1 => matching.into_iter().next().expect("len checked"),
131 count => {
132 return Err(ResolveError::Ambiguous {
133 layer: pin.layer.to_string(),
134 count,
135 });
136 }
137 }
138 }
139 };
140
141 if !layer.channel.is_empty() && layer.channel != pin.channel.as_str() {
148 return Err(ResolveError::ChannelMismatch {
149 layer: layer.layer.to_string(),
150 installed: layer.channel.clone(),
151 pinned: pin.channel.as_str().to_string(),
152 });
153 }
154
155 let composed: Vec<(String, std::path::PathBuf)> = compose_tools(&layer, store)?;
160
161 let tool_names: Vec<String> = match &pin.tools {
162 Some(subset) => subset.clone(),
163 None => {
164 let bin = layer.root.join("bin");
165 let mut names: Vec<String> = match std::fs::read_dir(&bin) {
166 Ok(entries) => entries
167 .filter_map(|e| e.ok())
168 .filter(|e| e.path().is_file())
169 .map(|e| e.file_name().to_string_lossy().into_owned())
170 .collect(),
171 Err(_) => Vec::new(),
172 };
173 names.extend(composed.iter().map(|(n, _)| n.clone()));
175 names.sort();
176 names.dedup();
177 names
178 }
179 };
180
181 let mut tools = Vec::new();
182 let mut missing = Vec::new();
183 for name in tool_names {
184 let own = store.tool_path(&layer, &name);
187 let from_composition = composed.iter().find(|(n, _)| n == &name);
188 match (own, from_composition) {
189 (Some(_), Some(_)) => {
190 return Err(ResolveError::Compose(
191 crate::compose::ComposeError::AmbiguousTool {
192 tool: name,
193 first: layer.digest.clone(),
194 second: "an included layer".into(),
195 },
196 ));
197 }
198 (Some(path), None) => tools.push((name, path)),
199 (None, Some((_, path))) => tools.push((name, path.clone())),
200 (None, None) => missing.push(name),
201 }
202 }
203 if !missing.is_empty() {
204 if pin.tools.is_some() {
210 let mut available: Vec<String> = store
211 .manifest_tool_names(&layer)
212 .unwrap_or_default()
213 .into_iter()
214 .chain(composed.iter().map(|(n, _)| n.clone()))
215 .collect();
216 available.sort();
217 available.dedup();
218 let unknown: Vec<String> = missing
219 .iter()
220 .filter(|m| !available.contains(m))
221 .cloned()
222 .collect();
223 if !unknown.is_empty() {
224 return Err(ResolveError::PinNamesUnknownTool {
225 layer: layer.layer.to_string(),
226 missing: unknown,
227 available: if available.is_empty() {
228 "(nothing)".into()
229 } else {
230 available.join(", ")
231 },
232 });
233 }
234 }
235 return Err(ResolveError::PartialLayer {
236 layer: layer.layer.to_string(),
237 missing,
238 });
239 }
240
241 let mut runners = std::collections::BTreeMap::new();
244 if let Ok(bytes) = std::fs::read(layer.root.join("layer.json"))
245 && let Ok(json) = serde_json::from_slice::<serde_json::Value>(&bytes)
246 && let Some(entries) = json["manifests"].as_array()
247 {
248 for entry in entries {
249 let ann = &entry["annotations"];
250 if let (Some(tool), Some(runner)) = (
251 ann["eu.pulseengine.tool"].as_str(),
252 ann[crate::bazel::ANN_RUNNER].as_str(),
253 ) {
254 runners.insert(
255 tool.to_string(),
256 RunnerContract {
257 tool: runner.to_string(),
258 args: ann[crate::bazel::ANN_RUNNER_ARGS]
259 .as_str()
260 .map(|a| a.split_whitespace().map(str::to_string).collect())
261 .unwrap_or_default(),
262 arg_prefix: ann[crate::bazel::ANN_RUNNER_ARG_PREFIX]
263 .as_str()
264 .map(str::to_string),
265 },
266 );
267 }
268 }
269 }
270 Ok(Resolved {
271 layer,
272 tools,
273 runners,
274 })
275}
276
277fn compose_tools(
282 layer: &InstalledLayer,
283 store: &Store,
284) -> Result<Vec<(String, std::path::PathBuf)>, ResolveError> {
285 let path = layer.root.join("layer.json");
286 let Ok(bytes) = std::fs::read(&path) else {
287 return Ok(Vec::new());
290 };
291 let root_view = crate::compose::view(&bytes)?;
295 if root_view.includes.is_empty() {
296 return Ok(Vec::new());
297 }
298 for inc in &root_view.includes {
301 if store.find_anywhere(&inc.digest)?.is_none() {
304 return Err(ResolveError::IncludeNotInstalled {
305 layer: layer.layer.to_string(),
306 missing: inc.layer.clone().unwrap_or_else(|| inc.digest.clone()),
307 realm: inc
308 .realm
309 .as_ref()
310 .map(|r| format!(" from realm '{r}'"))
311 .unwrap_or_default(),
312 });
313 }
314 }
315 let walked = crate::compose::walk(&layer.digest, &root_view, |digest| {
316 let (_, entry) = store.find_anywhere(digest).ok().flatten()?;
317 let bytes = std::fs::read(entry.root.join("layer.json")).ok()?;
318 crate::compose::view(&bytes).ok()
319 })?;
320 crate::compose::union_tools(&walked)?;
322
323 let mut out = Vec::new();
324 for (digest, _) in walked.iter().skip(1) {
325 let Some((owner, entry)) = store.find_anywhere(digest)? else {
326 continue;
327 };
328 let bin = entry.root.join("bin");
329 let Ok(rd) = std::fs::read_dir(&bin) else {
330 continue;
331 };
332 let mut names: Vec<String> = rd
333 .filter_map(|e| e.ok())
334 .filter(|e| e.path().is_file())
335 .map(|e| e.file_name().to_string_lossy().into_owned())
336 .collect();
337 names.sort();
338 for name in names {
339 if let Some(path) = owner.tool_path(&entry, &name) {
340 out.push((name, path));
341 }
342 }
343 }
344 Ok(out)
345}
346
347#[cfg(test)]
348mod tests {
349 use super::*;
350 use crate::pin::Pin;
351 use crate::store::{Store, fixtures, manifest_digest};
352
353 fn pin(toml: &str) -> Pin {
354 Pin::parse(toml, "varve.toml").unwrap()
355 }
356
357 fn qualified_pin(layer: &str) -> Pin {
358 pin(&format!(
359 "manifest-version = 1\n[toolchain]\nchannel = \"qualified\"\nlayer = \"{layer}\"\n"
360 ))
361 }
362
363 fn store() -> (tempfile::TempDir, Store) {
364 let tmp = tempfile::tempdir().unwrap();
365 let store = Store::at(tmp.path().join("varve-root"));
366 (tmp, store)
367 }
368
369 fn manifest_composing(layer: &str, tools: &[&str], include: &str) -> Vec<u8> {
371 let mut entries: Vec<String> = tools
372 .iter()
373 .map(|t| {
374 format!(
375 r#"{{"digest":"sha256:{t}","annotations":{{"eu.pulseengine.tool":"{t}"}}}}"#
376 )
377 })
378 .collect();
379 entries.push(format!(
380 r#"{{"digest":"{include}","annotations":{{"eu.pulseengine.varve.kind":"layer"}}}}"#
381 ));
382 format!(
383 r#"{{"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json","artifactType":"application/vnd.pulseengine.varve.layer.v1+json","annotations":{{"eu.pulseengine.varve.layer":"{layer}","eu.pulseengine.varve.channel":"qualified"}},"manifests":[{}]}}"#,
384 entries.join(",")
385 )
386 .into_bytes()
387 }
388
389 #[test]
391 fn a_pin_selecting_qualified_refuses_an_installed_rolling_layer() {
392 let (_tmp, store) = store();
399 store
400 .lay_down(
401 &fixtures::manifest("2026.07.0", "rolling"),
402 &[("synth", b"s")],
403 )
404 .unwrap();
405 let err = resolve(&qualified_pin("2026.07.0"), &store).unwrap_err();
406 match err {
407 ResolveError::ChannelMismatch {
408 installed, pinned, ..
409 } => {
410 assert_eq!(installed, "rolling");
411 assert_eq!(pinned, "qualified");
412 }
413 other => panic!("expected ChannelMismatch, got {other}"),
414 }
415 }
416
417 #[test]
419 fn a_matching_channel_still_resolves() {
420 let (_tmp, store) = store();
422 store
423 .lay_down(
424 &fixtures::manifest("2026.07.0", "qualified"),
425 &[("synth", b"s")],
426 )
427 .unwrap();
428 assert!(resolve(&qualified_pin("2026.07.0"), &store).is_ok());
429 }
430
431 #[test]
433 fn the_channel_refusal_names_both_channels_and_what_they_cost() {
434 let (_tmp, store) = store();
440 store
441 .lay_down(
442 &fixtures::manifest("2026.07.0", "rolling"),
443 &[("synth", b"s")],
444 )
445 .unwrap();
446 let msg = resolve(&qualified_pin("2026.07.0"), &store)
447 .unwrap_err()
448 .to_string();
449 assert!(msg.contains("rolling"), "names what is installed: {msg}");
450 assert!(msg.contains("qualified"), "names what is pinned: {msg}");
451 assert!(
453 msg.contains("support window"),
454 "says what qualified carries: {msg}"
455 );
456 assert!(
457 msg.contains("qualification evidence"),
458 "says what rolling lacks: {msg}"
459 );
460 }
461
462 #[test]
464 fn a_layer_predating_channel_annotations_is_not_refused() {
465 let (_tmp, store) = store();
470 store
471 .lay_down(
472 &fixtures::manifest_without_channel("2026.07.0"),
473 &[("synth", b"s")],
474 )
475 .unwrap();
476 let resolved = resolve(&qualified_pin("2026.07.0"), &store);
477 assert!(
478 resolved.is_ok(),
479 "a layer that states no channel contradicts no pin: {:?}",
480 resolved.err()
481 );
482 }
483
484 #[test]
486 fn resolve_returns_the_composed_layers_tools() {
487 let (_tmp, store) = store();
492 let up = store
493 .lay_down(
494 &fixtures::manifest("2026.08.0", "qualified"),
495 &[("wasm-tools", b"w")],
496 )
497 .unwrap();
498 store
499 .lay_down(
500 &manifest_composing("2026.07.0", &["rivet"], &up),
501 &[("rivet", b"r")],
502 )
503 .unwrap();
504 let resolved = resolve(&qualified_pin("2026.07.0"), &store).unwrap();
505 let names: Vec<&str> = resolved.tools.iter().map(|(n, _)| n.as_str()).collect();
506 assert!(names.contains(&"rivet"), "own tool missing: {names:?}");
507 assert!(
508 names.contains(&"wasm-tools"),
509 "composed tool missing — the composition resolved to nothing: {names:?}"
510 );
511 }
512
513 #[test]
515 fn resolve_refuses_a_tool_exposed_by_both_layers() {
516 let (_tmp, store) = store();
519 let up = store
520 .lay_down(
521 &fixtures::manifest("2026.08.0", "qualified"),
522 &[("wasm-tools", b"u")],
523 )
524 .unwrap();
525 store
526 .lay_down(
527 &manifest_composing("2026.07.0", &["wasm-tools"], &up),
528 &[("wasm-tools", b"r")],
529 )
530 .unwrap();
531 let err = resolve(&qualified_pin("2026.07.0"), &store).unwrap_err();
532 assert!(
533 matches!(err, ResolveError::Compose(_)),
534 "a tool in two layers must be refused, got {err}"
535 );
536 }
537
538 #[test]
540 fn a_pin_restricting_tools_also_restricts_the_composition() {
541 let (_tmp, store) = store();
544 let up = store
545 .lay_down(
546 &fixtures::manifest("2026.08.0", "qualified"),
547 &[("wasm-tools", b"w"), ("wkg", b"k")],
548 )
549 .unwrap();
550 store
551 .lay_down(
552 &manifest_composing("2026.07.0", &["rivet"], &up),
553 &[("rivet", b"r")],
554 )
555 .unwrap();
556 let pinned = pin(
557 "manifest-version = 1\n[toolchain]\nchannel = \"qualified\"\nlayer = \"2026.07.0\"\ntools = [\"rivet\", \"wasm-tools\"]\n",
558 );
559 let resolved = resolve(&pinned, &store).unwrap();
560 let names: Vec<&str> = resolved.tools.iter().map(|(n, _)| n.as_str()).collect();
561 assert!(
562 names.contains(&"wasm-tools"),
563 "selected composed tool: {names:?}"
564 );
565 assert!(
566 !names.contains(&"wkg"),
567 "the pin did not select wkg, so the composition must not add it: {names:?}"
568 );
569 }
570
571 #[test]
573 fn resolves_the_pinned_layer_by_name() {
574 let (_tmp, store) = store();
575 store
576 .lay_down(
577 &fixtures::manifest("2026.07.0", "qualified"),
578 &[("synth", b"s"), ("rivet", b"r")],
579 )
580 .unwrap();
581 let resolved = resolve(&qualified_pin("2026.07.0"), &store).unwrap();
582 assert_eq!(resolved.layer.layer.to_string(), "2026.07.0");
583 let names: Vec<&str> = resolved.tools.iter().map(|(n, _)| n.as_str()).collect();
584 assert_eq!(names, ["rivet", "synth"], "all tools, stable order");
585 }
586
587 #[test]
589 fn missing_layer_fails_with_the_corrective_command() {
590 let (_tmp, store) = store();
591 let err = resolve(&qualified_pin("2026.07.0"), &store).unwrap_err();
592 assert!(matches!(&err, ResolveError::NotInstalled { layer } if layer == "2026.07.0"));
593 assert!(
594 err.to_string().contains("varve install"),
595 "error must carry the fix: {err}"
596 );
597 }
598
599 #[test]
601 fn partial_layer_is_an_error_not_a_fallback() {
602 let (_tmp, store) = store();
607 store
608 .lay_down(
609 &crate::manifest::fixtures::manifest_with_tools(
610 "2026.07.0",
611 "qualified",
612 1,
613 "2026-07-01T00:00:00Z",
614 &[("rivet", "sha256:aa"), ("synth", "sha256:bb")],
615 ),
616 &[("rivet", b"r")],
617 )
618 .unwrap();
619 let p = pin(
620 "manifest-version = 1\n[toolchain]\nchannel = \"qualified\"\nlayer = \"2026.07.0\"\ntools = [\"rivet\", \"synth\"]\n",
621 );
622 match resolve(&p, &store).unwrap_err() {
623 ResolveError::PartialLayer { missing, .. } => {
624 assert_eq!(missing, vec!["synth".to_string()]);
625 }
626 other => panic!("expected PartialLayer, got: {other}"),
627 }
628 }
629
630 #[test]
632 fn a_pin_naming_a_tool_the_layer_never_had_is_not_told_to_reinstall() {
633 let (_tmp, store) = store();
639 store
640 .lay_down(
641 &crate::manifest::fixtures::manifest_with_tools(
642 "2026.07.0",
643 "qualified",
644 1,
645 "2026-07-01T00:00:00Z",
646 &[("rivet", "sha256:aa")],
647 ),
648 &[("rivet", b"r")],
649 )
650 .unwrap();
651 let p = pin(
652 "manifest-version = 1\n[toolchain]\nchannel = \"qualified\"\nlayer = \"2026.07.0\"\ntools = [\"notathing\"]\n",
653 );
654 match resolve(&p, &store).unwrap_err() {
655 ResolveError::PinNamesUnknownTool {
656 missing, available, ..
657 } => {
658 assert_eq!(missing, vec!["notathing".to_string()]);
659 assert!(
660 available.contains("rivet"),
661 "names what IS there: {available}"
662 );
663 }
664 other => panic!("expected PinNamesUnknownTool, got: {other}"),
665 }
666 }
667
668 #[test]
670 fn pinned_digest_wins_and_a_mismatching_name_is_a_hard_failure() {
671 let (_tmp, store) = store();
672 let july = fixtures::manifest("2026.07.0", "qualified");
673 store.lay_down(&july, &[("synth", b"s")]).unwrap();
674 let d_july = manifest_digest(&july);
675
676 let hex = d_july.strip_prefix("sha256:").unwrap();
678 let p = pin(&format!(
679 "manifest-version = 1\n[toolchain]\nchannel = \"qualified\"\nlayer = \"2026.08.0\"\ndigest = \"sha256:{hex}\"\n"
680 ));
681 let err = resolve(&p, &store).unwrap_err();
682 assert!(
683 matches!(&err, ResolveError::NameDigestMismatch { named, found, .. }
684 if named == "2026.08.0" && found == "2026.07.0"),
685 "got: {err}"
686 );
687 }
688
689 #[test]
691 fn matching_digest_pin_resolves() {
692 let (_tmp, store) = store();
693 let july = fixtures::manifest("2026.07.0", "qualified");
694 store.lay_down(&july, &[("synth", b"s")]).unwrap();
695 let hex = manifest_digest(&july)
696 .strip_prefix("sha256:")
697 .unwrap()
698 .to_string();
699 let p = pin(&format!(
700 "manifest-version = 1\n[toolchain]\nchannel = \"qualified\"\nlayer = \"2026.07.0\"\ndigest = \"sha256:{hex}\"\n"
701 ));
702 let resolved = resolve(&p, &store).unwrap();
703 assert_eq!(resolved.layer.layer.to_string(), "2026.07.0");
704 }
705
706 #[test]
708 fn a_newer_layer_in_the_core_cannot_change_what_a_pin_resolves_to() {
709 let (_tmp, store) = store();
710 store
711 .lay_down(
712 &fixtures::manifest("2026.07.0", "qualified"),
713 &[("synth", b"july")],
714 )
715 .unwrap();
716 let p = qualified_pin("2026.07.0");
717 let before = resolve(&p, &store).unwrap();
718
719 store
721 .lay_down(
722 &fixtures::manifest("2026.08.0", "qualified"),
723 &[("synth", b"august")],
724 )
725 .unwrap();
726 let after = resolve(&p, &store).unwrap();
727 assert_eq!(
728 before, after,
729 "resolution is a pure function of (pin, store entry)"
730 );
731 assert_eq!(after.layer.layer.to_string(), "2026.07.0");
732 }
733
734 #[test]
736 fn ambiguous_name_fails_closed_instead_of_choosing() {
737 let (_tmp, store) = store();
738 let a = fixtures::manifest("2026.07.0", "qualified");
741 let mut b = a.clone();
742 b.extend_from_slice(b"\n");
743 store.lay_down(&a, &[]).unwrap();
744 store.lay_down(&b, &[]).unwrap();
745 let err = resolve(&qualified_pin("2026.07.0"), &store).unwrap_err();
746 assert!(
747 matches!(&err, ResolveError::Ambiguous { count: 2, .. }),
748 "got: {err}"
749 );
750 }
751
752 #[test]
754 fn resolution_and_listing_never_write_to_the_core() {
755 fn tree_snapshot(root: &std::path::Path) -> Vec<(String, Vec<u8>)> {
756 let mut out = Vec::new();
757 if !root.exists() {
758 return out;
759 }
760 let mut stack = vec![root.to_path_buf()];
761 while let Some(dir) = stack.pop() {
762 let mut entries: Vec<_> = std::fs::read_dir(&dir)
763 .unwrap()
764 .map(|e| e.unwrap().path())
765 .collect();
766 entries.sort();
767 for path in entries {
768 if path.is_dir() {
769 stack.push(path);
770 } else {
771 out.push((path.display().to_string(), std::fs::read(&path).unwrap()));
772 }
773 }
774 }
775 out.sort();
776 out
777 }
778
779 let (_tmp, store) = store();
780 store
781 .lay_down(
782 &fixtures::manifest("2026.07.0", "qualified"),
783 &[("synth", b"s")],
784 )
785 .unwrap();
786 let before = tree_snapshot(store.root());
787 let _ = resolve(&qualified_pin("2026.07.0"), &store).unwrap();
788 let _ = store.list().unwrap();
789 let _ = resolve(&qualified_pin("2026.09.0"), &store).unwrap_err();
790 let after = tree_snapshot(store.root());
791 assert_eq!(
792 before, after,
793 "select/verify/report must never mutate the core"
794 );
795 }
796}