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
42/// Resolve a root caixa's deps into a canonical lacre, offline if the cache
43/// is warm, otherwise cloning/fetching from git.
44pub fn resolve_lacre(
45 root: &Caixa,
46 cfg: &ResolverConfig,
47 cache: &CacheDir,
48) -> Result<Lacre, ResolveError> {
49 // Direct deps from the root caixa. Read both the runtime `:deps`
50 // and dev-only `:deps-dev` lists through the typed
51 // [`Caixa::deps`] / [`Caixa::deps_dev`] `&[Dep]`-return slice
52 // accessors so every projection of the outer-`Caixa`
53 // dependency-slot axes in the top-level closure resolver's
54 // queue-initialization surface routes through one typed
55 // dispatch — any future accessor extension (a per-scope alias
56 // table, per-cluster canary-version overlay, per-Aplicacao
57 // dev-closure-audit projection the M4 CR materializer's
58 // per-CR reconcile pass consumes) reaches both walks by
59 // construction. Peer of the sibling per-`:deps` /
60 // `:deps-dev` accessor family the ad34b4e / f7fd81e lifts
61 // opened on the outer-`Caixa` slot, extended here onto the
62 // caixa-resolver top-level closure resolver's outermost
63 // queue-seeding walks.
64 //
65 // The paired parent-`:nome` label carried on the queue's `from`
66 // arm — the byte-string the cycle-diagnostic re-labeler's
67 // `ResolveError::Cycle(from.clone())` map_err arm surfaces on any
68 // future extension that promotes the closure walker's per-target
69 // cycle-collapse into a first-class refusal — routes through the
70 // typed [`caixa_core::Caixa::nome`] `&str`-return accessor rather
71 // than the raw `root.nome.clone()` `String`-carry read. Byte-equal
72 // today (`Caixa::nome` is `&self.nome`; `.to_string()` on the
73 // borrowed `&str` allocates exactly one `String` byte-equal to the
74 // prior raw `.nome.clone()` read); the queue's per-transitive-target
75 // sibling push at line 77 already routes through the peer
76 // [`caixa_core::Dep::nome`] accessor (`dep.nome().to_string()`), so
77 // this converge closes the last unlifted parent-`:nome` field-read
78 // in the closure walker's queue-carried `from` axis. Peer with every
79 // prior per-`Caixa` universal-axis converge on the outer-`Caixa`
80 // `:nome` scalar (caixa-helm 22461ef / a7420bd, caixa-flux 4a363bf /
81 // 162e2e2, caixa-mesh 54bf2f3 / 980c059, caixa-crd 61d3429,
82 // caixa-tatara e73b19f, caixa-feira 5131203 / 4b05240 / 3219a42 /
83 // 5bc5178) and sibling to 5ce1b94's `Caixa::deps` / `Caixa::deps_dev`
84 // converge in this same crate on the paired outer-`Caixa`
85 // dependency-slot axis.
86 let mut queue: VecDeque<(Dep, String)> = VecDeque::new();
87 let mut seen: HashSet<String> = HashSet::new();
88 for dep in root.deps() {
89 queue.push_back((dep.clone(), root.nome().to_string()));
90 }
91 if cfg.include_dev {
92 for dep in root.deps_dev() {
93 queue.push_back((dep.clone(), root.nome().to_string()));
94 }
95 }
96
97 // Resolved entries keyed by nome, preserving deterministic deps_diretas.
98 let mut resolved: BTreeMap<String, ResolvedDep> = BTreeMap::new();
99
100 while let Some((dep, from)) = queue.pop_front() {
101 if !seen.insert(dep.nome().to_string()) {
102 continue;
103 }
104 let fetched = fetch_dep(&dep, cfg, cache).map_err(|e| match e {
105 ResolveError::Cycle(_) => ResolveError::Cycle(from.clone()),
106 other => other,
107 })?;
108 for t in &fetched.child_deps {
109 queue.push_back((t.clone(), dep.nome().to_string()));
110 }
111 resolved.insert(
112 dep.nome().to_string(),
113 ResolvedDep {
114 dep,
115 child_deps: fetched.child_deps,
116 resolved_fonte: fetched.resolved_fonte,
117 concrete_versao: fetched.concrete_versao,
118 conteudo: fetched.conteudo,
119 },
120 );
121 }
122
123 // Compute closure hashes in reverse-topological order.
124 let mut fechamento: BTreeMap<String, String> = BTreeMap::new();
125 // Simple fixpoint: re-run until all are hashable (acyclic → terminates).
126 let names: Vec<_> = resolved.keys().cloned().collect();
127 for _ in 0..names.len() {
128 let mut all_done = true;
129 for name in &names {
130 if fechamento.contains_key(name) {
131 continue;
132 }
133 let r = &resolved[name];
134 let child_closures: Option<Vec<String>> = r
135 .child_deps
136 .iter()
137 .map(|c| fechamento.get(c.nome()).cloned())
138 .collect();
139 if let Some(closures) = child_closures {
140 fechamento.insert(name.clone(), closure_hash(&r.conteudo, &closures));
141 } else {
142 all_done = false;
143 }
144 }
145 if all_done {
146 break;
147 }
148 }
149
150 // Build entries in sorted-name order.
151 let entries: Vec<LacreEntry> = resolved
152 .values()
153 .map(|r| LacreEntry {
154 nome: r.dep.nome().to_string(),
155 versao: r.concrete_versao.clone(),
156 fonte: r.resolved_fonte.clone(),
157 conteudo: r.conteudo.clone(),
158 fechamento: fechamento
159 .get(r.dep.nome())
160 .cloned()
161 .unwrap_or_else(|| hash_bytes(b"unresolved")),
162 deps_diretas: r.child_deps.iter().map(|c| c.nome().to_string()).collect(),
163 })
164 .collect();
165
166 Ok(Lacre::from_entries(entries))
167}
168
169struct ResolvedDep {
170 dep: Dep,
171 child_deps: Vec<Dep>,
172 resolved_fonte: DepSource,
173 concrete_versao: String,
174 conteudo: String,
175}
176
177struct FetchedDep {
178 child_deps: Vec<Dep>,
179 resolved_fonte: DepSource,
180 concrete_versao: String,
181 conteudo: String,
182}
183
184fn fetch_dep(
185 dep: &Dep,
186 cfg: &ResolverConfig,
187 cache: &CacheDir,
188) -> Result<FetchedDep, ResolveError> {
189 // Expand :fonte — None → default host shorthand.
190 //
191 // Route the per-`Dep` `:fonte` presence-projection through the
192 // lifted [`expand_fonte`] helper rather than an inline
193 // `dep.fonte.clone().unwrap_or_else(...)` cascade. The helper reads
194 // through the typed [`caixa_core::Dep::fonte`] `Option<&DepSource>`
195 // accessor and materializes the `github:<org>/<nome>`-shaped
196 // `default_host`-derived fallback for the author-omitted arm,
197 // keeping the closure-walker's two per-`Dep` `:fonte`-expansion
198 // consumers (this crate's [`fetch_dep`], caixa-feira/src/cmd/lock.rs's
199 // `resolve_stub` — 33e5d9a converged the peer site onto the same
200 // accessor) both routed through the substrate-primitive typed
201 // dispatch rather than each open-coding its own `.clone()` cascade.
202 let fonte = expand_fonte(dep, &cfg.default_host);
203
204 match &fonte {
205 DepSource::Path { caminho } => fetch_path(dep, caminho),
206 // Route the per-`DepSource::Git`-arm sole-set-pin projection
207 // through the lifted [`DepSource::sole_pin`] substrate
208 // accessor rather than passing broken-out `tag`/`rev`/`branch`
209 // `Option<&str>` triples down for `fetch_git` to re-inline the
210 // `rev.or(tag).or(branch)` cascade on — the two pre-lift
211 // consumers of the sole-set-pin projection (this crate's
212 // `fetch_git` `git checkout` target, caixa-crd's
213 // `dep_into_ref` `CaixaSource.git_ref` fill) now key off
214 // exactly one typed dispatch on the substrate primitive, so
215 // any future rebrand on the precedence axis (a `:commit` pin
216 // peer once signed-commit-verification lands, a `:ref` pin the
217 // M4 operator resolves per-cluster, a promotion of the plain
218 // `Option<String>` pins to a typed `GitPin` newtype) migrates
219 // as a single caixa-core edit rather than a coordinated
220 // rewrite of both consumer sites.
221 DepSource::Git { repo, .. } => fetch_git(dep, repo, fonte.sole_pin(), cache, fonte.clone()),
222 }
223}
224
225fn fetch_path(dep: &Dep, caminho: &str) -> Result<FetchedDep, ResolveError> {
226 let path = PathBuf::from(caminho);
227 if !path.exists() {
228 return Err(ResolveError::MissingPath {
229 nome: dep.nome().to_string(),
230 path,
231 });
232 }
233 let manifest = std::fs::read_to_string(path.join("caixa.lisp"))?;
234 let target = Caixa::from_lisp(&manifest)?;
235 Ok(FetchedDep {
236 // Route the fetched child's `:deps` `Vec<Dep>`-carry projection
237 // through the typed [`Caixa::deps`] `&[Dep]`-return slice
238 // accessor so the transitive walk keys off the same typed
239 // dispatch the outer queue-seeding read at [`resolve_lacre`]
240 // already routes through — a `.to_vec()` on the accessor's
241 // borrowed slice allocates exactly one `Vec<Dep>` per fetched
242 // target, byte-equal in element order to the prior raw
243 // `target.deps.clone()` read.
244 child_deps: target.deps().to_vec(),
245 resolved_fonte: DepSource::Path {
246 caminho: caminho.to_string(),
247 },
248 // Route the fetched child's `:versao` `String`-carry projection
249 // through the typed [`Caixa::versao`] `&str`-return accessor —
250 // sibling to the paired `target.deps().to_vec()` accessor-route
251 // above; a `.to_string()` on the accessor's borrowed `&str`
252 // allocates exactly one `String` byte-equal to the prior raw
253 // `target.versao.clone()` read. Closes the last unlifted per-
254 // `Caixa` universal-axis raw-field-access `String`-carry site on
255 // the caixa-resolver closure-walker's per-fetched-target
256 // [`FetchedDep`] emit surface, sibling to the peer per-`Caixa`
257 // universal-axis converges every peer per-kind renderer (caixa-
258 // helm eb912de, caixa-flux 2fc5f81, caixa-mesh 980c059, caixa-
259 // tatara e73b19f, caixa-crd 41ab9a3) already routes its `:versao`
260 // `String`-carry through.
261 concrete_versao: target.versao().to_string(),
262 conteudo: format!("path:{caminho}"),
263 })
264}
265
266fn fetch_git(
267 dep: &Dep,
268 repo: &str,
269 sole_pin: Option<&str>,
270 cache: &CacheDir,
271 original_fonte: DepSource,
272) -> Result<FetchedDep, ResolveError> {
273 let gitref = sole_pin.ok_or_else(|| ResolveError::MissingPin {
274 nome: dep.nome().to_string(),
275 })?;
276 let full_url = expand_shorthand(repo);
277 let key_bytes = format!("{full_url}#{gitref}");
278 let key = hash_bytes(key_bytes.as_bytes());
279 let short = &key["blake3:".len()..][..16];
280 let dest = cache.source_dir(short);
281
282 git::clone_or_fetch(&full_url, &dest)?;
283 git::checkout(&dest, gitref)?;
284 let sha = git::head_sha(&dest)?;
285 let conteudo = format!("git:{sha}");
286
287 let manifest_path = dest.join("caixa.lisp");
288 let manifest = std::fs::read_to_string(&manifest_path)?;
289 let target = Caixa::from_lisp(&manifest)?;
290
291 // Freeze :fonte into the lacre with the resolved commit — lock files
292 // are reproducible even if the upstream moves the tag.
293 let resolved = match original_fonte {
294 DepSource::Git {
295 repo: r,
296 tag: t,
297 branch: b,
298 ..
299 } => DepSource::Git {
300 repo: r,
301 tag: t,
302 rev: Some(sha),
303 branch: b,
304 },
305 other => other,
306 };
307
308 Ok(FetchedDep {
309 // Same accessor-route as the sibling [`fetch_path`] arm — the
310 // git-fetched child's `:deps` `Vec<Dep>`-carry projection
311 // reaches for the typed [`Caixa::deps`] `&[Dep]`-return slice
312 // accessor so both fetch-arm branches of the transitive walk
313 // key off the same typed dispatch as the outer
314 // [`resolve_lacre`] queue-seeding read.
315 child_deps: target.deps().to_vec(),
316 resolved_fonte: resolved,
317 // Sibling `:versao` `String`-carry converge to the paired
318 // [`fetch_path`] arm above — the git-fetched child's `:versao`
319 // scalar routes through the typed [`Caixa::versao`] `&str`-
320 // return accessor rather than the raw `.versao.clone()` field
321 // access, so both fetch-arm branches of the closure walker land
322 // the concrete-versao carry through the same typed dispatch.
323 concrete_versao: target.versao().to_string(),
324 conteudo,
325 })
326}
327
328/// Split `"github:pleme-io"` → `("github", "pleme-io")`. Unrecognized hosts
329/// return `("github", default_host_as_is)`.
330fn split_default_host(default_host: &str) -> (&str, &str) {
331 default_host
332 .split_once(':')
333 .unwrap_or(("github", default_host))
334}
335
336/// Per-`Dep` `:fonte` presence-projection with the resolver's
337/// `default_host`-derived fallback baked in — the substrate-primitive
338/// typed dispatch every caixa-resolver closure-walker per-`Dep`
339/// `:fonte`-expansion consumer keys off. The helper reads through the
340/// typed [`caixa_core::Dep::fonte`] `Option<&DepSource>`-return accessor
341/// rather than the raw `.fonte.clone()` field-access, so any future
342/// extension of the accessor's semantics (a per-scope source-override
343/// table on `:fonte` the M4 CR materializer resolves at admission time,
344/// a per-tenant `:fonte` rewrite overlay the roadmap acknowledges, a
345/// lacre-projected concrete-source pin resolver) reaches this fallback
346/// surface through exactly one caixa-core edit rather than a coordinated
347/// rewrite of both open-coded expansions.
348///
349/// Sibling of caixa-feira/src/cmd/lock.rs's `resolve_stub`
350/// (33e5d9a) per-`Dep` `:fonte` accessor-route on the peer stub-resolver
351/// site — same "the emit path must route through the substrate-primitive
352/// typed dispatch" discipline extended onto the caixa-resolver
353/// closure-walker's per-target `:fonte`-expansion surface. Byte-equal to
354/// the prior inline cascade today: on the author-set arm the accessor
355/// returns `Some(&<verbatim>)` and `.cloned()` allocates exactly one
356/// `DepSource` byte-equal to the pre-lift `.fonte.clone()` read; on the
357/// author-omitted arm the accessor returns `None` and the fallback
358/// materializes `DepSource::Git { repo: "<host>:<org>/<nome>", tag/rev/
359/// branch: None }` byte-equal to the pre-lift `unwrap_or_else(...)`
360/// tail. The `default_host` split cascades through
361/// [`split_default_host`] so `"github:pleme-io"` → `github:pleme-io/…`
362/// and `"acme-org"` → `github:acme-org/…` (the unrecognized-host arm
363/// defaults `host` to `"github"`).
364///
365/// The fallback deliberately does NOT compose through
366/// [`caixa_core::DepSource::default_github`] — that helper hardcodes
367/// `"github"` as the host segment (`"github:{org}/{nome}"`), whereas
368/// this helper honors an author-configurable `default_host` (e.g. a
369/// `"codeberg:acme"` config would produce `codeberg:acme/<nome>`,
370/// which `default_github` cannot express).
371pub(crate) fn expand_fonte(dep: &Dep, default_host: &str) -> DepSource {
372 dep.fonte().cloned().unwrap_or_else(|| {
373 let (host, org) = split_default_host(default_host);
374 DepSource::Git {
375 repo: format!("{host}:{org}/{}", dep.nome()),
376 tag: None,
377 rev: None,
378 branch: None,
379 }
380 })
381}
382
383#[allow(dead_code)]
384fn _unused_path(_p: &Path) {}
385
386#[cfg(test)]
387mod tests {
388 use super::*;
389 use caixa_core::CaixaKind;
390 use tempfile::tempdir;
391
392 #[test]
393 fn split_default_host_parses_github() {
394 assert_eq!(
395 split_default_host("github:pleme-io"),
396 ("github", "pleme-io")
397 );
398 }
399
400 /// Pin that the per-`Dep` `:fonte` presence-projection under
401 /// [`expand_fonte`] routes through the typed [`Dep::fonte`]
402 /// `Option<&DepSource>`-return accessor rather than the raw
403 /// `.fonte.clone()` field-access, and that the accessor's `None` arm
404 /// materializes the `<host>:<org>/<nome>`-shaped `default_host`-
405 /// derived fallback with `tag`/`rev`/`branch` all `None`. Sweeps
406 /// three arms of the per-`Dep` `:fonte` presence bit × per-config
407 /// `default_host` shape lattice the closure-walker's
408 /// [`fetch_dep`] call-site keys off:
409 ///
410 /// 1. Author-omitted arm × the canonical `"github:pleme-io"`
411 /// default-host shape — accessor projects `None`, helper falls
412 /// back to `DepSource::Git { repo: "github:pleme-io/<nome>",
413 /// tag/rev/branch: None }`, byte-equal to the pre-lift
414 /// inline `format!("{host}:{org}/{}", dep.nome())` on the
415 /// canonical default-host arm.
416 /// 2. Author-omitted arm × an unrecognized single-segment
417 /// `default_host` (`"acme-org"`) — [`split_default_host`]
418 /// defaults `host` to `"github"` and carries the whole string as
419 /// `org`, so the fallback is `DepSource::Git { repo:
420 /// "github:acme-org/<nome>", … }`. Pins the unrecognized-host
421 /// arm's fall-through under the helper.
422 /// 3. Author-set arm × any `default_host` — accessor projects
423 /// `Some(&<verbatim>)`, helper carries the source through
424 /// `.cloned()` byte-verbatim regardless of `default_host` (the
425 /// fallback branch never fires when the author declared
426 /// `:fonte`).
427 ///
428 /// Byte-equal today (`.fonte()` returns `self.fonte.as_ref()`;
429 /// `.cloned()` on `Option<&DepSource>` allocates exactly one
430 /// `DepSource` byte-equal to the prior raw `.fonte.clone()` read);
431 /// catches any future emit-side regression that re-introduces the
432 /// raw `.fonte.clone()` cascade at the [`fetch_dep`] call site (a
433 /// future accessor extension — a per-scope alias table, a per-tenant
434 /// `:fonte` rewrite overlay, a lacre-projected concrete-source pin
435 /// resolver — would then reach only the open-coded cascade and
436 /// silently diverge from the helper's typed dispatch, tripping the
437 /// author-set-arm pin below).
438 ///
439 /// Peer of the sibling caixa-feira/src/cmd/lock.rs's
440 /// `resolve_stub_fonte_routes_through_dep_fonte_accessor` (33e5d9a)
441 /// per-`Dep` `:fonte` accessor-route pin on the stub-resolver
442 /// surface — same "the emit path must route through the substrate-
443 /// primitive typed dispatch" discipline extended onto the
444 /// caixa-resolver closure-walker's per-target `:fonte`-expansion
445 /// helper.
446 #[test]
447 #[allow(clippy::too_many_lines)]
448 fn expand_fonte_routes_through_dep_fonte_accessor() {
449 // Arm 1: author-omitted `:fonte` × canonical `"github:pleme-io"`
450 // default-host. Accessor must project `None` and the helper's
451 // fallback must materialize the canonical
452 // `github:pleme-io/<nome>` shorthand.
453 let bare = Dep::simple("caixa-teia", "^0.1");
454 assert!(
455 bare.fonte().is_none(),
456 "author-omitted :fonte must project None through the accessor \
457 — pins the substrate contract the helper's unwrap_or_else \
458 cascade discriminates on",
459 );
460 let expanded = expand_fonte(&bare, "github:pleme-io");
461 assert_eq!(
462 expanded,
463 DepSource::Git {
464 repo: "github:pleme-io/caixa-teia".to_string(),
465 tag: None,
466 rev: None,
467 branch: None,
468 },
469 "author-omitted :fonte on the canonical github:pleme-io \
470 default-host must fall back to the github:<org>/<nome>-shaped \
471 Git shorthand byte-verbatim",
472 );
473
474 // Arm 2: author-omitted `:fonte` × unrecognized single-segment
475 // `default_host` (`"acme-org"`). `split_default_host`'s
476 // unrecognized-host arm defaults `host` to `"github"` and carries
477 // the whole string as `org`, so the helper's fallback must land
478 // `github:acme-org/<nome>` — pins that the helper reads the
479 // `default_host` through the shared [`split_default_host`]
480 // parser rather than re-inlining a `"github:"`-hardcoded
481 // shorthand (which would produce the same output for this input
482 // by coincidence but would diverge on a `"codeberg:acme"`-shaped
483 // config the arm-3 assertion below stresses).
484 let expanded_unrecognized_host = expand_fonte(&bare, "acme-org");
485 assert_eq!(
486 expanded_unrecognized_host,
487 DepSource::Git {
488 repo: "github:acme-org/caixa-teia".to_string(),
489 tag: None,
490 rev: None,
491 branch: None,
492 },
493 "author-omitted :fonte on an unrecognized single-segment \
494 default_host must default the host segment to `github` and \
495 carry the whole config string as the org",
496 );
497
498 // Arm 3: author-set `:fonte` × any `default_host`. Accessor must
499 // project `Some(&<verbatim>)`, and the helper must carry the
500 // source through `.cloned()` byte-verbatim — the fallback branch
501 // never fires when the author declared `:fonte` at authoring
502 // time. Any regression that re-inlined the raw `.fonte.clone()`
503 // cascade would still pass this arm today (both routes are
504 // byte-equal on the `Some` arm) but a future accessor extension
505 // (a per-scope alias table, a per-tenant rewrite overlay)
506 // would silently diverge here — the pin catches that
507 // divergence at compile time as soon as the accessor's return
508 // shape widens beyond `self.fonte.as_ref()`.
509 let with_path = Dep {
510 nome: "child".into(),
511 versao: "0.1.0".into(),
512 fonte: Some(DepSource::Path {
513 caminho: "/tmp/child".into(),
514 }),
515 opcional: false,
516 caracteristicas: vec![],
517 };
518 assert_eq!(
519 with_path.fonte(),
520 Some(&DepSource::Path {
521 caminho: "/tmp/child".into(),
522 }),
523 "author-set :fonte must project Some(&<verbatim>) through \
524 the accessor — pins the substrate contract the helper's \
525 .cloned() carry discriminates on",
526 );
527 let expanded_author_set = expand_fonte(&with_path, "github:pleme-io");
528 assert_eq!(
529 expanded_author_set,
530 DepSource::Path {
531 caminho: "/tmp/child".into(),
532 },
533 "author-set :fonte must carry through the helper .cloned() \
534 byte-verbatim regardless of default_host — the fallback \
535 branch must not fire when the accessor projects Some(&…)",
536 );
537 // Cross-check: swapping `default_host` on the author-set arm
538 // must not perturb the output — the fallback string is dead
539 // code on the `Some` arm.
540 let expanded_author_set_alt_host = expand_fonte(&with_path, "codeberg:acme");
541 assert_eq!(
542 expanded_author_set, expanded_author_set_alt_host,
543 "author-set :fonte must be default_host-agnostic — the \
544 fallback shorthand must not leak into the output when the \
545 accessor projects Some(&…)",
546 );
547 }
548
549 /// Pin that every projection of the outer-`Caixa` `:deps` /
550 /// `:deps-dev` dependency-slot family in [`resolve_lacre`] and the
551 /// per-target [`fetch_path`] transitive walk — the outer
552 /// queue-seeding walks over `root.deps()` and `root.deps_dev()`
553 /// (the two entry points every downstream git-clone target flows
554 /// through), plus the per-fetched-target `target.deps().to_vec()`
555 /// child-dep collection inside [`fetch_path`] the transitive walk
556 /// keys off — routes through the typed [`caixa_core::Caixa::deps`]
557 /// / [`caixa_core::Caixa::deps_dev`] `&[Dep]`-return slice
558 /// accessors rather than raw `&root.deps` / `&root.deps_dev` /
559 /// `target.deps.clone()` field reads. Byte-equal today (each
560 /// accessor returns `self.<field>.as_slice()`); catches any future
561 /// emit-site regression that reintroduces a raw field read, and
562 /// pins the closure-walker's four-site accessor-routing against a
563 /// hermetic tempdir-hosted `defcaixa`-parsed three-caixa closure
564 /// (root → child → grandchild via `:fonte (:tipo path :caminho …)`
565 /// on each edge, plus a dev-only sibling under `:deps-dev` that
566 /// the closure walker admits only when `cfg.include_dev` is true).
567 /// Peer of the sibling caixa-crd's `dep_into_ref_routes_through_dep_accessors`
568 /// (d65d1bf) per-entry `:deps` sub-slot family pin on the paired
569 /// per-`Dep` `Option<&DepSource>` composite-reference axis — same
570 /// "the emit path must route through the substrate-primitive
571 /// typed dispatch" discipline extended onto the outer top-level
572 /// [`Caixa`] `&[Dep]` slice axes at the closure-resolver's
573 /// queue-seeding surface.
574 #[test]
575 #[allow(clippy::too_many_lines)]
576 fn resolve_lacre_routes_dep_slot_family_through_caixa_accessors() {
577 let td = tempdir().expect("tempdir");
578 // Grandchild caixa on disk — no deps.
579 let grandchild_path = td.path().join("grandchild");
580 std::fs::create_dir_all(&grandchild_path).unwrap();
581 std::fs::write(
582 grandchild_path.join("caixa.lisp"),
583 r#"(defcaixa
584 :nome "grandchild"
585 :versao "0.1.0"
586 :kind Biblioteca
587 :bibliotecas ("lib/grandchild.lisp"))"#,
588 )
589 .unwrap();
590
591 // Child caixa on disk — depends on grandchild via Path.
592 let child_path = td.path().join("child");
593 std::fs::create_dir_all(&child_path).unwrap();
594 let child_lisp = format!(
595 r#"(defcaixa
596 :nome "child"
597 :versao "0.1.0"
598 :kind Biblioteca
599 :bibliotecas ("lib/child.lisp")
600 :deps ((:nome "grandchild" :versao "0.1.0"
601 :fonte (:tipo path :caminho "{}"))))"#,
602 grandchild_path.display()
603 );
604 std::fs::write(child_path.join("caixa.lisp"), child_lisp).unwrap();
605
606 // Dev-only sibling — no deps of its own.
607 let devchild_path = td.path().join("devchild");
608 std::fs::create_dir_all(&devchild_path).unwrap();
609 std::fs::write(
610 devchild_path.join("caixa.lisp"),
611 r#"(defcaixa
612 :nome "devchild"
613 :versao "0.1.0"
614 :kind Biblioteca
615 :bibliotecas ("lib/devchild.lisp"))"#,
616 )
617 .unwrap();
618
619 let root = Caixa {
620 nome: "root".into(),
621 versao: "0.1.0".into(),
622 kind: CaixaKind::Biblioteca,
623 edicao: None,
624 descricao: None,
625 repositorio: None,
626 licenca: None,
627 autores: vec![],
628 etiquetas: vec![],
629 deps: vec![Dep {
630 nome: "child".into(),
631 versao: "0.1.0".into(),
632 fonte: Some(DepSource::Path {
633 caminho: child_path.to_string_lossy().into_owned(),
634 }),
635 opcional: false,
636 caracteristicas: vec![],
637 }],
638 deps_dev: vec![Dep {
639 nome: "devchild".into(),
640 versao: "0.1.0".into(),
641 fonte: Some(DepSource::Path {
642 caminho: devchild_path.to_string_lossy().into_owned(),
643 }),
644 opcional: false,
645 caracteristicas: vec![],
646 }],
647 exe: vec![],
648 bibliotecas: vec![],
649 servicos: vec![],
650 limits: None,
651 behavior: None,
652 upgrade_from: vec![],
653 estrategia: None,
654 max_restarts: None,
655 restart_window: None,
656 children: vec![],
657 membros: vec![],
658 contratos: vec![],
659 politicas: None,
660 placement: None,
661 entrada: None,
662 ci: None,
663 };
664
665 let cache_root = td.path().join("cache");
666 std::fs::create_dir_all(&cache_root).unwrap();
667 let cache = CacheDir::at(&cache_root);
668
669 // include_dev=false: the closure-walker admits only `:deps`
670 // entries + their transitives. `:deps-dev` sibling must NOT
671 // appear, and the walker must have iterated `root.deps()`
672 // (surfacing `"child"`) and then `child.deps().to_vec()` inside
673 // `fetch_path` (surfacing `"grandchild"`).
674 let cfg = ResolverConfig::default();
675 let lacre = resolve_lacre(&root, &cfg, &cache).expect("resolve runtime-only closure");
676 let names: Vec<&str> = lacre.entradas.iter().map(|e| e.nome.as_str()).collect();
677 assert!(
678 names.contains(&"child"),
679 "resolve_lacre must surface `child` via root.deps() — got {names:?}"
680 );
681 assert!(
682 names.contains(&"grandchild"),
683 "resolve_lacre must surface `grandchild` via the transitive \
684 fetch_path target.deps().to_vec() walk — got {names:?}"
685 );
686 assert!(
687 !names.contains(&"devchild"),
688 "resolve_lacre must NOT surface `devchild` when \
689 include_dev=false (deps_dev accessor must be walked only \
690 under the include_dev cfg arm) — got {names:?}"
691 );
692
693 // include_dev=true: the closure-walker also admits `:deps-dev`
694 // entries. The `devchild` sibling must appear via
695 // `root.deps_dev()`.
696 let cfg_with_dev = ResolverConfig {
697 include_dev: true,
698 ..Default::default()
699 };
700 let lacre_with_dev =
701 resolve_lacre(&root, &cfg_with_dev, &cache).expect("resolve with dev closure");
702 let names_with_dev: Vec<&str> = lacre_with_dev
703 .entradas
704 .iter()
705 .map(|e| e.nome.as_str())
706 .collect();
707 assert!(
708 names_with_dev.contains(&"child"),
709 "include_dev=true closure must still surface `child` via \
710 root.deps() — got {names_with_dev:?}"
711 );
712 assert!(
713 names_with_dev.contains(&"grandchild"),
714 "include_dev=true closure must still surface `grandchild` \
715 via the transitive walk — got {names_with_dev:?}"
716 );
717 assert!(
718 names_with_dev.contains(&"devchild"),
719 "include_dev=true closure must surface `devchild` via \
720 root.deps_dev() — got {names_with_dev:?}"
721 );
722 }
723
724 /// Pin that both fetch-arm branches of the closure walker's per-
725 /// fetched-target [`FetchedDep`] emit surface — the [`fetch_path`]
726 /// arm and the [`fetch_git`] arm — carry each target's `:versao`
727 /// scalar into the resulting [`crate::LacreEntry`] via the typed
728 /// [`caixa_core::Caixa::versao`] `&str`-return accessor rather than a
729 /// raw `target.versao.clone()` field access. Byte-equal today
730 /// (`Caixa::versao` is `&self.versao`); catches any future emit-side
731 /// regression that reintroduces a raw field read and pins the two
732 /// [`FetchedDep::concrete_versao`] construction sites against a
733 /// hermetic tempdir-hosted three-caixa closure whose `:versao` bytes
734 /// distinguish each layer (root `"0.9.0"` → child `"0.5.2"` →
735 /// grandchild `"0.1.3"`), so a stub-that-hardcodes-a-fixed-string
736 /// regression trips on any layer.
737 ///
738 /// Peer of the sibling [`resolve_lacre_routes_dep_slot_family_through_caixa_accessors`]
739 /// per-`Caixa` `:deps` / `:deps-dev` slot-family pin on the sibling
740 /// per-`Caixa` `&[Dep]` slice-return accessor axis — same "the
741 /// emit path must route through the substrate-primitive typed
742 /// dispatch" discipline extended onto the outer top-level [`Caixa`]
743 /// `:versao` `&str`-return universal-axis at the closure-resolver's
744 /// per-fetched-target [`FetchedDep`] emit surface. Sibling to the
745 /// peer per-`Caixa` universal-axis converges every peer per-kind
746 /// renderer (caixa-helm eb912de, caixa-flux 2fc5f81, caixa-mesh
747 /// 980c059, caixa-tatara e73b19f, caixa-crd 41ab9a3) already routes
748 /// its `:versao` `String`-carry through.
749 #[test]
750 fn resolve_lacre_fetched_target_versao_routes_through_caixa_versao_accessor() {
751 let td = tempdir().expect("tempdir");
752 // Grandchild caixa on disk — `:versao "0.1.3"`.
753 let grandchild_path = td.path().join("grandchild");
754 std::fs::create_dir_all(&grandchild_path).unwrap();
755 std::fs::write(
756 grandchild_path.join("caixa.lisp"),
757 r#"(defcaixa
758 :nome "grandchild"
759 :versao "0.1.3"
760 :kind Biblioteca
761 :bibliotecas ("lib/grandchild.lisp"))"#,
762 )
763 .unwrap();
764
765 // Child caixa on disk — `:versao "0.5.2"`, depends on grandchild
766 // via Path so the closure walker traverses through
767 // [`fetch_path`] on both edges.
768 let child_path = td.path().join("child");
769 std::fs::create_dir_all(&child_path).unwrap();
770 let child_lisp = format!(
771 r#"(defcaixa
772 :nome "child"
773 :versao "0.5.2"
774 :kind Biblioteca
775 :bibliotecas ("lib/child.lisp")
776 :deps ((:nome "grandchild" :versao "0.1.3"
777 :fonte (:tipo path :caminho "{}"))))"#,
778 grandchild_path.display()
779 );
780 std::fs::write(child_path.join("caixa.lisp"), child_lisp).unwrap();
781
782 let root = Caixa {
783 nome: "root".into(),
784 versao: "0.9.0".into(),
785 kind: CaixaKind::Biblioteca,
786 edicao: None,
787 descricao: None,
788 repositorio: None,
789 licenca: None,
790 autores: vec![],
791 etiquetas: vec![],
792 deps: vec![Dep {
793 nome: "child".into(),
794 versao: "0.5.2".into(),
795 fonte: Some(DepSource::Path {
796 caminho: child_path.to_string_lossy().into_owned(),
797 }),
798 opcional: false,
799 caracteristicas: vec![],
800 }],
801 deps_dev: vec![],
802 exe: vec![],
803 bibliotecas: vec![],
804 servicos: vec![],
805 limits: None,
806 behavior: None,
807 upgrade_from: vec![],
808 estrategia: None,
809 max_restarts: None,
810 restart_window: None,
811 children: vec![],
812 membros: vec![],
813 contratos: vec![],
814 politicas: None,
815 placement: None,
816 entrada: None,
817 ci: None,
818 };
819
820 let cache_root = td.path().join("cache");
821 std::fs::create_dir_all(&cache_root).unwrap();
822 let cache = CacheDir::at(&cache_root);
823 let cfg = ResolverConfig::default();
824 let lacre = resolve_lacre(&root, &cfg, &cache).expect("resolve closure");
825
826 // Every fetched target's `:versao` scalar must land in the
827 // resulting `LacreEntry.versao` byte-verbatim to what
828 // `Caixa::versao()` returns for the source caixa on disk. A
829 // regression that hardcoded a fixed string on either fetch-arm
830 // side of the closure walker trips on the layer whose byte-shape
831 // it drifted off.
832 let pairs: Vec<(&str, &str)> = lacre
833 .entradas
834 .iter()
835 .map(|e| (e.nome.as_str(), e.versao.as_str()))
836 .collect();
837 assert!(
838 pairs.contains(&("child", "0.5.2")),
839 "child entry must carry `:versao \"0.5.2\"` verbatim through \
840 fetch_path -> Caixa::versao() -> FetchedDep::concrete_versao \
841 -> LacreEntry.versao — got {pairs:?}"
842 );
843 assert!(
844 pairs.contains(&("grandchild", "0.1.3")),
845 "grandchild entry must carry `:versao \"0.1.3\"` verbatim \
846 through the transitive walk's Caixa::versao() accessor route \
847 — got {pairs:?}"
848 );
849 }
850
851 /// Pin that the closure-walker's queue-seeding parent-`:nome` label
852 /// axis — the `String` carried on the queue's `from` arm at both the
853 /// outer `for dep in root.deps()` runtime seed and the
854 /// `cfg.include_dev`-gated `for dep in root.deps_dev()` dev-only
855 /// seed inside [`resolve_lacre`] — routes through the typed
856 /// [`caixa_core::Caixa::nome`] `&str`-return accessor rather than a
857 /// raw `root.nome.clone()` field read. The queue's per-transitive-
858 /// target sibling push already routes the paired parent-`:nome`
859 /// label through the peer [`caixa_core::Dep::nome`] accessor
860 /// (`dep.nome().to_string()` at the transitive-walk head), so this
861 /// pin closes the last unlifted parent-`:nome` field-read axis in
862 /// the closure walker's queue-carried `from` surface.
863 ///
864 /// Byte-equal today (`Caixa::nome` is `&self.nome`; `.to_string()`
865 /// on the borrowed `&str` allocates exactly one `String` byte-equal
866 /// to the prior raw `.nome.clone()` read); pin catches any future
867 /// silent detour that reintroduces a raw field read at either queue-
868 /// seeding site. The hermetic tempdir fixture uses a byte-
869 /// distinctive `:nome` on the root (`"root-abc"` — pairing DNS-1123
870 /// legality with three ASCII bytes distinct from every child's
871 /// `:nome` so a stub-that-hardcodes-a-fixed-string regression on
872 /// either seeding site can only pass by accident), and asserts
873 /// [`caixa_core::Caixa::nome`] returns byte-verbatim to what
874 /// [`resolve_lacre`]'s queue-seed reads. The end-to-end assertion
875 /// on the resulting [`crate::LacreEntry`] set pins that both
876 /// `:deps` and `:deps-dev` walks reach their transitive targets
877 /// after the accessor-route substitution, so a regression that
878 /// broke the queue-seeding shape by dropping the second push arm
879 /// (e.g. mis-collapsing the two guarded arms onto one) surfaces
880 /// as a missing child in the resolved closure.
881 ///
882 /// Peer of the sibling [`resolve_lacre_routes_dep_slot_family_through_caixa_accessors`]
883 /// per-`:deps` / `:deps-dev` slot-family pin above and of the
884 /// [`resolve_lacre_fetched_target_versao_routes_through_caixa_versao_accessor`]
885 /// per-`:versao` axis pin (0556249) on the same
886 /// closure-walker emit surface — same "the emit path must route
887 /// through the substrate-primitive typed dispatch" discipline the
888 /// per-`Caixa` `.nome` universal-axis converges every peer per-kind
889 /// renderer already carry, extended onto the outer top-level
890 /// [`Caixa`] `:nome` `&str`-return universal-axis at the closure-
891 /// resolver's queue-seeded per-transitive-target `from` label.
892 #[test]
893 fn resolve_lacre_queue_seed_parent_nome_routes_through_caixa_nome_accessor() {
894 let td = tempdir().expect("tempdir");
895 // Runtime-child caixa on disk — no deps of its own, distinct
896 // `:nome` bytes so it can be identified in the resolved closure.
897 let child_path = td.path().join("child");
898 std::fs::create_dir_all(&child_path).unwrap();
899 std::fs::write(
900 child_path.join("caixa.lisp"),
901 r#"(defcaixa
902 :nome "child"
903 :versao "0.1.0"
904 :kind Biblioteca
905 :bibliotecas ("lib/child.lisp"))"#,
906 )
907 .unwrap();
908
909 // Dev-only sibling — distinct `:nome`, guards the second
910 // seeding-arm branch.
911 let devchild_path = td.path().join("devchild");
912 std::fs::create_dir_all(&devchild_path).unwrap();
913 std::fs::write(
914 devchild_path.join("caixa.lisp"),
915 r#"(defcaixa
916 :nome "devchild"
917 :versao "0.1.0"
918 :kind Biblioteca
919 :bibliotecas ("lib/devchild.lisp"))"#,
920 )
921 .unwrap();
922
923 let root = Caixa {
924 nome: "root-abc".into(),
925 versao: "0.9.0".into(),
926 kind: CaixaKind::Biblioteca,
927 edicao: None,
928 descricao: None,
929 repositorio: None,
930 licenca: None,
931 autores: vec![],
932 etiquetas: vec![],
933 deps: vec![Dep {
934 nome: "child".into(),
935 versao: "0.1.0".into(),
936 fonte: Some(DepSource::Path {
937 caminho: child_path.to_string_lossy().into_owned(),
938 }),
939 opcional: false,
940 caracteristicas: vec![],
941 }],
942 deps_dev: vec![Dep {
943 nome: "devchild".into(),
944 versao: "0.1.0".into(),
945 fonte: Some(DepSource::Path {
946 caminho: devchild_path.to_string_lossy().into_owned(),
947 }),
948 opcional: false,
949 caracteristicas: vec![],
950 }],
951 exe: vec![],
952 bibliotecas: vec![],
953 servicos: vec![],
954 limits: None,
955 behavior: None,
956 upgrade_from: vec![],
957 estrategia: None,
958 max_restarts: None,
959 restart_window: None,
960 children: vec![],
961 membros: vec![],
962 contratos: vec![],
963 politicas: None,
964 placement: None,
965 entrada: None,
966 ci: None,
967 };
968
969 // Substrate-primitive byte-parity pin: `Caixa::nome()` returns
970 // the raw `:nome` byte-string verbatim. Both queue-seeded push
971 // arms compose `root.nome().to_string()` off this accessor, so
972 // a future accessor extension (a canonicalization pass, an
973 // aliasing overlay) reaches both call sites through one edit.
974 assert_eq!(
975 root.nome(),
976 "root-abc",
977 "Caixa::nome() must return the :nome byte-string verbatim — \
978 pins the substrate contract both queue-seeded push arms \
979 compose the parent-`:nome` label off of"
980 );
981 assert_eq!(
982 root.nome().to_string(),
983 "root-abc",
984 "root.nome().to_string() must equal the raw :nome byte-string \
985 verbatim — pins the accessor-routed String-carry the queue's \
986 `from` arm receives at both seeding sites"
987 );
988
989 // End-to-end pin on the closure walker's two-arm seeding surface:
990 // both `:deps` and `:deps-dev` walks must reach their transitive
991 // targets after the accessor-route substitution. A regression
992 // that dropped either arm's queue-seed push surfaces here as a
993 // missing child in the resolved lacre.
994 let cache_root = td.path().join("cache");
995 std::fs::create_dir_all(&cache_root).unwrap();
996 let cache = CacheDir::at(&cache_root);
997 let cfg_with_dev = ResolverConfig {
998 include_dev: true,
999 ..Default::default()
1000 };
1001 let lacre =
1002 resolve_lacre(&root, &cfg_with_dev, &cache).expect("resolve closure with dev deps");
1003 let names: Vec<&str> = lacre.entradas.iter().map(|e| e.nome.as_str()).collect();
1004 assert!(
1005 names.contains(&"child"),
1006 "resolve_lacre with the accessor-routed queue-seed must \
1007 surface `child` via the runtime `:deps` arm — got {names:?}"
1008 );
1009 assert!(
1010 names.contains(&"devchild"),
1011 "resolve_lacre with the accessor-routed queue-seed must \
1012 surface `devchild` via the `cfg.include_dev`-gated \
1013 `:deps-dev` arm — got {names:?}"
1014 );
1015 }
1016}