caixa_resolver/resolve.rs
1//! Top-level resolver — turn a [`Caixa`] into a [`Lacre`] with BLAKE3
2//! fechamento hashes over the full transitive closure.
3
4use std::collections::{BTreeMap, HashSet, VecDeque};
5use std::path::{Path, PathBuf};
6
7use caixa_core::{Caixa, Dep, DepSource};
8use caixa_lacre::{Lacre, LacreEntry, closure_hash, hash_bytes};
9use thiserror::Error;
10
11use crate::cache::CacheDir;
12use crate::config::ResolverConfig;
13use crate::git::{self, GitError};
14use crate::url::expand_shorthand;
15
16#[derive(Debug, Error)]
17pub enum ResolveError {
18 #[error("io: {0}")]
19 Io(#[from] std::io::Error),
20 #[error("git: {0}")]
21 Git(#[from] GitError),
22 #[error("lisp: {0}")]
23 Lisp(#[from] tatara_lisp::LispError),
24 /// A resolved dep's `caixa.lisp` did not read as a package manifest.
25 ///
26 /// Distinct from [`Self::Lisp`] because the interesting case is
27 /// `LeituraError::DialetoEstrangeiro`: a dep whose manifest is a
28 /// repo-surface declaration rather than a package manifest is not a
29 /// syntax error in that dep, it is the wrong KIND of file, and a resolver
30 /// that flattened both into "lisp: …" would send the author hunting for a
31 /// typo that is not there.
32 #[error("manifest: {0}")]
33 Manifesto(#[from] caixa_core::LeituraError),
34 #[error("dep '{nome}' expected a pin (:tag or :rev); got neither")]
35 MissingPin { nome: String },
36 #[error("dep '{nome}' path source {path} does not exist")]
37 MissingPath { nome: String, path: PathBuf },
38 #[error("cyclic dependency detected involving '{0}'")]
39 Cycle(String),
40}
41
42impl ResolveError {
43 /// Substrate primitive constructor for the per-`Dep`
44 /// `:fonte (:tipo path :caminho …)` on-disk-absence refusal
45 /// diagnostic. Folds the pre-lift open-coded four-line
46 /// `Err(ResolveError::MissingPath { nome: dep.nome().to_string(),
47 /// path })` two-slot struct-literal inside [`fetch_path`] onto one
48 /// dispatch. Peer with the sibling one-slot `MissingPin` `{ nome:
49 /// String }` variant on the same envelope, which the paired
50 /// [`Self::missing_pin`] ctor now folds onto its own substrate
51 /// primitive on [`fetch_git`]'s `.ok_or_else` closure-form emission
52 /// arm. Every future consumer that surfaces the same
53 /// on-disk-absence diagnostic outside `fetch_path` — a deferred
54 /// `feira lock --path`-only pre-flight probe, a per-lacre overlay
55 /// resolver rejecting a cluster-local `:caminho` overlay that
56 /// vanished between resolve passes, the M4 `mesh.pleme.io/v1alpha1/
57 /// Caixa` CR admission webhook re-checking a per-`:fonte`-patched
58 /// candidate before the closure walker re-fires — reaches the
59 /// variant through one call rather than re-inlining the two-slot
60 /// struct-literal in lockstep with [`fetch_path`]'s wire-up.
61 ///
62 /// The `nome: &str` parameter accepts `&str` literals and `&String`
63 /// via Deref coercion so the sole in-crate wire-up threads
64 /// `dep.nome()` (a `&str` accessor via [`Dep::nome`]) through the
65 /// ctor without a pre-conversion; the `path: impl Into<PathBuf>`
66 /// parameter takes the observed on-disk-absent [`PathBuf`] built
67 /// at the caller from `PathBuf::from(caminho)` and forwards it
68 /// through the [`From<PathBuf>`] identity into the variant slot
69 /// without an extra allocation.
70 #[must_use]
71 pub fn missing_path(nome: &str, path: impl Into<PathBuf>) -> ResolveError {
72 ResolveError::MissingPath {
73 nome: nome.to_string(),
74 path: path.into(),
75 }
76 }
77
78 /// Substrate primitive constructor for the per-`Dep`
79 /// `:fonte (:tipo git …)`-arm sole-set-pin absence refusal
80 /// diagnostic. Folds the pre-lift open-coded three-line
81 /// `ResolveError::MissingPin { nome: dep.nome().to_string() }`
82 /// one-slot struct-literal inside [`fetch_git`]'s `.ok_or_else`
83 /// closure-form emission arm onto one dispatch. Peer with the
84 /// sibling two-slot [`Self::missing_path`] ctor on the same
85 /// envelope's on-disk-absence refusal axis — this ctor closes the
86 /// paired closure-form emission axis on the `:fonte (:tipo git
87 /// …)`-arm's pin-precedence projection ([`DepSource::sole_pin`])
88 /// through which every author-omitted-and-default-host-shorthand-
89 /// expanded Git source (`expand_fonte(dep, default_host)` on a
90 /// `Dep::simple(...)` bare dep) reaches [`fetch_git`] with all
91 /// three `tag`/`rev`/`branch` pins `None`. Every future consumer
92 /// that surfaces the same sole-set-pin absence diagnostic outside
93 /// [`fetch_git`] — a deferred `feira lock --dry-run` pre-flight
94 /// per-`:deps` pin-presence probe that refuses before the
95 /// closure walker fires a single `git clone`, a per-lacre overlay
96 /// resolver rejecting a cluster-local `:fonte` overlay whose pin
97 /// axis was dropped between resolve passes, the M4
98 /// `mesh.pleme.io/v1alpha1/Caixa` CR admission webhook re-checking
99 /// a per-`:fonte`-patched candidate before the closure walker
100 /// re-fires — reaches the variant through one call rather than
101 /// re-inlining the one-slot struct-literal in lockstep with
102 /// [`fetch_git`]'s closure-form emission arm.
103 ///
104 /// The `nome: &str` parameter accepts `&str` literals and `&String`
105 /// via Deref coercion so the sole in-crate wire-up threads
106 /// `dep.nome()` (a `&str` accessor via [`Dep::nome`]) through the
107 /// ctor without a pre-conversion, byte-parallel to the sibling
108 /// [`Self::missing_path`] ctor's `nome` parameter.
109 #[must_use]
110 pub fn missing_pin(nome: &str) -> ResolveError {
111 ResolveError::MissingPin {
112 nome: nome.to_string(),
113 }
114 }
115}
116
117/// Resolve a root caixa's deps into a canonical lacre, offline if the cache
118/// is warm, otherwise cloning/fetching from git.
119pub fn resolve_lacre(
120 root: &Caixa,
121 cfg: &ResolverConfig,
122 cache: &CacheDir,
123) -> Result<Lacre, ResolveError> {
124 // Direct deps from the root caixa. Read both the runtime `:deps`
125 // and dev-only `:deps-dev` lists through the typed
126 // [`Caixa::deps`] / [`Caixa::deps_dev`] `&[Dep]`-return slice
127 // accessors so every projection of the outer-`Caixa`
128 // dependency-slot axes in the top-level closure resolver's
129 // queue-initialization surface routes through one typed
130 // dispatch — any future accessor extension (a per-scope alias
131 // table, per-cluster canary-version overlay, per-Aplicacao
132 // dev-closure-audit projection the M4 CR materializer's
133 // per-CR reconcile pass consumes) reaches both walks by
134 // construction. Peer of the sibling per-`:deps` /
135 // `:deps-dev` accessor family the ad34b4e / f7fd81e lifts
136 // opened on the outer-`Caixa` slot, extended here onto the
137 // caixa-resolver top-level closure resolver's outermost
138 // queue-seeding walks.
139 //
140 // The paired parent-`:nome` label carried on the queue's `from`
141 // arm — the byte-string the cycle-diagnostic re-labeler's
142 // `ResolveError::Cycle(from.clone())` map_err arm surfaces on any
143 // future extension that promotes the closure walker's per-target
144 // cycle-collapse into a first-class refusal — routes through the
145 // typed [`caixa_core::Caixa::nome`] `&str`-return accessor rather
146 // than the raw `root.nome.clone()` `String`-carry read. Byte-equal
147 // today (`Caixa::nome` is `&self.nome`; `.to_string()` on the
148 // borrowed `&str` allocates exactly one `String` byte-equal to the
149 // prior raw `.nome.clone()` read); the queue's per-transitive-target
150 // sibling push at line 77 already routes through the peer
151 // [`caixa_core::Dep::nome`] accessor (`dep.nome().to_string()`), so
152 // this converge closes the last unlifted parent-`:nome` field-read
153 // in the closure walker's queue-carried `from` axis. Peer with every
154 // prior per-`Caixa` universal-axis converge on the outer-`Caixa`
155 // `:nome` scalar (caixa-helm 22461ef / a7420bd, caixa-flux 4a363bf /
156 // 162e2e2, caixa-mesh 54bf2f3 / 980c059, caixa-crd 61d3429,
157 // caixa-tatara e73b19f, caixa-feira 5131203 / 4b05240 / 3219a42 /
158 // 5bc5178) and sibling to 5ce1b94's `Caixa::deps` / `Caixa::deps_dev`
159 // converge in this same crate on the paired outer-`Caixa`
160 // dependency-slot axis.
161 let mut queue: VecDeque<(Dep, String)> = VecDeque::new();
162 let mut seen: HashSet<String> = HashSet::new();
163 for dep in root.deps() {
164 queue.push_back((dep.clone(), root.nome().to_string()));
165 }
166 if cfg.include_dev {
167 for dep in root.deps_dev() {
168 queue.push_back((dep.clone(), root.nome().to_string()));
169 }
170 }
171
172 // Resolved entries keyed by nome, preserving deterministic deps_diretas.
173 let mut resolved: BTreeMap<String, ResolvedDep> = BTreeMap::new();
174
175 while let Some((dep, from)) = queue.pop_front() {
176 if !seen.insert(dep.nome().to_string()) {
177 continue;
178 }
179 let fetched = fetch_dep(&dep, cfg, cache).map_err(|e| match e {
180 ResolveError::Cycle(_) => ResolveError::Cycle(from.clone()),
181 other => other,
182 })?;
183 for t in &fetched.child_deps {
184 queue.push_back((t.clone(), dep.nome().to_string()));
185 }
186 resolved.insert(
187 dep.nome().to_string(),
188 ResolvedDep {
189 dep,
190 child_deps: fetched.child_deps,
191 resolved_fonte: fetched.resolved_fonte,
192 concrete_versao: fetched.concrete_versao,
193 conteudo: fetched.conteudo,
194 },
195 );
196 }
197
198 // Compute closure hashes in reverse-topological order.
199 let mut fechamento: BTreeMap<String, String> = BTreeMap::new();
200 // Simple fixpoint: re-run until all are hashable (acyclic → terminates).
201 let names: Vec<_> = resolved.keys().cloned().collect();
202 for _ in 0..names.len() {
203 let mut all_done = true;
204 for name in &names {
205 if fechamento.contains_key(name) {
206 continue;
207 }
208 let r = &resolved[name];
209 let child_closures: Option<Vec<String>> = r
210 .child_deps
211 .iter()
212 .map(|c| fechamento.get(c.nome()).cloned())
213 .collect();
214 if let Some(closures) = child_closures {
215 fechamento.insert(name.clone(), closure_hash(&r.conteudo, &closures));
216 } else {
217 all_done = false;
218 }
219 }
220 if all_done {
221 break;
222 }
223 }
224
225 // Build entries in sorted-name order.
226 let entries: Vec<LacreEntry> = resolved
227 .values()
228 .map(|r| LacreEntry {
229 nome: r.dep.nome().to_string(),
230 versao: r.concrete_versao.clone(),
231 fonte: r.resolved_fonte.clone(),
232 conteudo: r.conteudo.clone(),
233 fechamento: fechamento
234 .get(r.dep.nome())
235 .cloned()
236 .unwrap_or_else(|| hash_bytes(b"unresolved")),
237 deps_diretas: r.child_deps.iter().map(|c| c.nome().to_string()).collect(),
238 })
239 .collect();
240
241 Ok(Lacre::from_entries(entries))
242}
243
244struct ResolvedDep {
245 dep: Dep,
246 child_deps: Vec<Dep>,
247 resolved_fonte: DepSource,
248 concrete_versao: String,
249 conteudo: String,
250}
251
252struct FetchedDep {
253 child_deps: Vec<Dep>,
254 resolved_fonte: DepSource,
255 concrete_versao: String,
256 conteudo: String,
257}
258
259fn fetch_dep(
260 dep: &Dep,
261 cfg: &ResolverConfig,
262 cache: &CacheDir,
263) -> Result<FetchedDep, ResolveError> {
264 // Expand :fonte — None → default host shorthand.
265 //
266 // Route the per-`Dep` `:fonte` presence-projection through the
267 // lifted [`expand_fonte`] helper rather than an inline
268 // `dep.fonte.clone().unwrap_or_else(...)` cascade. The helper reads
269 // through the typed [`caixa_core::Dep::fonte`] `Option<&DepSource>`
270 // accessor and materializes the `github:<org>/<nome>`-shaped
271 // `default_host`-derived fallback for the author-omitted arm,
272 // keeping the closure-walker's two per-`Dep` `:fonte`-expansion
273 // consumers (this crate's [`fetch_dep`], caixa-feira/src/cmd/lock.rs's
274 // `resolve_stub` — 33e5d9a converged the peer site onto the same
275 // accessor) both routed through the substrate-primitive typed
276 // dispatch rather than each open-coding its own `.clone()` cascade.
277 let fonte = expand_fonte(dep, &cfg.default_host);
278
279 match &fonte {
280 DepSource::Path { caminho } => fetch_path(dep, caminho),
281 // Route the per-`DepSource::Git`-arm sole-set-pin projection
282 // through the lifted [`DepSource::sole_pin`] substrate
283 // accessor rather than passing broken-out `tag`/`rev`/`branch`
284 // `Option<&str>` triples down for `fetch_git` to re-inline the
285 // `rev.or(tag).or(branch)` cascade on — the two pre-lift
286 // consumers of the sole-set-pin projection (this crate's
287 // `fetch_git` `git checkout` target, caixa-crd's
288 // `dep_into_ref` `CaixaSource.git_ref` fill) now key off
289 // exactly one typed dispatch on the substrate primitive, so
290 // any future rebrand on the precedence axis (a `:commit` pin
291 // peer once signed-commit-verification lands, a `:ref` pin the
292 // M4 operator resolves per-cluster, a promotion of the plain
293 // `Option<String>` pins to a typed `GitPin` newtype) migrates
294 // as a single caixa-core edit rather than a coordinated
295 // rewrite of both consumer sites.
296 DepSource::Git { repo, .. } => fetch_git(dep, repo, fonte.sole_pin(), cache, fonte.clone()),
297 }
298}
299
300fn fetch_path(dep: &Dep, caminho: &str) -> Result<FetchedDep, ResolveError> {
301 let path = PathBuf::from(caminho);
302 if !path.exists() {
303 return Err(ResolveError::missing_path(dep.nome(), path));
304 }
305 let manifest = std::fs::read_to_string(path.join("caixa.lisp"))?;
306 let target = Caixa::from_lisp(&manifest)?;
307 Ok(FetchedDep {
308 // Route the fetched child's `:deps` `Vec<Dep>`-carry projection
309 // through the typed [`Caixa::deps`] `&[Dep]`-return slice
310 // accessor so the transitive walk keys off the same typed
311 // dispatch the outer queue-seeding read at [`resolve_lacre`]
312 // already routes through — a `.to_vec()` on the accessor's
313 // borrowed slice allocates exactly one `Vec<Dep>` per fetched
314 // target, byte-equal in element order to the prior raw
315 // `target.deps.clone()` read.
316 child_deps: target.deps().to_vec(),
317 resolved_fonte: DepSource::Path {
318 caminho: caminho.to_string(),
319 },
320 // Route the fetched child's `:versao` `String`-carry projection
321 // through the typed [`Caixa::versao`] `&str`-return accessor —
322 // sibling to the paired `target.deps().to_vec()` accessor-route
323 // above; a `.to_string()` on the accessor's borrowed `&str`
324 // allocates exactly one `String` byte-equal to the prior raw
325 // `target.versao.clone()` read. Closes the last unlifted per-
326 // `Caixa` universal-axis raw-field-access `String`-carry site on
327 // the caixa-resolver closure-walker's per-fetched-target
328 // [`FetchedDep`] emit surface, sibling to the peer per-`Caixa`
329 // universal-axis converges every peer per-kind renderer (caixa-
330 // helm eb912de, caixa-flux 2fc5f81, caixa-mesh 980c059, caixa-
331 // tatara e73b19f, caixa-crd 41ab9a3) already routes its `:versao`
332 // `String`-carry through.
333 concrete_versao: target.versao().to_string(),
334 conteudo: format!("path:{caminho}"),
335 })
336}
337
338fn fetch_git(
339 dep: &Dep,
340 repo: &str,
341 sole_pin: Option<&str>,
342 cache: &CacheDir,
343 original_fonte: DepSource,
344) -> Result<FetchedDep, ResolveError> {
345 let gitref = sole_pin.ok_or_else(|| ResolveError::missing_pin(dep.nome()))?;
346 let full_url = expand_shorthand(repo);
347 let key_bytes = format!("{full_url}#{gitref}");
348 let key = hash_bytes(key_bytes.as_bytes());
349 let short = &key["blake3:".len()..][..16];
350 let dest = cache.source_dir(short);
351
352 git::clone_or_fetch(&full_url, &dest)?;
353 git::checkout(&dest, gitref)?;
354 let sha = git::head_sha(&dest)?;
355 let conteudo = format!("git:{sha}");
356
357 let manifest_path = dest.join("caixa.lisp");
358 let manifest = std::fs::read_to_string(&manifest_path)?;
359 let target = Caixa::from_lisp(&manifest)?;
360
361 // Freeze :fonte into the lacre with the resolved commit — lock files
362 // are reproducible even if the upstream moves the tag.
363 let resolved = match original_fonte {
364 DepSource::Git {
365 repo: r,
366 tag: t,
367 branch: b,
368 ..
369 } => DepSource::Git {
370 repo: r,
371 tag: t,
372 rev: Some(sha),
373 branch: b,
374 },
375 other => other,
376 };
377
378 Ok(FetchedDep {
379 // Same accessor-route as the sibling [`fetch_path`] arm — the
380 // git-fetched child's `:deps` `Vec<Dep>`-carry projection
381 // reaches for the typed [`Caixa::deps`] `&[Dep]`-return slice
382 // accessor so both fetch-arm branches of the transitive walk
383 // key off the same typed dispatch as the outer
384 // [`resolve_lacre`] queue-seeding read.
385 child_deps: target.deps().to_vec(),
386 resolved_fonte: resolved,
387 // Sibling `:versao` `String`-carry converge to the paired
388 // [`fetch_path`] arm above — the git-fetched child's `:versao`
389 // scalar routes through the typed [`Caixa::versao`] `&str`-
390 // return accessor rather than the raw `.versao.clone()` field
391 // access, so both fetch-arm branches of the closure walker land
392 // the concrete-versao carry through the same typed dispatch.
393 concrete_versao: target.versao().to_string(),
394 conteudo,
395 })
396}
397
398/// Split `"github:pleme-io"` → `("github", "pleme-io")`. Unrecognized hosts
399/// return `("github", default_host_as_is)`.
400fn split_default_host(default_host: &str) -> (&str, &str) {
401 default_host
402 .split_once(':')
403 .unwrap_or(("github", default_host))
404}
405
406/// Per-`Dep` `:fonte` presence-projection with the resolver's
407/// `default_host`-derived fallback baked in — the substrate-primitive
408/// typed dispatch every caixa-resolver closure-walker per-`Dep`
409/// `:fonte`-expansion consumer keys off. The helper reads through the
410/// typed [`caixa_core::Dep::fonte`] `Option<&DepSource>`-return accessor
411/// rather than the raw `.fonte.clone()` field-access, so any future
412/// extension of the accessor's semantics (a per-scope source-override
413/// table on `:fonte` the M4 CR materializer resolves at admission time,
414/// a per-tenant `:fonte` rewrite overlay the roadmap acknowledges, a
415/// lacre-projected concrete-source pin resolver) reaches this fallback
416/// surface through exactly one caixa-core edit rather than a coordinated
417/// rewrite of both open-coded expansions.
418///
419/// Sibling of caixa-feira/src/cmd/lock.rs's `resolve_stub`
420/// (33e5d9a) per-`Dep` `:fonte` accessor-route on the peer stub-resolver
421/// site — same "the emit path must route through the substrate-primitive
422/// typed dispatch" discipline extended onto the caixa-resolver
423/// closure-walker's per-target `:fonte`-expansion surface. Byte-equal to
424/// the prior inline cascade today: on the author-set arm the accessor
425/// returns `Some(&<verbatim>)` and `.cloned()` allocates exactly one
426/// `DepSource` byte-equal to the pre-lift `.fonte.clone()` read; on the
427/// author-omitted arm the accessor returns `None` and the fallback
428/// materializes `DepSource::Git { repo: "<host>:<org>/<nome>", tag/rev/
429/// branch: None }` byte-equal to the pre-lift `unwrap_or_else(...)`
430/// tail. The `default_host` split cascades through
431/// [`split_default_host`] so `"github:pleme-io"` → `github:pleme-io/…`
432/// and `"acme-org"` → `github:acme-org/…` (the unrecognized-host arm
433/// defaults `host` to `"github"`).
434///
435/// The fallback deliberately does NOT compose through
436/// [`caixa_core::DepSource::default_github`] — that helper hardcodes
437/// `"github"` as the host segment (`"github:{org}/{nome}"`), whereas
438/// this helper honors an author-configurable `default_host` (e.g. a
439/// `"codeberg:acme"` config would produce `codeberg:acme/<nome>`,
440/// which `default_github` cannot express).
441pub(crate) fn expand_fonte(dep: &Dep, default_host: &str) -> DepSource {
442 dep.fonte().cloned().unwrap_or_else(|| {
443 let (host, org) = split_default_host(default_host);
444 DepSource::Git {
445 repo: format!("{host}:{org}/{}", dep.nome()),
446 tag: None,
447 rev: None,
448 branch: None,
449 }
450 })
451}
452
453#[allow(dead_code)]
454fn _unused_path(_p: &Path) {}
455
456#[cfg(test)]
457mod tests {
458 use super::*;
459 use caixa_core::CaixaKind;
460 use tempfile::tempdir;
461
462 #[test]
463 fn split_default_host_parses_github() {
464 assert_eq!(
465 split_default_host("github:pleme-io"),
466 ("github", "pleme-io")
467 );
468 }
469
470 /// Pin that the per-`Dep` `:fonte` presence-projection under
471 /// [`expand_fonte`] routes through the typed [`Dep::fonte`]
472 /// `Option<&DepSource>`-return accessor rather than the raw
473 /// `.fonte.clone()` field-access, and that the accessor's `None` arm
474 /// materializes the `<host>:<org>/<nome>`-shaped `default_host`-
475 /// derived fallback with `tag`/`rev`/`branch` all `None`. Sweeps
476 /// three arms of the per-`Dep` `:fonte` presence bit × per-config
477 /// `default_host` shape lattice the closure-walker's
478 /// [`fetch_dep`] call-site keys off:
479 ///
480 /// 1. Author-omitted arm × the canonical `"github:pleme-io"`
481 /// default-host shape — accessor projects `None`, helper falls
482 /// back to `DepSource::Git { repo: "github:pleme-io/<nome>",
483 /// tag/rev/branch: None }`, byte-equal to the pre-lift
484 /// inline `format!("{host}:{org}/{}", dep.nome())` on the
485 /// canonical default-host arm.
486 /// 2. Author-omitted arm × an unrecognized single-segment
487 /// `default_host` (`"acme-org"`) — [`split_default_host`]
488 /// defaults `host` to `"github"` and carries the whole string as
489 /// `org`, so the fallback is `DepSource::Git { repo:
490 /// "github:acme-org/<nome>", … }`. Pins the unrecognized-host
491 /// arm's fall-through under the helper.
492 /// 3. Author-set arm × any `default_host` — accessor projects
493 /// `Some(&<verbatim>)`, helper carries the source through
494 /// `.cloned()` byte-verbatim regardless of `default_host` (the
495 /// fallback branch never fires when the author declared
496 /// `:fonte`).
497 ///
498 /// Byte-equal today (`.fonte()` returns `self.fonte.as_ref()`;
499 /// `.cloned()` on `Option<&DepSource>` allocates exactly one
500 /// `DepSource` byte-equal to the prior raw `.fonte.clone()` read);
501 /// catches any future emit-side regression that re-introduces the
502 /// raw `.fonte.clone()` cascade at the [`fetch_dep`] call site (a
503 /// future accessor extension — a per-scope alias table, a per-tenant
504 /// `:fonte` rewrite overlay, a lacre-projected concrete-source pin
505 /// resolver — would then reach only the open-coded cascade and
506 /// silently diverge from the helper's typed dispatch, tripping the
507 /// author-set-arm pin below).
508 ///
509 /// Peer of the sibling caixa-feira/src/cmd/lock.rs's
510 /// `resolve_stub_fonte_routes_through_dep_fonte_accessor` (33e5d9a)
511 /// per-`Dep` `:fonte` accessor-route pin on the stub-resolver
512 /// surface — same "the emit path must route through the substrate-
513 /// primitive typed dispatch" discipline extended onto the
514 /// caixa-resolver closure-walker's per-target `:fonte`-expansion
515 /// helper.
516 #[test]
517 #[allow(clippy::too_many_lines)]
518 fn expand_fonte_routes_through_dep_fonte_accessor() {
519 // Arm 1: author-omitted `:fonte` × canonical `"github:pleme-io"`
520 // default-host. Accessor must project `None` and the helper's
521 // fallback must materialize the canonical
522 // `github:pleme-io/<nome>` shorthand.
523 let bare = Dep::simple("caixa-teia", "^0.1");
524 assert!(
525 bare.fonte().is_none(),
526 "author-omitted :fonte must project None through the accessor \
527 — pins the substrate contract the helper's unwrap_or_else \
528 cascade discriminates on",
529 );
530 let expanded = expand_fonte(&bare, "github:pleme-io");
531 assert_eq!(
532 expanded,
533 DepSource::Git {
534 repo: "github:pleme-io/caixa-teia".to_string(),
535 tag: None,
536 rev: None,
537 branch: None,
538 },
539 "author-omitted :fonte on the canonical github:pleme-io \
540 default-host must fall back to the github:<org>/<nome>-shaped \
541 Git shorthand byte-verbatim",
542 );
543
544 // Arm 2: author-omitted `:fonte` × unrecognized single-segment
545 // `default_host` (`"acme-org"`). `split_default_host`'s
546 // unrecognized-host arm defaults `host` to `"github"` and carries
547 // the whole string as `org`, so the helper's fallback must land
548 // `github:acme-org/<nome>` — pins that the helper reads the
549 // `default_host` through the shared [`split_default_host`]
550 // parser rather than re-inlining a `"github:"`-hardcoded
551 // shorthand (which would produce the same output for this input
552 // by coincidence but would diverge on a `"codeberg:acme"`-shaped
553 // config the arm-3 assertion below stresses).
554 let expanded_unrecognized_host = expand_fonte(&bare, "acme-org");
555 assert_eq!(
556 expanded_unrecognized_host,
557 DepSource::Git {
558 repo: "github:acme-org/caixa-teia".to_string(),
559 tag: None,
560 rev: None,
561 branch: None,
562 },
563 "author-omitted :fonte on an unrecognized single-segment \
564 default_host must default the host segment to `github` and \
565 carry the whole config string as the org",
566 );
567
568 // Arm 3: author-set `:fonte` × any `default_host`. Accessor must
569 // project `Some(&<verbatim>)`, and the helper must carry the
570 // source through `.cloned()` byte-verbatim — the fallback branch
571 // never fires when the author declared `:fonte` at authoring
572 // time. Any regression that re-inlined the raw `.fonte.clone()`
573 // cascade would still pass this arm today (both routes are
574 // byte-equal on the `Some` arm) but a future accessor extension
575 // (a per-scope alias table, a per-tenant rewrite overlay)
576 // would silently diverge here — the pin catches that
577 // divergence at compile time as soon as the accessor's return
578 // shape widens beyond `self.fonte.as_ref()`.
579 let with_path = Dep {
580 nome: "child".into(),
581 versao: "0.1.0".into(),
582 fonte: Some(DepSource::Path {
583 caminho: "/tmp/child".into(),
584 }),
585 opcional: false,
586 caracteristicas: vec![],
587 };
588 assert_eq!(
589 with_path.fonte(),
590 Some(&DepSource::Path {
591 caminho: "/tmp/child".into(),
592 }),
593 "author-set :fonte must project Some(&<verbatim>) through \
594 the accessor — pins the substrate contract the helper's \
595 .cloned() carry discriminates on",
596 );
597 let expanded_author_set = expand_fonte(&with_path, "github:pleme-io");
598 assert_eq!(
599 expanded_author_set,
600 DepSource::Path {
601 caminho: "/tmp/child".into(),
602 },
603 "author-set :fonte must carry through the helper .cloned() \
604 byte-verbatim regardless of default_host — the fallback \
605 branch must not fire when the accessor projects Some(&…)",
606 );
607 // Cross-check: swapping `default_host` on the author-set arm
608 // must not perturb the output — the fallback string is dead
609 // code on the `Some` arm.
610 let expanded_author_set_alt_host = expand_fonte(&with_path, "codeberg:acme");
611 assert_eq!(
612 expanded_author_set, expanded_author_set_alt_host,
613 "author-set :fonte must be default_host-agnostic — the \
614 fallback shorthand must not leak into the output when the \
615 accessor projects Some(&…)",
616 );
617 }
618
619 /// Pin that every projection of the outer-`Caixa` `:deps` /
620 /// `:deps-dev` dependency-slot family in [`resolve_lacre`] and the
621 /// per-target [`fetch_path`] transitive walk — the outer
622 /// queue-seeding walks over `root.deps()` and `root.deps_dev()`
623 /// (the two entry points every downstream git-clone target flows
624 /// through), plus the per-fetched-target `target.deps().to_vec()`
625 /// child-dep collection inside [`fetch_path`] the transitive walk
626 /// keys off — routes through the typed [`caixa_core::Caixa::deps`]
627 /// / [`caixa_core::Caixa::deps_dev`] `&[Dep]`-return slice
628 /// accessors rather than raw `&root.deps` / `&root.deps_dev` /
629 /// `target.deps.clone()` field reads. Byte-equal today (each
630 /// accessor returns `self.<field>.as_slice()`); catches any future
631 /// emit-site regression that reintroduces a raw field read, and
632 /// pins the closure-walker's four-site accessor-routing against a
633 /// hermetic tempdir-hosted `defcaixa`-parsed three-caixa closure
634 /// (root → child → grandchild via `:fonte (:tipo path :caminho …)`
635 /// on each edge, plus a dev-only sibling under `:deps-dev` that
636 /// the closure walker admits only when `cfg.include_dev` is true).
637 /// Peer of the sibling caixa-crd's `dep_into_ref_routes_through_dep_accessors`
638 /// (d65d1bf) per-entry `:deps` sub-slot family pin on the paired
639 /// per-`Dep` `Option<&DepSource>` composite-reference axis — same
640 /// "the emit path must route through the substrate-primitive
641 /// typed dispatch" discipline extended onto the outer top-level
642 /// [`Caixa`] `&[Dep]` slice axes at the closure-resolver's
643 /// queue-seeding surface.
644 #[test]
645 #[allow(clippy::too_many_lines)]
646 fn resolve_lacre_routes_dep_slot_family_through_caixa_accessors() {
647 let td = tempdir().expect("tempdir");
648 // Grandchild caixa on disk — no deps.
649 let grandchild_path = td.path().join("grandchild");
650 std::fs::create_dir_all(&grandchild_path).unwrap();
651 std::fs::write(
652 grandchild_path.join("caixa.lisp"),
653 r#"(defcaixa
654 :nome "grandchild"
655 :versao "0.1.0"
656 :kind Biblioteca
657 :bibliotecas ("lib/grandchild.lisp"))"#,
658 )
659 .unwrap();
660
661 // Child caixa on disk — depends on grandchild via Path.
662 let child_path = td.path().join("child");
663 std::fs::create_dir_all(&child_path).unwrap();
664 let child_lisp = format!(
665 r#"(defcaixa
666 :nome "child"
667 :versao "0.1.0"
668 :kind Biblioteca
669 :bibliotecas ("lib/child.lisp")
670 :deps ((:nome "grandchild" :versao "0.1.0"
671 :fonte (:tipo path :caminho "{}"))))"#,
672 grandchild_path.display()
673 );
674 std::fs::write(child_path.join("caixa.lisp"), child_lisp).unwrap();
675
676 // Dev-only sibling — no deps of its own.
677 let devchild_path = td.path().join("devchild");
678 std::fs::create_dir_all(&devchild_path).unwrap();
679 std::fs::write(
680 devchild_path.join("caixa.lisp"),
681 r#"(defcaixa
682 :nome "devchild"
683 :versao "0.1.0"
684 :kind Biblioteca
685 :bibliotecas ("lib/devchild.lisp"))"#,
686 )
687 .unwrap();
688
689 let root = Caixa {
690 nome: "root".into(),
691 versao: "0.1.0".into(),
692 kind: CaixaKind::Biblioteca,
693 edicao: None,
694 descricao: None,
695 repositorio: None,
696 licenca: None,
697 autores: vec![],
698 etiquetas: vec![],
699 deps: vec![Dep {
700 nome: "child".into(),
701 versao: "0.1.0".into(),
702 fonte: Some(DepSource::Path {
703 caminho: child_path.to_string_lossy().into_owned(),
704 }),
705 opcional: false,
706 caracteristicas: vec![],
707 }],
708 deps_dev: vec![Dep {
709 nome: "devchild".into(),
710 versao: "0.1.0".into(),
711 fonte: Some(DepSource::Path {
712 caminho: devchild_path.to_string_lossy().into_owned(),
713 }),
714 opcional: false,
715 caracteristicas: vec![],
716 }],
717 exe: vec![],
718 bibliotecas: vec![],
719 servicos: vec![],
720 limits: None,
721 behavior: None,
722 upgrade_from: vec![],
723 estrategia: None,
724 max_restarts: None,
725 restart_window: None,
726 children: vec![],
727 membros: vec![],
728 contratos: vec![],
729 politicas: None,
730 placement: None,
731 entrada: None,
732 ci: None,
733 };
734
735 let cache_root = td.path().join("cache");
736 std::fs::create_dir_all(&cache_root).unwrap();
737 let cache = CacheDir::at(&cache_root);
738
739 // include_dev=false: the closure-walker admits only `:deps`
740 // entries + their transitives. `:deps-dev` sibling must NOT
741 // appear, and the walker must have iterated `root.deps()`
742 // (surfacing `"child"`) and then `child.deps().to_vec()` inside
743 // `fetch_path` (surfacing `"grandchild"`).
744 let cfg = ResolverConfig::default();
745 let lacre = resolve_lacre(&root, &cfg, &cache).expect("resolve runtime-only closure");
746 let names: Vec<&str> = lacre.entradas.iter().map(|e| e.nome.as_str()).collect();
747 assert!(
748 names.contains(&"child"),
749 "resolve_lacre must surface `child` via root.deps() — got {names:?}"
750 );
751 assert!(
752 names.contains(&"grandchild"),
753 "resolve_lacre must surface `grandchild` via the transitive \
754 fetch_path target.deps().to_vec() walk — got {names:?}"
755 );
756 assert!(
757 !names.contains(&"devchild"),
758 "resolve_lacre must NOT surface `devchild` when \
759 include_dev=false (deps_dev accessor must be walked only \
760 under the include_dev cfg arm) — got {names:?}"
761 );
762
763 // include_dev=true: the closure-walker also admits `:deps-dev`
764 // entries. The `devchild` sibling must appear via
765 // `root.deps_dev()`.
766 let cfg_with_dev = ResolverConfig {
767 include_dev: true,
768 ..Default::default()
769 };
770 let lacre_with_dev =
771 resolve_lacre(&root, &cfg_with_dev, &cache).expect("resolve with dev closure");
772 let names_with_dev: Vec<&str> = lacre_with_dev
773 .entradas
774 .iter()
775 .map(|e| e.nome.as_str())
776 .collect();
777 assert!(
778 names_with_dev.contains(&"child"),
779 "include_dev=true closure must still surface `child` via \
780 root.deps() — got {names_with_dev:?}"
781 );
782 assert!(
783 names_with_dev.contains(&"grandchild"),
784 "include_dev=true closure must still surface `grandchild` \
785 via the transitive walk — got {names_with_dev:?}"
786 );
787 assert!(
788 names_with_dev.contains(&"devchild"),
789 "include_dev=true closure must surface `devchild` via \
790 root.deps_dev() — got {names_with_dev:?}"
791 );
792 }
793
794 /// Pin that both fetch-arm branches of the closure walker's per-
795 /// fetched-target [`FetchedDep`] emit surface — the [`fetch_path`]
796 /// arm and the [`fetch_git`] arm — carry each target's `:versao`
797 /// scalar into the resulting [`crate::LacreEntry`] via the typed
798 /// [`caixa_core::Caixa::versao`] `&str`-return accessor rather than a
799 /// raw `target.versao.clone()` field access. Byte-equal today
800 /// (`Caixa::versao` is `&self.versao`); catches any future emit-side
801 /// regression that reintroduces a raw field read and pins the two
802 /// [`FetchedDep::concrete_versao`] construction sites against a
803 /// hermetic tempdir-hosted three-caixa closure whose `:versao` bytes
804 /// distinguish each layer (root `"0.9.0"` → child `"0.5.2"` →
805 /// grandchild `"0.1.3"`), so a stub-that-hardcodes-a-fixed-string
806 /// regression trips on any layer.
807 ///
808 /// Peer of the sibling [`resolve_lacre_routes_dep_slot_family_through_caixa_accessors`]
809 /// per-`Caixa` `:deps` / `:deps-dev` slot-family pin on the sibling
810 /// per-`Caixa` `&[Dep]` slice-return accessor axis — same "the
811 /// emit path must route through the substrate-primitive typed
812 /// dispatch" discipline extended onto the outer top-level [`Caixa`]
813 /// `:versao` `&str`-return universal-axis at the closure-resolver's
814 /// per-fetched-target [`FetchedDep`] emit surface. Sibling to the
815 /// peer per-`Caixa` universal-axis converges every peer per-kind
816 /// renderer (caixa-helm eb912de, caixa-flux 2fc5f81, caixa-mesh
817 /// 980c059, caixa-tatara e73b19f, caixa-crd 41ab9a3) already routes
818 /// its `:versao` `String`-carry through.
819 #[test]
820 fn resolve_lacre_fetched_target_versao_routes_through_caixa_versao_accessor() {
821 let td = tempdir().expect("tempdir");
822 // Grandchild caixa on disk — `:versao "0.1.3"`.
823 let grandchild_path = td.path().join("grandchild");
824 std::fs::create_dir_all(&grandchild_path).unwrap();
825 std::fs::write(
826 grandchild_path.join("caixa.lisp"),
827 r#"(defcaixa
828 :nome "grandchild"
829 :versao "0.1.3"
830 :kind Biblioteca
831 :bibliotecas ("lib/grandchild.lisp"))"#,
832 )
833 .unwrap();
834
835 // Child caixa on disk — `:versao "0.5.2"`, depends on grandchild
836 // via Path so the closure walker traverses through
837 // [`fetch_path`] on both edges.
838 let child_path = td.path().join("child");
839 std::fs::create_dir_all(&child_path).unwrap();
840 let child_lisp = format!(
841 r#"(defcaixa
842 :nome "child"
843 :versao "0.5.2"
844 :kind Biblioteca
845 :bibliotecas ("lib/child.lisp")
846 :deps ((:nome "grandchild" :versao "0.1.3"
847 :fonte (:tipo path :caminho "{}"))))"#,
848 grandchild_path.display()
849 );
850 std::fs::write(child_path.join("caixa.lisp"), child_lisp).unwrap();
851
852 let root = Caixa {
853 nome: "root".into(),
854 versao: "0.9.0".into(),
855 kind: CaixaKind::Biblioteca,
856 edicao: None,
857 descricao: None,
858 repositorio: None,
859 licenca: None,
860 autores: vec![],
861 etiquetas: vec![],
862 deps: vec![Dep {
863 nome: "child".into(),
864 versao: "0.5.2".into(),
865 fonte: Some(DepSource::Path {
866 caminho: child_path.to_string_lossy().into_owned(),
867 }),
868 opcional: false,
869 caracteristicas: vec![],
870 }],
871 deps_dev: vec![],
872 exe: vec![],
873 bibliotecas: vec![],
874 servicos: vec![],
875 limits: None,
876 behavior: None,
877 upgrade_from: vec![],
878 estrategia: None,
879 max_restarts: None,
880 restart_window: None,
881 children: vec![],
882 membros: vec![],
883 contratos: vec![],
884 politicas: None,
885 placement: None,
886 entrada: None,
887 ci: None,
888 };
889
890 let cache_root = td.path().join("cache");
891 std::fs::create_dir_all(&cache_root).unwrap();
892 let cache = CacheDir::at(&cache_root);
893 let cfg = ResolverConfig::default();
894 let lacre = resolve_lacre(&root, &cfg, &cache).expect("resolve closure");
895
896 // Every fetched target's `:versao` scalar must land in the
897 // resulting `LacreEntry.versao` byte-verbatim to what
898 // `Caixa::versao()` returns for the source caixa on disk. A
899 // regression that hardcoded a fixed string on either fetch-arm
900 // side of the closure walker trips on the layer whose byte-shape
901 // it drifted off.
902 let pairs: Vec<(&str, &str)> = lacre
903 .entradas
904 .iter()
905 .map(|e| (e.nome.as_str(), e.versao.as_str()))
906 .collect();
907 assert!(
908 pairs.contains(&("child", "0.5.2")),
909 "child entry must carry `:versao \"0.5.2\"` verbatim through \
910 fetch_path -> Caixa::versao() -> FetchedDep::concrete_versao \
911 -> LacreEntry.versao — got {pairs:?}"
912 );
913 assert!(
914 pairs.contains(&("grandchild", "0.1.3")),
915 "grandchild entry must carry `:versao \"0.1.3\"` verbatim \
916 through the transitive walk's Caixa::versao() accessor route \
917 — got {pairs:?}"
918 );
919 }
920
921 /// Pin that the closure-walker's queue-seeding parent-`:nome` label
922 /// axis — the `String` carried on the queue's `from` arm at both the
923 /// outer `for dep in root.deps()` runtime seed and the
924 /// `cfg.include_dev`-gated `for dep in root.deps_dev()` dev-only
925 /// seed inside [`resolve_lacre`] — routes through the typed
926 /// [`caixa_core::Caixa::nome`] `&str`-return accessor rather than a
927 /// raw `root.nome.clone()` field read. The queue's per-transitive-
928 /// target sibling push already routes the paired parent-`:nome`
929 /// label through the peer [`caixa_core::Dep::nome`] accessor
930 /// (`dep.nome().to_string()` at the transitive-walk head), so this
931 /// pin closes the last unlifted parent-`:nome` field-read axis in
932 /// the closure walker's queue-carried `from` surface.
933 ///
934 /// Byte-equal today (`Caixa::nome` is `&self.nome`; `.to_string()`
935 /// on the borrowed `&str` allocates exactly one `String` byte-equal
936 /// to the prior raw `.nome.clone()` read); pin catches any future
937 /// silent detour that reintroduces a raw field read at either queue-
938 /// seeding site. The hermetic tempdir fixture uses a byte-
939 /// distinctive `:nome` on the root (`"root-abc"` — pairing DNS-1123
940 /// legality with three ASCII bytes distinct from every child's
941 /// `:nome` so a stub-that-hardcodes-a-fixed-string regression on
942 /// either seeding site can only pass by accident), and asserts
943 /// [`caixa_core::Caixa::nome`] returns byte-verbatim to what
944 /// [`resolve_lacre`]'s queue-seed reads. The end-to-end assertion
945 /// on the resulting [`crate::LacreEntry`] set pins that both
946 /// `:deps` and `:deps-dev` walks reach their transitive targets
947 /// after the accessor-route substitution, so a regression that
948 /// broke the queue-seeding shape by dropping the second push arm
949 /// (e.g. mis-collapsing the two guarded arms onto one) surfaces
950 /// as a missing child in the resolved closure.
951 ///
952 /// Peer of the sibling [`resolve_lacre_routes_dep_slot_family_through_caixa_accessors`]
953 /// per-`:deps` / `:deps-dev` slot-family pin above and of the
954 /// [`resolve_lacre_fetched_target_versao_routes_through_caixa_versao_accessor`]
955 /// per-`:versao` axis pin (0556249) on the same
956 /// closure-walker emit surface — same "the emit path must route
957 /// through the substrate-primitive typed dispatch" discipline the
958 /// per-`Caixa` `.nome` universal-axis converges every peer per-kind
959 /// renderer already carry, extended onto the outer top-level
960 /// [`Caixa`] `:nome` `&str`-return universal-axis at the closure-
961 /// resolver's queue-seeded per-transitive-target `from` label.
962 #[test]
963 fn resolve_lacre_queue_seed_parent_nome_routes_through_caixa_nome_accessor() {
964 let td = tempdir().expect("tempdir");
965 // Runtime-child caixa on disk — no deps of its own, distinct
966 // `:nome` bytes so it can be identified in the resolved closure.
967 let child_path = td.path().join("child");
968 std::fs::create_dir_all(&child_path).unwrap();
969 std::fs::write(
970 child_path.join("caixa.lisp"),
971 r#"(defcaixa
972 :nome "child"
973 :versao "0.1.0"
974 :kind Biblioteca
975 :bibliotecas ("lib/child.lisp"))"#,
976 )
977 .unwrap();
978
979 // Dev-only sibling — distinct `:nome`, guards the second
980 // seeding-arm branch.
981 let devchild_path = td.path().join("devchild");
982 std::fs::create_dir_all(&devchild_path).unwrap();
983 std::fs::write(
984 devchild_path.join("caixa.lisp"),
985 r#"(defcaixa
986 :nome "devchild"
987 :versao "0.1.0"
988 :kind Biblioteca
989 :bibliotecas ("lib/devchild.lisp"))"#,
990 )
991 .unwrap();
992
993 let root = Caixa {
994 nome: "root-abc".into(),
995 versao: "0.9.0".into(),
996 kind: CaixaKind::Biblioteca,
997 edicao: None,
998 descricao: None,
999 repositorio: None,
1000 licenca: None,
1001 autores: vec![],
1002 etiquetas: vec![],
1003 deps: vec![Dep {
1004 nome: "child".into(),
1005 versao: "0.1.0".into(),
1006 fonte: Some(DepSource::Path {
1007 caminho: child_path.to_string_lossy().into_owned(),
1008 }),
1009 opcional: false,
1010 caracteristicas: vec![],
1011 }],
1012 deps_dev: vec![Dep {
1013 nome: "devchild".into(),
1014 versao: "0.1.0".into(),
1015 fonte: Some(DepSource::Path {
1016 caminho: devchild_path.to_string_lossy().into_owned(),
1017 }),
1018 opcional: false,
1019 caracteristicas: vec![],
1020 }],
1021 exe: vec![],
1022 bibliotecas: vec![],
1023 servicos: vec![],
1024 limits: None,
1025 behavior: None,
1026 upgrade_from: vec![],
1027 estrategia: None,
1028 max_restarts: None,
1029 restart_window: None,
1030 children: vec![],
1031 membros: vec![],
1032 contratos: vec![],
1033 politicas: None,
1034 placement: None,
1035 entrada: None,
1036 ci: None,
1037 };
1038
1039 // Substrate-primitive byte-parity pin: `Caixa::nome()` returns
1040 // the raw `:nome` byte-string verbatim. Both queue-seeded push
1041 // arms compose `root.nome().to_string()` off this accessor, so
1042 // a future accessor extension (a canonicalization pass, an
1043 // aliasing overlay) reaches both call sites through one edit.
1044 assert_eq!(
1045 root.nome(),
1046 "root-abc",
1047 "Caixa::nome() must return the :nome byte-string verbatim — \
1048 pins the substrate contract both queue-seeded push arms \
1049 compose the parent-`:nome` label off of"
1050 );
1051 assert_eq!(
1052 root.nome().to_string(),
1053 "root-abc",
1054 "root.nome().to_string() must equal the raw :nome byte-string \
1055 verbatim — pins the accessor-routed String-carry the queue's \
1056 `from` arm receives at both seeding sites"
1057 );
1058
1059 // End-to-end pin on the closure walker's two-arm seeding surface:
1060 // both `:deps` and `:deps-dev` walks must reach their transitive
1061 // targets after the accessor-route substitution. A regression
1062 // that dropped either arm's queue-seed push surfaces here as a
1063 // missing child in the resolved lacre.
1064 let cache_root = td.path().join("cache");
1065 std::fs::create_dir_all(&cache_root).unwrap();
1066 let cache = CacheDir::at(&cache_root);
1067 let cfg_with_dev = ResolverConfig {
1068 include_dev: true,
1069 ..Default::default()
1070 };
1071 let lacre =
1072 resolve_lacre(&root, &cfg_with_dev, &cache).expect("resolve closure with dev deps");
1073 let names: Vec<&str> = lacre.entradas.iter().map(|e| e.nome.as_str()).collect();
1074 assert!(
1075 names.contains(&"child"),
1076 "resolve_lacre with the accessor-routed queue-seed must \
1077 surface `child` via the runtime `:deps` arm — got {names:?}"
1078 );
1079 assert!(
1080 names.contains(&"devchild"),
1081 "resolve_lacre with the accessor-routed queue-seed must \
1082 surface `devchild` via the `cfg.include_dev`-gated \
1083 `:deps-dev` arm — got {names:?}"
1084 );
1085 }
1086
1087 /// Pin that [`ResolveError::missing_path`] is byte-equal to the
1088 /// pre-lift open-coded four-line `{ nome: nome.to_string(), path }`
1089 /// two-slot struct-literal on a representative
1090 /// `("caixa-teia", PathBuf("/nonexistent/child"))` fixture. Catches
1091 /// any future ctor-side silent field-swap, `.to_string()` /
1092 /// `.into()` cascade drop, or variant rename that would leave the
1093 /// wire-up compiling but surface a different two-slot payload than
1094 /// the pre-lift struct-literal.
1095 #[test]
1096 fn missing_path_ctor_matches_struct_literal_wrap() {
1097 let ctor = ResolveError::missing_path("caixa-teia", PathBuf::from("/nonexistent/child"));
1098 let literal = ResolveError::MissingPath {
1099 nome: "caixa-teia".to_string(),
1100 path: PathBuf::from("/nonexistent/child"),
1101 };
1102 assert_eq!(
1103 format!("{ctor:?}"),
1104 format!("{literal:?}"),
1105 "missing_path ctor must debug-render byte-equal to the \
1106 open-coded two-slot struct-literal on the same fixture — \
1107 any silent field-swap or cascade drop surfaces here"
1108 );
1109 assert_eq!(
1110 format!("{ctor}"),
1111 format!("{literal}"),
1112 "missing_path ctor must display-render byte-equal to the \
1113 open-coded struct-literal on the same fixture — pins the \
1114 thiserror #[error] template's routing through both slots"
1115 );
1116 }
1117
1118 /// Sweep [`ResolveError::missing_path`] across a boundary matrix of
1119 /// `nome` shapes × `path` shapes so any wrapper-side silent
1120 /// lowercase, trim, truncate, two-axis field-swap, or `.into()`
1121 /// cascade divergence on the two-field construction surfaces at
1122 /// assert time rather than at a downstream diagnostic consumer that
1123 /// reads the fields back and gets a different value than the one it
1124 /// stored.
1125 ///
1126 /// - `nome` axis: canonical DNS-1123 identifier (`"caixa-teia"`),
1127 /// single-char (`"a"`), and an inner-hyphen + digit shape
1128 /// (`"hello-rio-42"`) — the three canonical `Dep::nome`-return
1129 /// shapes the `caixa-core::Dep::nome` accessor surfaces at the
1130 /// wire-up.
1131 /// - `path` axis: absolute UNIX-style (`"/absent/child"`), relative
1132 /// single-segment (`"child"`), and a nested relative with parent
1133 /// traversal (`"../sibling/child"`) — the three canonical
1134 /// `PathBuf::from(caminho)` shapes the `DepSource::Path
1135 /// { caminho }` arm surfaces at the wire-up.
1136 ///
1137 /// Both `&str`-literal and `&String` (via Deref coercion) carriers
1138 /// are exercised for `nome` because the wire-up hands `dep.nome()`
1139 /// (a `&str` accessor); both `PathBuf`-owned and `&Path` (via
1140 /// `Into<PathBuf>`) carriers are exercised for `path` because the
1141 /// wire-up hands a `PathBuf` built from `PathBuf::from(caminho)`.
1142 #[test]
1143 fn missing_path_ctor_routes_nome_and_path_through_verbatim() {
1144 let nomes: &[&str] = &["caixa-teia", "a", "hello-rio-42"];
1145 let paths: &[PathBuf] = &[
1146 PathBuf::from("/absent/child"),
1147 PathBuf::from("child"),
1148 PathBuf::from("../sibling/child"),
1149 ];
1150 for nome in nomes {
1151 for path in paths {
1152 // &str carrier for nome × PathBuf-owned carrier for path.
1153 let ctor = ResolveError::missing_path(nome, path.clone());
1154 match &ctor {
1155 ResolveError::MissingPath {
1156 nome: got_nome,
1157 path: got_path,
1158 } => {
1159 assert_eq!(
1160 got_nome, nome,
1161 "missing_path ctor must carry nome byte-verbatim \
1162 — no silent lowercase/trim/truncate on the \
1163 &str-literal carrier at {nome}"
1164 );
1165 assert_eq!(
1166 got_path, path,
1167 "missing_path ctor must carry path byte-verbatim \
1168 — no silent normalization/canonicalization on \
1169 the PathBuf-owned carrier at {path:?}"
1170 );
1171 }
1172 other => panic!(
1173 "missing_path ctor must construct the MissingPath \
1174 variant — got {other:?} for ({nome:?}, {path:?})"
1175 ),
1176 }
1177 // &String (Deref coercion) carrier for nome × &Path
1178 // (Into<PathBuf>) carrier for path — pins that the ctor
1179 // signature accepts both without a pre-conversion at the
1180 // call site.
1181 let owned_nome = String::from(*nome);
1182 let ctor2 = ResolveError::missing_path(&owned_nome, path.as_path());
1183 match &ctor2 {
1184 ResolveError::MissingPath {
1185 nome: got_nome,
1186 path: got_path,
1187 } => {
1188 assert_eq!(
1189 got_nome, nome,
1190 "missing_path ctor must carry nome byte-verbatim \
1191 on the &String Deref-coercion carrier at {nome}"
1192 );
1193 assert_eq!(
1194 got_path, path,
1195 "missing_path ctor must carry path byte-verbatim \
1196 on the &Path Into<PathBuf> carrier at {path:?}"
1197 );
1198 }
1199 other => panic!(
1200 "missing_path ctor must construct the MissingPath \
1201 variant on the &String/&Path carriers — got \
1202 {other:?} for ({nome:?}, {path:?})"
1203 ),
1204 }
1205 }
1206 }
1207 }
1208
1209 /// Pin the end-to-end route from [`fetch_path`]'s on-disk-absence
1210 /// arm through [`ResolveError::missing_path`]: authoring a `:deps`
1211 /// entry whose `:fonte (:tipo path :caminho …)` points at a path
1212 /// that does not exist on disk must surface a
1213 /// `ResolveError::MissingPath { nome, path }` byte-equal to the
1214 /// substrate-primitive ctor on the same fixture, so a future silent
1215 /// de-lift of the wire-up back to the open-coded struct-literal
1216 /// trips at caixa-resolver test time rather than at a downstream
1217 /// diagnostic consumer far from the wire-up commit.
1218 #[test]
1219 fn fetch_path_absent_dir_routes_through_missing_path_ctor() {
1220 let td = tempdir().expect("tempdir");
1221 // Root caixa points at a `:caminho` under the tempdir that
1222 // was never created — the closure walker's `fetch_path` arm
1223 // must refuse before it opens the (nonexistent)
1224 // `caixa.lisp`.
1225 let absent = td.path().join("absent-child");
1226 assert!(
1227 !absent.exists(),
1228 "test precondition: the child path must be absent on disk \
1229 so fetch_path's `!path.exists()` refusal arm fires"
1230 );
1231 let root = Caixa {
1232 nome: "root".into(),
1233 versao: "0.1.0".into(),
1234 kind: CaixaKind::Biblioteca,
1235 edicao: None,
1236 descricao: None,
1237 repositorio: None,
1238 licenca: None,
1239 autores: vec![],
1240 etiquetas: vec![],
1241 deps: vec![Dep {
1242 nome: "absent-child".into(),
1243 versao: "0.1.0".into(),
1244 fonte: Some(DepSource::Path {
1245 caminho: absent.to_string_lossy().into_owned(),
1246 }),
1247 opcional: false,
1248 caracteristicas: vec![],
1249 }],
1250 deps_dev: vec![],
1251 exe: vec![],
1252 bibliotecas: vec![],
1253 servicos: vec![],
1254 limits: None,
1255 behavior: None,
1256 upgrade_from: vec![],
1257 estrategia: None,
1258 max_restarts: None,
1259 restart_window: None,
1260 children: vec![],
1261 membros: vec![],
1262 contratos: vec![],
1263 politicas: None,
1264 placement: None,
1265 entrada: None,
1266 ci: None,
1267 };
1268 let cache_root = td.path().join("cache");
1269 std::fs::create_dir_all(&cache_root).unwrap();
1270 let cache = CacheDir::at(&cache_root);
1271 let cfg = ResolverConfig::default();
1272 let err = resolve_lacre(&root, &cfg, &cache)
1273 .expect_err("resolve_lacre must refuse when a :caminho points at an absent path");
1274 let expected = ResolveError::missing_path("absent-child", absent.clone());
1275 assert_eq!(
1276 format!("{err:?}"),
1277 format!("{expected:?}"),
1278 "fetch_path absent-dir refusal must debug-render byte-equal \
1279 to the substrate-primitive missing_path ctor on the same \
1280 fixture — a silent de-lift of the wire-up back to the \
1281 open-coded struct-literal would still pass this arm today \
1282 (both routes are byte-equal at emit time) but a future \
1283 ctor-side extension (a per-cluster :caminho overlay \
1284 rewrite, a per-scope alias table) would silently diverge \
1285 here as soon as the ctor's construction path widens"
1286 );
1287 assert_eq!(
1288 format!("{err}"),
1289 format!("{expected}"),
1290 "fetch_path absent-dir refusal must display-render \
1291 byte-equal to the substrate-primitive missing_path ctor \
1292 — pins the #[error(...)] template routing on both slots"
1293 );
1294 match err {
1295 ResolveError::MissingPath {
1296 nome: got_nome,
1297 path: got_path,
1298 } => {
1299 assert_eq!(
1300 got_nome, "absent-child",
1301 "fetch_path refusal must surface the offending Dep::nome \
1302 verbatim in the MissingPath::nome slot"
1303 );
1304 assert_eq!(
1305 got_path, absent,
1306 "fetch_path refusal must surface the offending \
1307 PathBuf::from(caminho) verbatim in the \
1308 MissingPath::path slot"
1309 );
1310 }
1311 other => panic!(
1312 "fetch_path absent-dir refusal must construct the \
1313 MissingPath variant on the ResolveError envelope — got \
1314 {other:?}"
1315 ),
1316 }
1317 }
1318
1319 /// Pin that [`ResolveError::missing_pin`] is byte-equal to the
1320 /// pre-lift open-coded three-line `{ nome: nome.to_string() }`
1321 /// one-slot struct-literal on a representative `"caixa-teia"`
1322 /// fixture. Catches any future ctor-side silent `.to_string()`
1323 /// cascade drop, variant rename, or slot-widening that would
1324 /// leave the wire-up compiling but surface a different one-slot
1325 /// payload than the pre-lift struct-literal.
1326 #[test]
1327 fn missing_pin_ctor_matches_struct_literal_wrap() {
1328 let ctor = ResolveError::missing_pin("caixa-teia");
1329 let literal = ResolveError::MissingPin {
1330 nome: "caixa-teia".to_string(),
1331 };
1332 assert_eq!(
1333 format!("{ctor:?}"),
1334 format!("{literal:?}"),
1335 "missing_pin ctor must debug-render byte-equal to the \
1336 open-coded one-slot struct-literal on the same fixture — \
1337 any silent cascade drop or slot-widening surfaces here"
1338 );
1339 assert_eq!(
1340 format!("{ctor}"),
1341 format!("{literal}"),
1342 "missing_pin ctor must display-render byte-equal to the \
1343 open-coded struct-literal on the same fixture — pins the \
1344 thiserror #[error] template's routing through the sole slot"
1345 );
1346 }
1347
1348 /// Sweep [`ResolveError::missing_pin`] across the boundary matrix
1349 /// of `nome` shapes so any wrapper-side silent lowercase, trim,
1350 /// truncate, or `.to_string()`-cascade divergence on the one-field
1351 /// construction surfaces at assert time rather than at a downstream
1352 /// diagnostic consumer that reads the field back and gets a
1353 /// different value than the one it stored.
1354 ///
1355 /// - `nome` axis: canonical DNS-1123 identifier (`"caixa-teia"`),
1356 /// single-char (`"a"`), and an inner-hyphen + digit shape
1357 /// (`"hello-rio-42"`) — the three canonical [`Dep::nome`]-return
1358 /// shapes the [`caixa_core::Dep::nome`] accessor surfaces at the
1359 /// [`fetch_git`] wire-up. Byte-parallel to the sibling
1360 /// [`missing_path_ctor_routes_nome_and_path_through_verbatim`]
1361 /// sweep on the peer [`ResolveError::missing_path`] ctor.
1362 ///
1363 /// Both `&str`-literal and `&String` (via Deref coercion) carriers
1364 /// are exercised because the wire-up hands `dep.nome()` (a `&str`
1365 /// accessor).
1366 #[test]
1367 fn missing_pin_ctor_routes_nome_through_verbatim() {
1368 let nomes: &[&str] = &["caixa-teia", "a", "hello-rio-42"];
1369 for nome in nomes {
1370 // &str carrier.
1371 let ctor = ResolveError::missing_pin(nome);
1372 match &ctor {
1373 ResolveError::MissingPin { nome: got_nome } => {
1374 assert_eq!(
1375 got_nome, nome,
1376 "missing_pin ctor must carry nome byte-verbatim \
1377 — no silent lowercase/trim/truncate on the \
1378 &str-literal carrier at {nome}"
1379 );
1380 }
1381 other => panic!(
1382 "missing_pin ctor must construct the MissingPin \
1383 variant — got {other:?} for {nome:?}"
1384 ),
1385 }
1386 // &String (Deref coercion) carrier — pins that the ctor
1387 // signature accepts both without a pre-conversion at the
1388 // call site.
1389 let owned_nome = String::from(*nome);
1390 let ctor2 = ResolveError::missing_pin(&owned_nome);
1391 match &ctor2 {
1392 ResolveError::MissingPin { nome: got_nome } => {
1393 assert_eq!(
1394 got_nome, nome,
1395 "missing_pin ctor must carry nome byte-verbatim \
1396 on the &String Deref-coercion carrier at {nome}"
1397 );
1398 }
1399 other => panic!(
1400 "missing_pin ctor must construct the MissingPin \
1401 variant on the &String carrier — got {other:?} \
1402 for {nome:?}"
1403 ),
1404 }
1405 }
1406 }
1407
1408 /// Pin the end-to-end route from [`fetch_git`]'s sole-set-pin
1409 /// absence arm through [`ResolveError::missing_pin`]: authoring a
1410 /// `:deps` entry whose expanded `:fonte (:tipo git …)` shape lands
1411 /// with every `tag`/`rev`/`branch` pin `None` (the shape
1412 /// [`expand_fonte`] materializes for an author-omitted-`:fonte`
1413 /// bare [`Dep::simple`] dep under the canonical
1414 /// `"github:pleme-io"` default-host) must surface a
1415 /// `ResolveError::MissingPin { nome }` byte-equal to the
1416 /// substrate-primitive ctor on the same fixture, so a future
1417 /// silent de-lift of the wire-up back to the open-coded
1418 /// struct-literal trips at caixa-resolver test time rather than
1419 /// at a downstream diagnostic consumer far from the wire-up
1420 /// commit. The refusal fires inside [`fetch_git`]'s
1421 /// `sole_pin.ok_or_else(...)?` arm before any `git::clone_or_fetch`
1422 /// call, so the test is fully hermetic — no network, no `git`
1423 /// binary on the runner, no on-disk source directory beyond the
1424 /// empty cache root.
1425 #[test]
1426 fn fetch_git_unpinned_dep_routes_through_missing_pin_ctor() {
1427 let td = tempdir().expect("tempdir");
1428 // Root caixa with one bare, registry-shaped dep whose `:fonte`
1429 // the author omitted — [`expand_fonte`] materializes the
1430 // `github:pleme-io/absent-child` shorthand with every pin
1431 // (`tag`, `rev`, `branch`) `None`, so
1432 // `DepSource::sole_pin()` projects `None` at the fetch site
1433 // and `fetch_git`'s `.ok_or_else` closure-form emission arm
1434 // fires before any git operation.
1435 let root = Caixa {
1436 nome: "root".into(),
1437 versao: "0.1.0".into(),
1438 kind: CaixaKind::Biblioteca,
1439 edicao: None,
1440 descricao: None,
1441 repositorio: None,
1442 licenca: None,
1443 autores: vec![],
1444 etiquetas: vec![],
1445 deps: vec![Dep::simple("absent-child", "^0.1")],
1446 deps_dev: vec![],
1447 exe: vec![],
1448 bibliotecas: vec![],
1449 servicos: vec![],
1450 limits: None,
1451 behavior: None,
1452 upgrade_from: vec![],
1453 estrategia: None,
1454 max_restarts: None,
1455 restart_window: None,
1456 children: vec![],
1457 membros: vec![],
1458 contratos: vec![],
1459 politicas: None,
1460 placement: None,
1461 entrada: None,
1462 ci: None,
1463 };
1464
1465 // Test precondition: the bare `Dep::simple(...)` shape must
1466 // reach `fetch_git` with every pin `None` after `expand_fonte`
1467 // materializes the default-host shorthand. Pinning this
1468 // upstream so a future `Dep::simple` extension (e.g. auto-
1469 // pinning to a lockfile-derived rev) surfaces here rather
1470 // than at the ctor-route assert below.
1471 let expanded = expand_fonte(&root.deps[0], "github:pleme-io");
1472 assert!(
1473 matches!(
1474 &expanded,
1475 DepSource::Git {
1476 tag: None,
1477 rev: None,
1478 branch: None,
1479 ..
1480 }
1481 ),
1482 "test precondition: expand_fonte on a bare Dep::simple \
1483 under the canonical default-host must land with every \
1484 pin `None` — got {expanded:?}"
1485 );
1486 assert_eq!(
1487 expanded.sole_pin(),
1488 None,
1489 "test precondition: DepSource::sole_pin on the unpinned \
1490 git-shape must project None so fetch_git's ok_or_else \
1491 fires before any git operation"
1492 );
1493
1494 let cache_root = td.path().join("cache");
1495 std::fs::create_dir_all(&cache_root).unwrap();
1496 let cache = CacheDir::at(&cache_root);
1497 let cfg = ResolverConfig::default();
1498 let err = resolve_lacre(&root, &cfg, &cache).expect_err(
1499 "resolve_lacre must refuse when a bare :deps entry expands \
1500 to a git source with no :tag/:rev/:branch pin",
1501 );
1502 let expected = ResolveError::missing_pin("absent-child");
1503 assert_eq!(
1504 format!("{err:?}"),
1505 format!("{expected:?}"),
1506 "fetch_git unpinned-dep refusal must debug-render \
1507 byte-equal to the substrate-primitive missing_pin ctor \
1508 on the same fixture — a silent de-lift of the wire-up \
1509 back to the open-coded struct-literal would still pass \
1510 this arm today (both routes are byte-equal at emit time) \
1511 but a future ctor-side extension (a per-scope pin \
1512 overlay, a per-cluster :fonte rewrite) would silently \
1513 diverge here as soon as the ctor's construction path \
1514 widens"
1515 );
1516 assert_eq!(
1517 format!("{err}"),
1518 format!("{expected}"),
1519 "fetch_git unpinned-dep refusal must display-render \
1520 byte-equal to the substrate-primitive missing_pin ctor \
1521 — pins the #[error(...)] template routing on the sole slot"
1522 );
1523 match err {
1524 ResolveError::MissingPin { nome: got_nome } => {
1525 assert_eq!(
1526 got_nome, "absent-child",
1527 "fetch_git refusal must surface the offending \
1528 Dep::nome verbatim in the MissingPin::nome slot"
1529 );
1530 }
1531 other => panic!(
1532 "fetch_git unpinned-dep refusal must construct the \
1533 MissingPin variant on the ResolveError envelope — \
1534 got {other:?}"
1535 ),
1536 }
1537 }
1538}