sergio/takana
> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…
⎇ c583c4d679523abe02800398b711708b447d3a0f
crates/hammer-build/src/lib.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
Etapa G: frontera go.mod-en-subdir — knob [build] subdir (vendor/detect/compile en src/<subdir>)
1//! El laboratorio: compila una receta de forma hermética y la sella en el store; e hidrata
2//! artefactos al FHS. Ver `docs/02-build-lab.md` y `docs/03-hydration.md`.
3
4use std::path::{Path, PathBuf};
5
6use hammer_core::{ArtifactHash, Compiler, LinkMode, Phases, Recipe, Store};
7
8pub mod config;
9pub mod download;
10pub mod fetch;
11pub mod hydrate;
12pub mod sandbox;
13pub mod swm_bridge;
14
15pub use config::BuildConfig;
16pub use hydrate::{hydrate as run_hydrate, DynamicSpec, HydrateReport, HydratedFile};
17pub use sandbox::{BuildFailure, Sandbox};
18pub use swm_bridge::build_source_patch;
19
20/// Calcula el `ArtifactHash` de una receta, resolviendo recursivamente sus deps de build.
21/// Ver `docs/02-build-lab.md` §2 y §5.
22///
23/// **Catálogo = `base_dir` de la receta.** Cada nombre en `deps.build` se resuelve al hermano
24/// `<base_dir>/<dep>.toml`; su hash entra en el de la receta (Merkle sobre el subgrafo de build).
25/// Así dos recetas con la misma fuente pero distinto grafo de deps hashean distinto, y cambiar
26/// una dep transitiva invalida la caché de todo lo que la usa.
27///
28/// Errores: una receta con `deps.build` pero sin `base_dir` (parseada de TOML crudo, no cargada
29/// de disco) no puede resolver su catálogo; un `<dep>.toml` ausente, o un ciclo de deps, también
30/// fallan con un mensaje que nombra la cadena.
31pub fn artifact_hash(recipe: &Recipe, _store: &Store) -> hammer_core::Result<ArtifactHash> {
32 let mut stack: Vec<String> = Vec::new();
33 artifact_hash_rec(recipe, &mut stack)
34}
35
36fn artifact_hash_rec(recipe: &Recipe, stack: &mut Vec<String>) -> hammer_core::Result<ArtifactHash> {
37 if stack.iter().any(|n| n == &recipe.name) {
38 stack.push(recipe.name.clone());
39 return Err(hammer_core::Error::Recipe(format!(
40 "ciclo de dependencias de build: {}",
41 stack.join(" -> ")
42 )));
43 }
44
45 let dep_hashes = resolve_build_dep_hashes(recipe, stack)?;
46 let inputs = recipe.hash_inputs(&dep_hashes)?;
47 let refs: Vec<&[u8]> = inputs.iter().map(|v| v.as_slice()).collect();
48 Ok(ArtifactHash::of_inputs(&refs))
49}
50
51/// Resuelve la ruta de una dep de build: primero como HERMANO (`base_dir/{dep}.toml`), y si no
52/// existe, en el catálogo PADRE (`base_dir/../{dep}.toml`). El fallback al padre permite que una
53/// receta en `recipes/incoming/` (cola de staging) dependa de una del corpus `recipes/` sin
54/// duplicarla ni symlinkearla — destraba la frontera de deps-del-corpus (openssl, zlib, …) en la
55/// granja, que construye desde `incoming/`. Si ninguna existe, devuelve la ruta hermana (para que
56/// el error apunte al lugar esperado).
57fn resolve_dep_path(base_dir: &Path, dep: &str) -> PathBuf {
58 let sibling = base_dir.join(format!("{dep}.toml"));
59 if sibling.exists() {
60 return sibling;
61 }
62 if let Some(parent) = base_dir.parent() {
63 let in_parent = parent.join(format!("{dep}.toml"));
64 if in_parent.exists() {
65 return in_parent;
66 }
67 }
68 sibling
69}
70
71/// Resuelve y hashea, en orden de declaración, cada `deps.build` de `recipe` cargándolo del
72/// catálogo (su `base_dir`). `stack` lleva la cadena en curso para detectar ciclos.
73fn resolve_build_dep_hashes(
74 recipe: &Recipe,
75 stack: &mut Vec<String>,
76) -> hammer_core::Result<Vec<ArtifactHash>> {
77 if recipe.deps.build.is_empty() {
78 return Ok(Vec::new());
79 }
80 if recipe.base_dir.as_os_str().is_empty() {
81 return Err(hammer_core::Error::Recipe(format!(
82 "la receta '{}' declara deps de build {:?} pero no tiene base_dir; \
83 cárgala con Recipe::load_from_path para resolver el catálogo",
84 recipe.name, recipe.deps.build
85 )));
86 }
87
88 stack.push(recipe.name.clone());
89 let mut hashes = Vec::with_capacity(recipe.deps.build.len());
90 for dep in &recipe.deps.build {
91 let dep_path = resolve_dep_path(&recipe.base_dir, dep);
92 let dep_recipe = Recipe::load_from_path(&dep_path).map_err(|e| {
93 hammer_core::Error::Recipe(format!(
94 "no pude cargar la dep de build '{dep}' de '{}' ({}): {e}",
95 recipe.name,
96 dep_path.display()
97 ))
98 })?;
99 hashes.push(artifact_hash_rec(&dep_recipe, stack)?);
100 }
101 stack.pop();
102 Ok(hashes)
103}
104
105/// Construye (si falta) cada `deps.build` y devuelve sus directorios sellados en el store, para
106/// apilarlos como capas overlay del sandbox (variante b: bwrap necesita libcap.a + sys/capability.h,
107/// ausentes del toolchain Alpine). Recursivo vía `build`: una dep con sus propias deps se materializa
108/// transitivamente. Los ciclos ya los corta `artifact_hash` (con su `stack`) antes de llegar acá.
109fn materialize_build_deps(
110 recipe: &Recipe,
111 cfg: &BuildConfig,
112 store: &Store,
113) -> hammer_core::Result<Vec<PathBuf>> {
114 if recipe.deps.build.is_empty() {
115 return Ok(Vec::new());
116 }
117 if recipe.base_dir.as_os_str().is_empty() {
118 return Err(hammer_core::Error::Recipe(format!(
119 "la receta '{}' declara deps de build {:?} pero no tiene base_dir; \
120 cárgala con Recipe::load_from_path para resolver el catálogo",
121 recipe.name, recipe.deps.build
122 )));
123 }
124 let mut dirs = Vec::with_capacity(recipe.deps.build.len());
125 for dep in &recipe.deps.build {
126 let dep_path = resolve_dep_path(&recipe.base_dir, dep);
127 let dep_recipe = Recipe::load_from_path(&dep_path).map_err(|e| {
128 hammer_core::Error::Recipe(format!(
129 "no pude cargar la dep de build '{dep}' de '{}' ({}): {e}",
130 recipe.name,
131 dep_path.display()
132 ))
133 })?;
134 let dep_hash = build(&dep_recipe, cfg, store)?;
135 dirs.push(store.path_of(&dep_hash, &dep_recipe.name));
136 }
137 Ok(dirs)
138}
139
140/// Resuelve el directorio de zig para una receta: el por defecto del lab (`cfg.zig_dir`), salvo que
141/// la receta fije `build.zig_version`, en cuyo caso se usa el zig de esa versión, ubicado como
142/// **hermano** del por defecto (`<…>/zig-x86_64-linux-<version>`, el layout de `.dev-fs/tools/`).
143/// Escotilla por-receta para esquivar la regresión de zig 0.14 sin tocar el toolchain global.
144fn effective_zig_dir(cfg: &BuildConfig, recipe: &Recipe) -> hammer_core::Result<PathBuf> {
145 let Some(version) = &recipe.build.zig_version else {
146 return Ok(cfg.zig_dir.clone());
147 };
148 let parent = cfg.zig_dir.parent().unwrap_or_else(|| Path::new("."));
149 let dir = parent.join(format!("zig-x86_64-linux-{version}"));
150 if !dir.join("zig").is_file() {
151 return Err(hammer_core::Error::Recipe(format!(
152 "zig_version=\"{version}\": no encuentro el ejecutable zig en {} \
153 (bajá ese zig a {}/)",
154 dir.display(),
155 parent.display(),
156 )));
157 }
158 Ok(dir)
159}
160
161/// Compila una receta y devuelve el hash del artefacto sellado en el store.
162/// Si el hash ya existe en el store, devuelve sin recompilar (caché).
163pub fn build(
164 recipe: &Recipe,
165 cfg: &BuildConfig,
166 store: &Store,
167) -> hammer_core::Result<ArtifactHash> {
168 let h = artifact_hash(recipe, store)?;
169 if store.has(&h, &recipe.name) {
170 tracing::info!(hash = %h, name = %recipe.name, "caché: artefacto ya en el store");
171 return Ok(h);
172 }
173 // zig efectivo de ESTA receta: el por defecto del lab, salvo que la receta fije `zig_version`
174 // (escotilla para esquivar la regresión de zig 0.14 en ciertos binarios musl; ver SDD 11 §7.2b).
175 let zig_dir = effective_zig_dir(cfg, recipe)?;
176 sandbox::ensure_layout(&cfg.rootfs, &zig_dir)?;
177
178 // Materializa las deps de build (las construye si faltan) ANTES del fetch: sus árboles sellados
179 // se apilan como capas overlay del sandbox para que headers/libs/pkgconfig estén en /usr.
180 let dep_dirs = materialize_build_deps(recipe, cfg, store)?;
181 if !dep_dirs.is_empty() {
182 tracing::info!(n = dep_dirs.len(), "deps de build materializadas como capas overlay");
183 }
184
185 tracing::info!(hash = %h, name = %recipe.name, "fetch");
186 let src_tree = fetch::fetch(recipe, &cfg.work_root)?;
187
188 if !recipe.source.patches.is_empty() {
189 tracing::info!(n = recipe.source.patches.len(), "aplicando patches");
190 fetch::apply_patches(recipe, &src_tree)?;
191 }
192
193 // Receta efectiva para resolver las FASES: puede ganar un `-p <pkg>` si la fuente es un
194 // workspace Cargo virtual (ver inject_cargo_package_selector). El hash `h` ya está fijado desde
195 // los flags ORIGINALES de la receta; el `-p` es resolución determinista del lab (misma fuente →
196 // mismo paquete) ⇒ no afecta la reproducibilidad ni el sidecar (que guarda la receta original).
197 let mut eff_recipe = recipe.clone();
198
199 // Recetas Cargo: vendoreamos las deps (red permitida en el fetch) para que el build sea
200 // hermético `--offline` dentro del sandbox. Tras los patches, por si tocaron Cargo.toml/lock.
201 if matches!(detect_build_system(&src_tree), BuildSys::Cargo) {
202 // El source se copia DENTRO del repo hammer (que es un workspace Cargo) ⇒ si el Cargo.toml
203 // de la fuente NO declara su propio `[workspace]`, cargo cree que pertenece al workspace de
204 // hammer y `cargo vendor` aborta. Inyectamos un `[workspace]` vacío para aislarla. SÓLO si
205 // no lo tiene ya (las recetas del corpus que lo parchean a mano siguen funcionando sin
206 // duplicar la tabla). Esto desbloquea genéricamente los imports Rust (Etapa G) sin un patch
207 // por receta. Ver memoria 'cargo-recipe crate suelto'.
208 ensure_cargo_workspace_isolation(&src_tree)?;
209 // Extractor de workspaces (Etapa G): si el root es virtual, resuelve `-p <pkg>` del bin.
210 inject_cargo_package_selector(&mut eff_recipe, &src_tree);
211 tracing::info!("cargo: vendoreando deps para build offline");
212 fetch::vendor_cargo_deps(&src_tree)?;
213 }
214
215 // Recetas Go: análogo a Cargo. Si el árbol trae `go.mod`, vendoreamos los módulos (red en el
216 // fetch) para que `go build -mod=vendor` resuelva offline en el sandbox. El toolchain `go` lo
217 // aporta `deps.build = ["go"]` (recipes/go.toml) DENTRO del sandbox; el vendoring usa el `go`
218 // del host (lo provee el lab). No hay BuildSys::Go: las recetas Go fijan el compile en
219 // `[build.phases]` (ver tandas/staged-2026-06-26/amfora.toml como plantilla).
220 // El módulo Go puede estar en un subdir (recipe `build.subdir`, p.ej. dolt→go); vendoreamos ahí.
221 let go_root = if eff_recipe.build.subdir.is_empty() {
222 src_tree.clone()
223 } else {
224 src_tree.join(&eff_recipe.build.subdir)
225 };
226 if go_root.join("go.mod").is_file() || go_root.join("go.work").is_file() {
227 tracing::info!("go: vendoreando módulos para build offline");
228 fetch::vendor_go_deps(&go_root)?;
229 }
230
231 let out_dir = unique_out_dir(&cfg.work_root, &recipe.name, &h)?;
232 let mut env: Vec<(String, String)> = Vec::new();
233 if matches!(recipe.build.link, LinkMode::Static) {
234 // autotools/cmake leen LDFLAGS para el link final; -static fuerza el binario
235 // monolítico que pide nuestra estrategia de hidratación primaria.
236 env.push(("LDFLAGS".into(), "-static".into()));
237 }
238 // El `compiler` de la receta decide el CC/CXX/AR reales; el default del sandbox es `zig cc`.
239 // Esto vuelve DECLARATIVO el escape a un toolchain real (gcc de Alpine = musl + GNU `ld`) para
240 // paquetes que el `lld` estricto de zig NO linkea (símbolos duplicados, flags que zig filtra —
241 // ver Etapa G, lz4). `self.env` pisa los defaults del sandbox. gcc/clang default a x86-64
242 // genérico (reproducible, sin `-march=native`), no hace falta `-mcpu=baseline`.
243 match recipe.build.compiler {
244 Compiler::ZigCc => {}
245 Compiler::Gcc => {
246 env.push(("CC".into(), "gcc".into()));
247 env.push(("CXX".into(), "g++".into()));
248 env.push(("AR".into(), "ar".into()));
249 }
250 Compiler::Clang => {
251 env.push(("CC".into(), "clang".into()));
252 env.push(("CXX".into(), "clang++".into()));
253 env.push(("AR".into(), "llvm-ar".into()));
254 }
255 }
256 // Autotools (Etapa G Fase 3): exporta CBUILD/CHOST con el triple nativo SANEADO del sandbox
257 // (`x86_64-linux-musl`, el mismo que el wrapper zig-cc emite). Las recetas importadas de Alpine
258 // traducen `./configure --build=$CBUILD --host=$CHOST` literal del abuild (`translate_abuild` los
259 // deja intactos a propósito); sin estas envs `$CBUILD/$CHOST` quedan VACÍAS y autotools cae a
260 // `config.guess`, que detecta `x86_64-alpine-linux-musl` — vendor que zig NO conoce
261 // (`UnknownOperatingSystem`). Resuelve el residuo autotools que antes era trabajo a mano. Inerte
262 // para Cargo/CMake/Meson (no leen estas envs).
263 env.push(("CBUILD".into(), SANDBOX_NATIVE_TARGET.to_string()));
264 env.push(("CHOST".into(), SANDBOX_NATIVE_TARGET.to_string()));
265 // *-sys C-FFI (Etapa G): si la receta declara `openssl` como dep, apuntá `openssl-sys` a la
266 // openssl hammer-built materializada en /usr (estática, musl). Sin esto openssl-sys busca en el
267 // host y aborta ("Could not find directory of OpenSSL installation"). Condicional al dep ⇒ no
268 // rompe recetas que usen openssl vendored. Destraba la frontera cargo-* (reqwest/git tools).
269 if recipe.deps.build.iter().any(|d| d == "openssl") {
270 env.push(("OPENSSL_DIR".into(), "/usr".into()));
271 env.push(("OPENSSL_STATIC".into(), "1".into()));
272 env.push(("OPENSSL_NO_VENDOR".into(), "1".into()));
273 }
274 if let Some(cache) = &cfg.cache_root {
275 std::fs::create_dir_all(cache.join("zig"))?;
276 }
277 let sb = Sandbox {
278 rootfs: cfg.rootfs.clone(),
279 zig_dir: zig_dir.clone(),
280 src_dir: src_tree.clone(),
281 out_dir: out_dir.clone(),
282 cache_dir: cfg.cache_root.clone(),
283 deps: dep_dirs,
284 env,
285 };
286
287 let phases = resolve_phases(&eff_recipe, &src_tree)?;
288 if let Some(cmd) = &phases.configure {
289 tracing::info!(cmd, "phase: configure");
290 sb.run(cmd)?;
291 }
292 if let Some(cmd) = &phases.compile {
293 tracing::info!(cmd, "phase: compile");
294 sb.run(cmd)?;
295 } else {
296 return Err(hammer_core::Error::Recipe(
297 "receta sin fase 'compile' y heurística no encontró ninguna".into(),
298 ));
299 }
300 if let Some(cmd) = &phases.install {
301 tracing::info!(cmd, "phase: install");
302 sb.run(cmd)?;
303 } else {
304 return Err(hammer_core::Error::Recipe(
305 "receta sin fase 'install' y heurística no encontró ninguna".into(),
306 ));
307 }
308
309 // Sidecar de provenance: escribimos la receta serializada DENTRO de out_dir antes del
310 // seal, para que quede congelada en el árbol read-only del store. `hammer export` la
311 // lee de vuelta para emitir `source_patch` con la receta original en vez de un
312 // `file_drop` opaco. Ver `docs/04-overlay.md` §provenance (Fase 4).
313 let sidecar_path = out_dir.join(hammer_core::store::RECIPE_SIDECAR_REL);
314 if let Some(parent) = sidecar_path.parent() {
315 std::fs::create_dir_all(parent)?;
316 }
317 let toml_text = recipe.to_toml()?;
318 std::fs::write(&sidecar_path, toml_text.as_bytes())?;
319
320 let sealed = store.seal(&out_dir, &h, &recipe.name)?;
321 tracing::info!(path = %sealed.display(), "sealed");
322 Ok(h)
323}
324
325/// Sistemas de build que reconoce la heurística por inspección del árbol del source.
326/// Ver `docs/02-build-lab.md` §4.
327#[derive(Debug, Clone, Copy, PartialEq, Eq)]
328enum BuildSys {
329 /// `configure.ac` (o `.in`) sin `configure` generado: hay que correr `autoreconf` primero.
330 AutoconfRaw,
331 /// `configure` ya está en el tarball release: vamos directo a `./configure`.
332 AutoconfReady,
333 /// `CMakeLists.txt`.
334 CMake,
335 /// `meson.build`.
336 Meson,
337 /// `Cargo.toml`: crate o workspace Rust. Build con `cargo`, link con `zig cc`.
338 Cargo,
339 /// `go.mod`: módulo Go. Build con `go install` del paquete main (auto-detectado),
340 /// deps vendoreadas en el fetch (`vendor_go_deps`). El toolchain `go` llega por deps.build.
341 Go,
342 /// `Makefile` plano (o `makefile`) sin sistema generador.
343 Make,
344 /// No detectamos nada: la receta debe traer overrides en `[build.phases]`.
345 Unknown,
346}
347
348/// ¿El `Cargo.toml` raíz es un MANIFIESTO VIRTUAL? (declara `[workspace]` pero NO `[package]`).
349/// Es el patrón del ecosistema CLI Rust moderno (sd, fd, …): el root sólo agrupa miembros y el
350/// binario vive en un sub-paquete. En ese caso `cargo … --bin X` solo falla: hace falta `-p <pkg>`.
351/// Un crate suelto (con `[package]`, aunque `ensure_cargo_workspace_isolation` le agregue
352/// `[workspace]`) NO es virtual ⇒ no se toca (ripgrep y el corpus quedan idénticos).
353fn cargo_root_is_virtual(src: &Path) -> bool {
354 let Ok(text) = std::fs::read_to_string(src.join("Cargo.toml")) else {
355 return false;
356 };
357 let mut has_ws = false;
358 let mut has_pkg = false;
359 for l in text.lines() {
360 let t = l.trim_start();
361 if t.starts_with("[workspace]") || t.starts_with("[workspace.") {
362 has_ws = true;
363 }
364 if t.starts_with("[package]") {
365 has_pkg = true;
366 }
367 }
368 has_ws && !has_pkg
369}
370
371/// Lee los MEMBERS del workspace (vía `cargo metadata`, la fuente autoritativa: expande globs,
372/// `[[bin]]`, bins por convención `src/bin/*.rs`, y workspaces anidados — todo lo que un parseo a
373/// mano del TOML erraría) y devuelve el NOMBRE del paquete que expone un bin llamado `bin`. Es el
374/// "extractor de workspaces" de la Etapa G: convierte un workspace virtual en un `-p <pkg>` exacto.
375fn resolve_cargo_bin_package(src: &Path, bin: &str) -> Option<String> {
376 let out = std::process::Command::new("cargo")
377 .args(["metadata", "--no-deps", "--format-version", "1"])
378 .current_dir(src)
379 .output()
380 .ok()?;
381 if !out.status.success() {
382 return None;
383 }
384 let meta: serde_json::Value = serde_json::from_slice(&out.stdout).ok()?;
385 for pkg in meta.get("packages")?.as_array()? {
386 let pkg_name = pkg.get("name")?.as_str()?;
387 for tgt in pkg.get("targets")?.as_array()? {
388 let is_bin = tgt
389 .get("kind")
390 .and_then(|k| k.as_array())
391 .map(|ks| ks.iter().any(|x| x.as_str() == Some("bin")))
392 .unwrap_or(false);
393 if is_bin && tgt.get("name").and_then(|n| n.as_str()) == Some(bin) {
394 return Some(pkg_name.to_string());
395 }
396 }
397 }
398 None
399}
400
401/// Si la fuente es un workspace VIRTUAL y los flags piden `--bin X` sin `-p`, resuelve el paquete
402/// real que expone `X` y antepone `-p <pkg>`. Deja los flags intactos en cualquier otro caso
403/// (crate suelto, ya con `-p`, o bin irresoluble). Determinista (misma fuente → mismo paquete) ⇒
404/// reproducible. Sin esto, casi todo el CLI Rust moderno (workspaces virtuales) exigía `-p` a mano.
405fn inject_cargo_package_selector(recipe: &mut Recipe, src: &Path) {
406 let flags = &recipe.build.flags;
407 if flags.iter().any(|f| f == "-p" || f == "--package") {
408 return; // el autor ya eligió el paquete
409 }
410 if !cargo_root_is_virtual(src) {
411 return; // crate suelto: `--bin` solo alcanza
412 }
413 // bin = el argumento que sigue a `--bin`.
414 let Some(bin) = flags.iter().position(|f| f == "--bin").and_then(|i| flags.get(i + 1)).cloned()
415 else {
416 return;
417 };
418 if let Some(pkg) = resolve_cargo_bin_package(src, &bin) {
419 tracing::info!(%bin, %pkg, "cargo: workspace virtual ⇒ -p resuelto automáticamente");
420 let mut new_flags = vec!["-p".to_string(), pkg];
421 new_flags.extend(recipe.build.flags.iter().cloned());
422 recipe.build.flags = new_flags;
423 }
424}
425
426/// Garantiza que el `Cargo.toml` raíz de la fuente declare un `[workspace]` propio, para que cargo
427/// NO la considere parte del workspace del repo hammer (que la contiene en `work/sources/`). Append
428/// idempotente: si ya hay `[workspace]` (crate suelto que es su propio workspace, o una receta que
429/// lo parchea), no toca nada. Caso virtual-manifest (root ya es `[workspace]`) tampoco se toca.
430fn ensure_cargo_workspace_isolation(src: &Path) -> hammer_core::Result<()> {
431 let manifest = src.join("Cargo.toml");
432 let text = std::fs::read_to_string(&manifest)?;
433 // Heurística simple: ¿hay una tabla `[workspace]` (al inicio de línea)? Cubre `[workspace]` y
434 // `[workspace.members]`/`[workspace.dependencies]` (un root virtual siempre trae alguna).
435 let has_workspace = text
436 .lines()
437 .any(|l| l.trim_start().starts_with("[workspace]") || l.trim_start().starts_with("[workspace."));
438 if !has_workspace {
439 let mut patched = text;
440 if !patched.ends_with('\n') {
441 patched.push('\n');
442 }
443 patched.push_str("\n[workspace]\n");
444 std::fs::write(&manifest, patched)?;
445 tracing::info!("cargo: inyectado [workspace] vacío para aislar la fuente del repo hammer");
446 }
447 Ok(())
448}
449
450fn detect_build_system(src: &Path) -> BuildSys {
451 let has = |p: &str| src.join(p).exists();
452 // `go.mod`/`go.work` PRIMERO: un módulo Go suele traer también un `configure`/`Makefile` AUXILIAR
453 // (man pages, completions, packaging), pero el binario se hace con `go build`. Ningún paquete
454 // C/Rust del corpus trae go.mod. `go.work` cubre los WORKSPACE multimódulo cuya RAÍZ no tiene
455 // go.mod (sólo go.work + submódulos) — sin esto caían a Makefile/Unknown.
456 if has("go.mod") || has("go.work") {
457 return BuildSys::Go;
458 }
459 // El orden importa: cuando coexisten varios (un tarball release de autotools trae el
460 // `configure` regenerado y un `Makefile` huérfano de un build previo), preferimos el
461 // sistema "más alto" que sí sabe regenerar todo.
462 if has("configure.ac") || has("configure.in") {
463 if has("configure") {
464 return BuildSys::AutoconfReady;
465 }
466 return BuildSys::AutoconfRaw;
467 }
468 if has("configure") {
469 return BuildSys::AutoconfReady;
470 }
471 if has("CMakeLists.txt") {
472 return BuildSys::CMake;
473 }
474 if has("meson.build") {
475 return BuildSys::Meson;
476 }
477 // Cargo gana sobre un `Makefile` huérfano (algunos crates traen un wrapper `make`),
478 // pero cede ante autotools/cmake/meson (un políglota C+Rust suele querer el sistema C).
479 if has("Cargo.toml") {
480 return BuildSys::Cargo;
481 }
482 if has("Makefile") || has("makefile") || has("GNUmakefile") {
483 return BuildSys::Make;
484 }
485 BuildSys::Unknown
486}
487
488/// Detecta el directorio del paquete `main` PRINCIPAL de un módulo Go, relativo a `src`, para
489/// `go install`. Busca por FILESYSTEM (no compila ⇒ no se contamina con mains de ejemplo rotos,
490/// como los de `docs/`/`scripts/` que rompen `go list ./...`): archivos `.go` cuya primera línea
491/// significativa es `package main`, descartando dirs auxiliares (vendor/docs/examples/tests/
492/// scripts/tools/internal y ocultos/`_`). Elige el principal por heurística: raíz > `cmd/<x>` >
493/// menor profundidad. Devuelve un path estilo go (`.`, `./cmd/mlr`). Una receta puede forzar el
494/// paquete vía `build.flags` (gana sobre la auto-detección).
495fn detect_go_main(src: &Path, pkg_name: &str) -> String {
496 fn is_main_go(path: &Path) -> bool {
497 let Ok(text) = std::fs::read_to_string(path) else { return false };
498 // El primer token significativo de un .go es la cláusula `package` (tras build-constraints
499 // `//go:build`, comentarios de línea y el header de licencia). Hay que saltar comentarios de
500 // BLOQUE `/* ... */` correctamente: muchos headers Apache son `/*\nCopyright ...\n*/` y las
501 // líneas internas NO arrancan con `*` ⇒ un filtro por-línea ingenuo cortaría ahí y fallaría
502 // (p.ej. cmd/helm/helm.go). Escaneamos rastreando el estado dentro/fuera de bloque.
503 let mut in_block = false;
504 for line in text.lines() {
505 let mut l = line.trim();
506 if in_block {
507 match l.find("*/") {
508 Some(i) => {
509 l = l[i + 2..].trim();
510 in_block = false;
511 }
512 None => continue,
513 }
514 }
515 if l.is_empty() || l.starts_with("//") {
516 continue;
517 }
518 if let Some(i) = l.find("/*") {
519 let before = l[..i].trim();
520 if !before.is_empty() {
521 return before.starts_with("package main");
522 }
523 match l[i + 2..].find("*/") {
524 Some(j) => {
525 let after = l[i + 2 + j + 2..].trim();
526 if after.is_empty() {
527 continue;
528 }
529 return after.starts_with("package main");
530 }
531 None => {
532 in_block = true;
533 continue;
534 }
535 }
536 }
537 return l.starts_with("package main");
538 }
539 false
540 }
541 const SKIP: &[&str] = &[
542 "vendor", "docs", "doc", "example", "examples", "test", "tests", "testdata", "script",
543 "scripts", "tools", "internal", "third_party",
544 ];
545 let mut found: Vec<PathBuf> = Vec::new();
546 let mut stack = vec![src.to_path_buf()];
547 while let Some(dir) = stack.pop() {
548 let Ok(entries) = std::fs::read_dir(&dir) else { continue };
549 let mut has_main = false;
550 for e in entries.flatten() {
551 let p = e.path();
552 if p.is_dir() {
553 let name = e.file_name().to_string_lossy().to_string();
554 if name.starts_with('.') || name.starts_with('_') || SKIP.contains(&name.as_str()) {
555 continue;
556 }
557 stack.push(p);
558 } else if !has_main
559 && p.extension().map(|x| x == "go").unwrap_or(false)
560 && !p.to_string_lossy().ends_with("_test.go")
561 && is_main_go(&p)
562 {
563 has_main = true;
564 }
565 }
566 if has_main {
567 if let Ok(rel) = dir.strip_prefix(src) {
568 found.push(rel.to_path_buf());
569 }
570 }
571 }
572 // raíz (prioridad 0) > cmd/<x> (1) > resto (2); a igual clase, el dir cuyo nombre coincide con el
573 // de la receta (p.ej. cmd/eksctl para la receta `eksctl`) gana sobre otros mains hermanos (cmd/
574 // schema, un helper de codegen que panica) — sin esto detect elegía arbitrariamente entre varios
575 // mains y sellaba el binario EQUIVOCADO; luego, a igual nombre, menor profundidad.
576 let key = |p: &PathBuf| -> (u8, u8, usize) {
577 let s = p.to_string_lossy();
578 let depth = p.components().count();
579 let name_match = if p.file_name().map(|f| f == pkg_name).unwrap_or(false) { 0 } else { 1 };
580 let class = if s.is_empty() {
581 0
582 } else if s.starts_with("cmd/") || s == "cmd" {
583 1
584 } else {
585 2
586 };
587 (class, name_match, depth)
588 };
589 found.sort_by_key(key);
590 match found.first() {
591 Some(p) if !p.as_os_str().is_empty() => format!("./{}", p.to_string_lossy()),
592 _ => ".".to_string(),
593 }
594}
595
596/// Target nativo del sandbox del lab (Alpine x86_64 musl). Una receta Cargo cuyo `build.target`
597/// sea éste se construye **nativa** (sin `--target`): el rust del sandbox sólo trae su std nativo
598/// (`x86_64-alpine-linux-musl`), no el `x86_64-unknown-linux-musl` que pediría un cross, y no hay
599/// rustup para agregarlo. Ver `docs/runbooks/stage1-vm-boot.md` §8 (primera corrida).
600const SANDBOX_NATIVE_TARGET: &str = "x86_64-linux-musl";
601
602/// Traduce el triple estilo zig/hammer (`x86_64-linux-musl`) al de rustc
603/// (`x86_64-unknown-linux-musl`). Un triple ya completo (con vendor) o ajeno al patrón
604/// `<arch>-linux-<libc>` se devuelve tal cual: la receta puede dar el triple exacto.
605fn rustc_triple(target: &str) -> String {
606 match target.split('-').collect::<Vec<_>>().as_slice() {
607 [arch, "linux", libc] => format!("{arch}-unknown-linux-{libc}"),
608 _ => target.to_string(),
609 }
610}
611
612/// Nombre de la env `CARGO_TARGET_<TRIPLE>_LINKER` para un triple de rustc.
613fn cargo_linker_env_var(triple: &str) -> String {
614 format!("CARGO_TARGET_{}_LINKER", triple.to_uppercase().replace(['-', '.'], "_"))
615}
616
617/// Devuelve los comandos a ejecutar para cada fase: overrides explícitos en la receta
618/// ganan; el resto lo deriva la heurística según el sistema de build detectado.
619fn resolve_phases(recipe: &Recipe, src: &Path) -> hammer_core::Result<Phases> {
620 let mut out = recipe.build.phases.clone();
621 // Raíz efectiva del módulo Go: `<src>/<subdir>` si la receta lo fija (dolt→go, csvtk→subdir),
622 // si no `src`. detect/detect_go_main operan ahí; el compile hace `cd <subdir>` primero.
623 let go_src = if recipe.build.subdir.is_empty() {
624 src.to_path_buf()
625 } else {
626 src.join(&recipe.build.subdir)
627 };
628 let bs = detect_build_system(&go_src);
629 let flags = recipe.build.flags.join(" ");
630 let flags_suffix = if flags.is_empty() { String::new() } else { format!(" {flags}") };
631
632 // Autotools (Etapa G Fase 3): fija `--build`/`--host` al triple nativo saneado para que
633 // `config.guess` no detecte el vendor `alpine` que zig rechaza, e iguala lo que el wrapper zig-cc
634 // produce. Sólo si la receta no los puso ya en sus flags (juicio per-paquete gana). build==host
635 // ⇒ NATIVO (no cross): autotools sigue corriendo sus AC_RUN tests; sólo normaliza el triple.
636 let autotools_triple = if recipe.build.flags.iter().any(|f| {
637 f.starts_with("--build=") || f.starts_with("--host=")
638 }) {
639 String::new()
640 } else {
641 format!(" --build={SANDBOX_NATIVE_TARGET} --host={SANDBOX_NATIVE_TARGET}")
642 };
643
644 if out.configure.is_none() {
645 out.configure = match bs {
646 BuildSys::AutoconfRaw => Some(format!(
647 "autoreconf -fi && ./configure --prefix=/usr{autotools_triple}{flags_suffix}"
648 )),
649 BuildSys::AutoconfReady => {
650 Some(format!("./configure --prefix=/usr{autotools_triple}{flags_suffix}"))
651 }
652 BuildSys::CMake => Some(format!(
653 "cmake -S . -B _build -DCMAKE_INSTALL_PREFIX=/usr -DCMAKE_BUILD_TYPE=Release{flags_suffix}"
654 )),
655 BuildSys::Meson => Some(format!("meson setup _build --prefix=/usr{flags_suffix}")),
656 // Cargo/Go no tienen fase configure separada.
657 BuildSys::Cargo | BuildSys::Go | BuildSys::Make | BuildSys::Unknown => None,
658 };
659 }
660 if out.compile.is_none() {
661 out.compile = match bs {
662 BuildSys::AutoconfRaw | BuildSys::AutoconfReady | BuildSys::Make => {
663 Some(r#"make -j"$(nproc)""#.to_string())
664 }
665 BuildSys::CMake => Some(r#"cmake --build _build -j "$(nproc)""#.to_string()),
666 BuildSys::Meson => Some("meson compile -C _build".to_string()),
667 BuildSys::Cargo => {
668 // Hermético y reproducible: `--offline` (deps vendoreadas en la fuente) +
669 // `--locked` (Cargo.lock fijo, coherente con ADR 0006). El link va por un
670 // wrapper `zig cc` — cargo no acepta un linker de dos palabras, y zig provee
671 // el `libgcc_s` que el link dinámico musl pide (ver M2 del plan arje↔hammer).
672 if recipe.build.target == SANDBOX_NATIVE_TARGET {
673 // NATIVO: el sandbox del lab YA es x86_64 musl, así que no cruzamos. El rust del
674 // sandbox sólo trae su std nativo (`x86_64-alpine-linux-musl`), no
675 // `x86_64-unknown-linux-musl`; con `--target` fallaría "can't find crate for
676 // core" (no hay rustup). Ver docs/runbooks/stage1-vm-boot.md §8.
677 //
678 // El wrapper `.hammer-zig-cc` hace DOS cosas: (1) es un único ejecutable, lo que
679 // resuelve el `zig cc` de dos palabras que cargo (linker) y cc-rs (CC)
680 // mal-parsean; (2) **sanea el triple**: cc-rs (build scripts que compilan C/asm,
681 // p.ej. blake3) detecta el host triple de Alpine rust y pasa
682 // `--target=x86_64-alpine-linux-musl`, pero zig no conoce el vendor "alpine"
683 // (`UnknownOperatingSystem`); lo reescribimos a `x86_64-linux-musl`. Se usa como
684 // CC (cc-rs) y como linker (RUSTFLAGS, target-agnóstico — el default sería
685 // cc/gcc, ausente en el sandbox zig-only).
686 // Flags de codegen del BINARIO FINAL, vía `cargo rustc -- …` (NO RUSTFLAGS): en
687 // build nativo RUSTFLAGS también alcanza a los proc-macros (deps), y
688 // `+crt-static`/`relocation-model=static` los rompe ("cannot produce
689 // proc-macro" — un proc-macro es un dylib PIC). `cargo rustc -- …` los aplica
690 // sólo a la crate top-level, dejando los proc-macros intactos.
691 let bin_flags = match recipe.build.link {
692 // Estático AUTOCONTENIDO y no-PIE: `+crt-static` mete musl dentro (sin
693 // DT_NEEDED — el rust de Alpine enlaza musl dinámico por defecto, lo que deja
694 // `libc.musl-x86_64.so.1` y rompe el boot, runbook §8); `relocation-model=static`
695 // ⇒ ET_EXEC sin interpreter (vía de oro: como busybox, sin libc.so/loader/soname).
696 LinkMode::Static => "-C target-feature=+crt-static -C relocation-model=static",
697 // Dinámico: apaga crt-static para poder dlopen (necesita el loader/musl en el rootfs).
698 LinkMode::Dynamic => "-C target-feature=-crt-static",
699 };
700 // `-C link-self-contained=no` CONDICIONAL: el link (de TODO — deps,
701 // build-scripts, proc-macros y la crate top) lo hace `zig cc`, que ya aporta
702 // sus startfiles (crt1.o). Un rust VANILLA con target musl
703 // `x86_64-unknown-linux-musl` trae CRT AUTOCONTENIDO (self-contained/rcrt1.o) y
704 // lo pasaría también ⇒ `ld.lld: duplicate symbol: _start`; hay que apagarlo. PERO
705 // el rust de Alpine (host `x86_64-alpine-linux-musl`) PARCHEA la opción para que
706 // sea un ERROR ("option `-C link-self-contained` is not supported on this
707 // target") y NO usa self-contained — el baseline 9adefb82 linkea con zig sin
708 // chocar. Así que: añadir el flag SÓLO si el host del rustc NO es `-alpine-`
709 // (i.e. un rust vanilla como el hammer-rust del frente self-host); para Alpine,
710 // omitirlo. RUSTFLAGS alcanza a todas las unidades; los `bin_flags` (cargo rustc
711 // --) sólo a la top.
712 // CC para los build-scripts (cc-rs compila el C de deps como libgit2-sys,
713 // openssl-sys, libc-stdhandle). Por defecto = el wrapper zig-cc. PERO zig-cc
714 // MISCOMPILA/RECHAZA muchos de esos C (mismo frente que file/jq en C puro: la
715 // granja lo reveló — delta/gitui/jless mueren ahí). Con `compiler="gcc"` la receta
716 // declara el GUETO: cc-rs usa gcc (el Alpine musl gcc del sandbox, probado en C),
717 // y el linker sigue siendo zig-cc — los .o/.a de gcc-musl y el Rust zig-musl son
718 // ABI-compatibles (ambos musl, mismo ELF x86-64). Aditivo: una receta Rust sin
719 // `compiler=` queda en ZigCc ⇒ baseline (ripgrep/fd/…) intacto.
720 let cc_for_scripts = match recipe.build.compiler {
721 Compiler::Gcc => "gcc",
722 Compiler::Clang => "clang",
723 Compiler::ZigCc => "$PWD/.hammer-zig-cc",
724 };
725 Some(format!(
726 "printf '%s\\n' '#!/bin/sh' 'for a do' \
727 'case \"$a\" in --target=*) a=--target=x86_64-linux-musl ;; esac' \
728 'set -- \"$@\" \"$a\"' 'shift' 'done' 'exec zig cc -mcpu=baseline \"$@\"' \
729 > \"$PWD/.hammer-zig-cc\" && chmod +x \"$PWD/.hammer-zig-cc\" && \
730 RF=\"-C linker=$PWD/.hammer-zig-cc\"; \
731 rustc -vV | grep -q 'host: .*-alpine-' || RF=\"$RF -C link-self-contained=no\"; \
732 CC=\"{cc_for_scripts}\" RUSTFLAGS=\"$RF\" \
733 cargo rustc --release --locked --offline{flags_suffix} -- {bin_flags}"
734 ))
735 } else {
736 // CROSS real (otro target que zig sí entiende): `--target` + linker por env
737 // per-triple. Hermético y reproducible: `--offline` (deps vendoreadas) +
738 // `--locked` (Cargo.lock fijo, ADR 0006). zig provee el `libgcc_s` que el link
739 // dinámico musl pide (ver M2 del plan arje↔hammer).
740 let setup = "printf '#!/bin/sh\\nexec zig cc -mcpu=baseline \"$@\"\\n' > \"$PWD/.hammer-zig-cc\" && \
741 chmod +x \"$PWD/.hammer-zig-cc\" && ";
742 let triple = rustc_triple(&recipe.build.target);
743 let linker_var = cargo_linker_env_var(&triple);
744 let rustflags = match recipe.build.link {
745 // musl es crt-static por defecto; el dinámico lo apaga para poder dlopen.
746 LinkMode::Dynamic => "RUSTFLAGS='-C target-feature=-crt-static' ",
747 LinkMode::Static => "",
748 };
749 Some(format!(
750 "{setup}{rustflags}{linker_var}=\"$PWD/.hammer-zig-cc\" \
751 cargo build --release --locked --offline --target {triple}{flags_suffix}"
752 ))
753 }
754 }
755 BuildSys::Go => {
756 // Un solo patrón GENÉRICO para todo proyecto Go: `go install` del paquete main
757 // auto-detectado (o el que fije `build.flags`). go install nombra el binario solo
758 // (cmd/mlr→mlr, raíz→<módulo>) y lo deja en GOBIN=/out/usr/bin ⇒ no hace falta saber
759 // el nombre. Offline+reproducible: vendor (vendor_go_deps) + GOPROXY=off + -trimpath
760 // -buildid=. El toolchain `go` llega por deps.build=["go"] (overlay).
761 let pkg = if recipe.build.flags.is_empty() {
762 detect_go_main(&go_src, &recipe.name)
763 } else {
764 flags.clone()
765 };
766 // Si el módulo está en subdir, el compile entra ahí primero (el sandbox arranca en /src).
767 let cd_prefix = if recipe.build.subdir.is_empty() {
768 String::new()
769 } else {
770 format!("cd {} && ", recipe.build.subdir)
771 };
772 // GOCACHE/GOTMPDIR en /src (disk-backed, per-build, lo limpia el watchdog), NO en
773 // /tmp: el sandbox monta `--tmpfs /tmp` (~½ RAM); un proyecto Go ENORME (telegraf,
774 // vault) desborda ese tmpfs con sus objetos compilados + el temp del linker al mapear
775 // el binario → "link: mapping output file failed: no space left on device" aunque el
776 // disco tenga sitio. /src es el árbol de fuentes bind-montado (en disco) y no se sella
777 // (sólo /out) ⇒ inocuo. GOTMPDIR debe pre-existir. No entra al hash de input (es la
778 // fase generada, no un campo de la receta) ⇒ no re-sella el corpus Go.
779 // CGO opt-in (recipe `cgo = true`): drivers con C bundled (sqlite3 de mattn en
780 // usql/sq/dolt) necesitan cgo. CC="zig cc" (musl nativo del sandbox) + link estático
781 // por el linker externo (`-linkmode=external -extldflags=-static`). Default: CGO off
782 // (Go puro estático). Validado: usql (sqlite, estático y corre).
783 let (cgo_env, ldflags) = if recipe.build.cgo {
784 (
785 "CGO_ENABLED=1 CC=\"zig cc\" CXX=\"zig c++\"",
786 "-ldflags=\"-buildid= -linkmode=external -extldflags=-static\"",
787 )
788 } else {
789 ("CGO_ENABLED=0", "-ldflags=-buildid=")
790 };
791 Some(format!(
792 "{cd_prefix}export GOCACHE=/src/.gocache GOTMPDIR=/src/.gotmp GOPATH=/tmp/gopath \
793 GOTOOLCHAIN=local {cgo_env} GOFLAGS=-mod=vendor GOPROXY=off \
794 GOBIN=/out/usr/bin; mkdir -p /src/.gotmp; \
795 go install -trimpath {ldflags} {pkg}"
796 ))
797 }
798 BuildSys::Unknown => None,
799 };
800 }
801 if out.install.is_none() {
802 out.install = match bs {
803 BuildSys::AutoconfRaw | BuildSys::AutoconfReady | BuildSys::Make => {
804 Some("make install DESTDIR=/out PREFIX=/usr".to_string())
805 }
806 BuildSys::CMake => Some("DESTDIR=/out cmake --install _build".to_string()),
807 BuildSys::Meson => Some("DESTDIR=/out meson install -C _build --no-rebuild".to_string()),
808 BuildSys::Cargo => {
809 // Copia los ejecutables finales (top del dir release, bit de ejecución) a
810 // /out/usr/bin. `-perm -100` / `-exec cp \;` son busybox-safe (rootfs Alpine).
811 // Build nativo ⇒ `target/release`; cross ⇒ `target/<triple>/release`.
812 let rel = if recipe.build.target == SANDBOX_NATIVE_TARGET {
813 "target/release".to_string()
814 } else {
815 format!("target/{}/release", rustc_triple(&recipe.build.target))
816 };
817 Some(format!(
818 "mkdir -p /out/usr/bin && \
819 find {rel} -maxdepth 1 -type f -perm -100 \
820 -exec cp {{}} /out/usr/bin/ \\;"
821 ))
822 }
823 // `go install` ya dejó los binarios en GOBIN=/out/usr/bin: nada que hacer.
824 BuildSys::Go => Some("true".to_string()),
825 BuildSys::Unknown => None,
826 };
827 }
828 Ok(out)
829}
830
831#[cfg(test)]
832mod heuristic_tests {
833 use super::*;
834
835 fn touch(dir: &Path, name: &str) {
836 std::fs::write(dir.join(name), b"").unwrap();
837 }
838
839 fn recipe(flags: &[&str]) -> Recipe {
840 let mut r = Recipe::from_toml(
841 r#"
842name = "x"
843version = "0"
844[source]
845repo = "git://x"
846commit = "deadbeef"
847[build]
848"#,
849 )
850 .unwrap();
851 r.build.flags = flags.iter().map(|s| (*s).to_string()).collect();
852 r
853 }
854
855 #[test]
856 fn go_subdir_compiles_in_subdir() {
857 // dolt-like: go.mod en subdir `go/`. Con build.subdir="go" el lab detecta Go ahí y el compile
858 // entra al subdir primero.
859 let d = tempfile::tempdir().unwrap();
860 std::fs::create_dir_all(d.path().join("go/cmd/dolt")).unwrap();
861 touch(d.path(), "go/go.mod");
862 std::fs::write(d.path().join("go/cmd/dolt/main.go"), b"package main\nfunc main(){}\n").unwrap();
863 let mut r = recipe(&[]);
864 r.name = "dolt".into();
865 r.build.subdir = "go".into();
866 let p = resolve_phases(&r, d.path()).unwrap();
867 let c = p.compile.unwrap();
868 assert!(c.starts_with("cd go && "), "entra al subdir: {c}");
869 assert!(c.contains("go install -trimpath") && c.trim_end().ends_with("./cmd/dolt"), "{c}");
870 }
871
872 #[test]
873 fn go_mod_wins_over_configure_and_derives_go_install() {
874 // miller-like: go.mod + configure auxiliar ⇒ Go gana, y el compile es `go install`.
875 let d = tempfile::tempdir().unwrap();
876 touch(d.path(), "go.mod");
877 touch(d.path(), "configure");
878 std::fs::write(d.path().join("main.go"), b"package main\nfunc main(){}\n").unwrap();
879 assert_eq!(detect_build_system(d.path()), BuildSys::Go);
880 let p = resolve_phases(&recipe(&[]), d.path()).unwrap();
881 assert!(p.configure.is_none());
882 let c = p.compile.unwrap();
883 assert!(c.contains("go install -trimpath"), "{c}");
884 assert!(c.ends_with(" ."), "raíz ⇒ paquete `.`: {c}");
885 assert_eq!(p.install.as_deref(), Some("true"));
886 }
887
888 #[test]
889 fn go_cgo_knob_switches_compile_env() {
890 // cgo=false (default) ⇒ CGO_ENABLED=0, Go puro; cgo=true ⇒ CGO_ENABLED=1 + CC zig + estático.
891 let d = tempfile::tempdir().unwrap();
892 touch(d.path(), "go.mod");
893 std::fs::write(d.path().join("main.go"), b"package main\nfunc main(){}\n").unwrap();
894 let off = resolve_phases(&recipe(&[]), d.path()).unwrap().compile.unwrap();
895 assert!(off.contains("CGO_ENABLED=0"), "{off}");
896 let mut r = recipe(&[]);
897 r.build.cgo = true;
898 let on = resolve_phases(&r, d.path()).unwrap().compile.unwrap();
899 assert!(on.contains("CGO_ENABLED=1") && on.contains("zig cc") && on.contains("-extldflags=-static"), "{on}");
900 }
901
902 #[test]
903 fn detect_go_when_only_go_work() {
904 // workspace multimódulo sin go.mod en la raíz (sólo go.work) ⇒ sigue siendo Go (antes caía a
905 // Makefile/Unknown). El vendoring usará `go work vendor`.
906 let d = tempfile::tempdir().unwrap();
907 touch(d.path(), "go.work");
908 touch(d.path(), "Makefile"); // un Makefile auxiliar no debe ganarle a Go
909 assert_eq!(detect_build_system(d.path()), BuildSys::Go);
910 }
911
912 #[test]
913 fn detect_go_main_picks_cmd_over_docs() {
914 // main real en cmd/mlr + main de ejemplo roto en docs/ ⇒ elige cmd/mlr, ignora docs.
915 let d = tempfile::tempdir().unwrap();
916 for sub in ["cmd/mlr", "docs/example"] {
917 std::fs::create_dir_all(d.path().join(sub)).unwrap();
918 std::fs::write(d.path().join(sub).join("main.go"), b"package main\nfunc main(){}\n").unwrap();
919 }
920 assert_eq!(detect_go_main(d.path(), "mlr"), "./cmd/mlr");
921 }
922
923 #[test]
924 fn detect_go_main_prefers_cmd_matching_recipe_name() {
925 // eksctl-like: cmd/eksctl (CLI real) + cmd/schema (helper que panica). El dir que coincide
926 // con el name de la receta gana sobre el hermano ⇒ no sella el binario equivocado.
927 let d = tempfile::tempdir().unwrap();
928 for sub in ["cmd/schema", "cmd/eksctl"] {
929 std::fs::create_dir_all(d.path().join(sub)).unwrap();
930 std::fs::write(d.path().join(sub).join("main.go"), b"package main\nfunc main(){}\n").unwrap();
931 }
932 assert_eq!(detect_go_main(d.path(), "eksctl"), "./cmd/eksctl");
933 }
934
935 #[test]
936 fn detect_go_main_sees_main_under_block_comment_header() {
937 // header de licencia `/* ... */` con líneas internas SIN `*` (estilo Apache de helm):
938 // el main en cmd/helm debe detectarse igual (regresión del corte ingenuo por-línea).
939 let d = tempfile::tempdir().unwrap();
940 std::fs::create_dir_all(d.path().join("cmd/helm")).unwrap();
941 std::fs::write(
942 d.path().join("cmd/helm/helm.go"),
943 b"/*\nCopyright The Helm Authors.\nLicensed under the Apache License 2.0\n*/\n\npackage main\n\nfunc main() {}\n",
944 )
945 .unwrap();
946 assert_eq!(detect_go_main(d.path(), "helm"), "./cmd/helm");
947 }
948
949 #[test]
950 fn detect_autoconf_raw_when_only_configure_ac() {
951 let d = tempfile::tempdir().unwrap();
952 touch(d.path(), "configure.ac");
953 assert_eq!(detect_build_system(d.path()), BuildSys::AutoconfRaw);
954 let p = resolve_phases(&recipe(&[]), d.path()).unwrap();
955 assert!(p.configure.as_deref().unwrap().starts_with("autoreconf -fi &&"));
956 assert!(p.install.as_deref().unwrap().contains("DESTDIR=/out"));
957 }
958
959 #[test]
960 fn detect_autoconf_ready_when_configure_exists() {
961 let d = tempfile::tempdir().unwrap();
962 touch(d.path(), "configure.ac");
963 touch(d.path(), "configure");
964 assert_eq!(detect_build_system(d.path()), BuildSys::AutoconfReady);
965 let p = resolve_phases(&recipe(&["--enable-foo"]), d.path()).unwrap();
966 let c = p.configure.unwrap();
967 assert!(c.starts_with("./configure --prefix=/usr"), "{c}");
968 assert!(c.ends_with("--enable-foo"), "{c}");
969 }
970
971 #[test]
972 fn autotools_injects_native_triple() {
973 // Etapa G Fase 3: configure heurístico fija --build/--host al triple saneado.
974 let d = tempfile::tempdir().unwrap();
975 touch(d.path(), "configure.ac");
976 touch(d.path(), "configure");
977 let c = resolve_phases(&recipe(&[]), d.path()).unwrap().configure.unwrap();
978 assert!(c.contains("--build=x86_64-linux-musl"), "{c}");
979 assert!(c.contains("--host=x86_64-linux-musl"), "{c}");
980 }
981
982 #[test]
983 fn autotools_triple_yields_to_recipe_flags() {
984 // Si la receta ya fija el triple (juicio per-paquete), no duplicamos.
985 let d = tempfile::tempdir().unwrap();
986 touch(d.path(), "configure.ac");
987 touch(d.path(), "configure");
988 let c = resolve_phases(&recipe(&["--host=aarch64-linux-musl"]), d.path())
989 .unwrap()
990 .configure
991 .unwrap();
992 assert!(c.contains("--host=aarch64-linux-musl"), "{c}");
993 assert!(!c.contains("x86_64-linux-musl"), "no inyecta si la receta ya lo puso ({c})");
994 }
995
996 #[test]
997 fn cmake_ignores_autotools_triple() {
998 // El triple es exclusivo de autotools; CMake/Meson/Cargo no lo reciben.
999 let d = tempfile::tempdir().unwrap();
1000 touch(d.path(), "CMakeLists.txt");
1001 let c = resolve_phases(&recipe(&[]), d.path()).unwrap().configure.unwrap();
1002 assert!(!c.contains("--host="), "{c}");
1003 }
1004
1005 #[test]
1006 fn detect_cmake() {
1007 let d = tempfile::tempdir().unwrap();
1008 touch(d.path(), "CMakeLists.txt");
1009 assert_eq!(detect_build_system(d.path()), BuildSys::CMake);
1010 let p = resolve_phases(&recipe(&[]), d.path()).unwrap();
1011 assert!(p.configure.unwrap().contains("-DCMAKE_INSTALL_PREFIX=/usr"));
1012 assert!(p.compile.unwrap().contains("cmake --build _build"));
1013 assert!(p.install.unwrap().contains("DESTDIR=/out"));
1014 }
1015
1016 #[test]
1017 fn detect_meson() {
1018 let d = tempfile::tempdir().unwrap();
1019 touch(d.path(), "meson.build");
1020 assert_eq!(detect_build_system(d.path()), BuildSys::Meson);
1021 let p = resolve_phases(&recipe(&[]), d.path()).unwrap();
1022 assert!(p.configure.unwrap().contains("meson setup _build"));
1023 assert!(p.install.unwrap().contains("meson install"));
1024 }
1025
1026 #[test]
1027 fn detect_cargo() {
1028 let d = tempfile::tempdir().unwrap();
1029 touch(d.path(), "Cargo.toml");
1030 assert_eq!(detect_build_system(d.path()), BuildSys::Cargo);
1031 // Target default (x86_64-linux-musl) = nativo del sandbox ⇒ build SIN --target.
1032 let p = resolve_phases(&recipe(&[]), d.path()).unwrap();
1033 assert!(p.configure.is_none(), "cargo no usa configure");
1034 let c = p.compile.unwrap();
1035 // Nativo: `cargo rustc` (no `cargo build`) para pasar los flags de codegen tras `--` sólo
1036 // a la crate final; sin `--target` (lo cross sí; el único --target es el del wrapper saneador).
1037 assert!(c.contains("cargo rustc --release"), "{c}");
1038 assert!(c.contains("--locked") && c.contains("--offline"), "{c}");
1039 assert!(!c.contains("--offline --target"), "nativo: cargo sin --target ({c})");
1040 assert!(c.contains("a=--target=x86_64-linux-musl"), "wrapper sanea el triple ({c})");
1041 assert!(c.contains("-- -C target-feature=+crt-static"), "crt-static scoped tras -- ({c})");
1042 assert!(c.contains("relocation-model=static"), "no-PIE: ET_EXEC sin interpreter ({c})");
1043 assert!(c.contains("CC=\"$PWD/.hammer-zig-cc\""), "CC = wrapper para cc-rs ({c})");
1044 // El linker se arma en `RF` (que además gana `-C link-self-contained=no` cuando el host no es
1045 // Alpine) y se aplica vía `RUSTFLAGS="$RF"`.
1046 assert!(c.contains("RF=\"-C linker=$PWD/.hammer-zig-cc"), "linker en RF ({c})");
1047 assert!(c.contains("RUSTFLAGS=\"$RF\""), "RF aplicado por RUSTFLAGS ({c})");
1048 let i = p.install.unwrap();
1049 assert!(i.contains("/out/usr/bin"), "{i}");
1050 assert!(i.contains("find target/release"), "nativo instala de target/release ({i})");
1051 }
1052
1053 #[test]
1054 fn cargo_gcc_ghetto_uses_gcc_for_build_scripts() {
1055 // Receta Rust con `compiler="gcc"`: el GUETO para crates con dep-C (libgit2-sys/openssl-sys/
1056 // libc-stdhandle) que zig-cc rechaza. cc-rs debe usar gcc (CC=gcc), pero el LINKER sigue
1057 // siendo el wrapper zig-cc (objetos gcc-musl + Rust zig-musl son ABI-compatibles).
1058 let d = tempfile::tempdir().unwrap();
1059 touch(d.path(), "Cargo.toml");
1060 let mut r = recipe(&[]);
1061 r.build.compiler = Compiler::Gcc;
1062 let c = resolve_phases(&r, d.path()).unwrap().compile.unwrap();
1063 assert!(c.contains("CC=\"gcc\""), "cc-rs usa gcc en el gueto ({c})");
1064 assert!(!c.contains("CC=\"$PWD/.hammer-zig-cc\""), "CC ya no es el wrapper ({c})");
1065 // El linker NO cambia: sigue el wrapper zig-cc.
1066 assert!(c.contains("RF=\"-C linker=$PWD/.hammer-zig-cc"), "linker sigue zig-cc ({c})");
1067 assert!(c.contains("cargo rustc --release"), "sigue el camino cargo nativo ({c})");
1068 }
1069
1070 #[test]
1071 fn cargo_default_compiler_keeps_zig_cc_for_build_scripts() {
1072 // Sin `compiler=` (default ZigCc): CC sigue siendo el wrapper — baseline ripgrep/fd intacto.
1073 let d = tempfile::tempdir().unwrap();
1074 touch(d.path(), "Cargo.toml");
1075 let c = resolve_phases(&recipe(&[]), d.path()).unwrap().compile.unwrap();
1076 assert!(c.contains("CC=\"$PWD/.hammer-zig-cc\""), "default: cc-rs usa el wrapper zig ({c})");
1077 }
1078
1079 #[test]
1080 fn cargo_cross_target_keeps_triple() {
1081 // Un target NO nativo (cross real) sí pasa --target + el linker per-triple.
1082 let d = tempfile::tempdir().unwrap();
1083 touch(d.path(), "Cargo.toml");
1084 let mut r = recipe(&[]);
1085 r.build.target = "aarch64-linux-musl".into();
1086 let p = resolve_phases(&r, d.path()).unwrap();
1087 let c = p.compile.unwrap();
1088 assert!(c.contains("--target aarch64-unknown-linux-musl"), "{c}");
1089 assert!(c.contains("CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER="), "{c}");
1090 assert!(p.install.unwrap().contains("target/aarch64-unknown-linux-musl/release"));
1091 }
1092
1093 #[test]
1094 fn cargo_wins_over_stray_makefile_but_not_cmake() {
1095 let d = tempfile::tempdir().unwrap();
1096 touch(d.path(), "Cargo.toml");
1097 touch(d.path(), "Makefile");
1098 assert_eq!(detect_build_system(d.path()), BuildSys::Cargo);
1099 // pero un proyecto C+Rust con CMakeLists prefiere el sistema C
1100 touch(d.path(), "CMakeLists.txt");
1101 assert_eq!(detect_build_system(d.path()), BuildSys::CMake);
1102 }
1103
1104 #[test]
1105 fn cargo_flags_go_to_compile() {
1106 let d = tempfile::tempdir().unwrap();
1107 touch(d.path(), "Cargo.toml");
1108 let p = resolve_phases(&recipe(&["--features", "gpu"]), d.path()).unwrap();
1109 // Los flags de la receta van como args de cargo (antes del `--` que separa los de rustc).
1110 assert!(p.compile.unwrap().contains("--features gpu -- "));
1111 }
1112
1113 #[test]
1114 fn cargo_dynamic_disables_crt_static() {
1115 let d = tempfile::tempdir().unwrap();
1116 touch(d.path(), "Cargo.toml");
1117 let mut r = recipe(&[]);
1118 r.build.link = hammer_core::LinkMode::Dynamic;
1119 let c = resolve_phases(&r, d.path()).unwrap().compile.unwrap();
1120 assert!(c.contains("target-feature=-crt-static"), "{c}");
1121 }
1122
1123 #[test]
1124 fn rustc_triple_translation() {
1125 assert_eq!(rustc_triple("x86_64-linux-musl"), "x86_64-unknown-linux-musl");
1126 assert_eq!(rustc_triple("aarch64-linux-gnu"), "aarch64-unknown-linux-gnu");
1127 // un triple ya completo pasa intacto
1128 assert_eq!(rustc_triple("x86_64-unknown-linux-musl"), "x86_64-unknown-linux-musl");
1129 assert_eq!(
1130 cargo_linker_env_var("x86_64-unknown-linux-musl"),
1131 "CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER"
1132 );
1133 }
1134
1135 #[test]
1136 fn detect_make() {
1137 let d = tempfile::tempdir().unwrap();
1138 touch(d.path(), "Makefile");
1139 assert_eq!(detect_build_system(d.path()), BuildSys::Make);
1140 let p = resolve_phases(&recipe(&[]), d.path()).unwrap();
1141 assert!(p.configure.is_none(), "make plano no necesita configure");
1142 assert!(p.compile.unwrap().starts_with("make"));
1143 }
1144
1145 #[test]
1146 fn unknown_returns_no_phases() {
1147 let d = tempfile::tempdir().unwrap();
1148 // sólo un README, ningún archivo característico.
1149 std::fs::write(d.path().join("README"), b"hi").unwrap();
1150 assert_eq!(detect_build_system(d.path()), BuildSys::Unknown);
1151 let p = resolve_phases(&recipe(&[]), d.path()).unwrap();
1152 assert!(p.compile.is_none());
1153 assert!(p.install.is_none());
1154 }
1155
1156 #[test]
1157 fn recipe_override_wins_over_heuristic() {
1158 let d = tempfile::tempdir().unwrap();
1159 touch(d.path(), "Makefile");
1160 let mut r = recipe(&[]);
1161 r.build.phases.compile = Some("zig cc -static -o foo foo.c".to_string());
1162 r.build.phases.install = Some("cp foo /out/bin/foo".to_string());
1163 let p = resolve_phases(&r, d.path()).unwrap();
1164 assert_eq!(p.compile.unwrap(), "zig cc -static -o foo foo.c");
1165 assert_eq!(p.install.unwrap(), "cp foo /out/bin/foo");
1166 }
1167}
1168
1169
1170fn unique_out_dir(
1171 work_root: &Path,
1172 name: &str,
1173 h: &ArtifactHash,
1174) -> hammer_core::Result<PathBuf> {
1175 let dir = work_root
1176 .join("out")
1177 .join(h.store_dir_name(name));
1178 if dir.exists() {
1179 std::fs::remove_dir_all(&dir)?;
1180 }
1181 std::fs::create_dir_all(&dir)?;
1182 Ok(dir)
1183}
1184
1185/// Proyecta un artefacto del store al FHS (real o de overlay). Resuelve el directorio del
1186/// artefacto a partir del hash y delega en [`hydrate::hydrate`].
1187pub fn hydrate(
1188 h: &ArtifactHash,
1189 store: &Store,
1190 target_fhs: &Path,
1191 mode: LinkMode,
1192 dynamic: Option<&hydrate::DynamicSpec>,
1193) -> hammer_core::Result<HydrateReport> {
1194 let artifact_dir = store.find_by_hash(h.as_str())?;
1195 hydrate::hydrate(&artifact_dir, target_fhs, mode, dynamic)
1196}
1197
1198#[cfg(test)]
1199mod dep_hash_tests {
1200 use super::*;
1201
1202 /// Escribe `<dir>/<name>.toml` con `source.commit = commit` y, si hay, `deps.build = deps`.
1203 fn write_recipe(dir: &Path, name: &str, commit: &str, deps: &[&str]) {
1204 let deps_block = if deps.is_empty() {
1205 String::new()
1206 } else {
1207 let list = deps.iter().map(|d| format!("\"{d}\"")).collect::<Vec<_>>().join(", ");
1208 format!("\n[deps]\nbuild = [{list}]\n")
1209 };
1210 let toml = format!(
1211 "name = \"{name}\"\nversion = \"1\"\n[source]\nrepo = \"git://{name}\"\ncommit = \"{commit}\"\n[build]\n{deps_block}"
1212 );
1213 std::fs::write(dir.join(format!("{name}.toml")), toml).unwrap();
1214 }
1215
1216 fn load(dir: &Path, name: &str) -> Recipe {
1217 Recipe::load_from_path(dir.join(format!("{name}.toml"))).unwrap()
1218 }
1219
1220 fn store_in(dir: &Path) -> Store {
1221 Store::open(dir.join("store")).unwrap()
1222 }
1223
1224 #[test]
1225 fn dep_contributes_to_hash() {
1226 let d = tempfile::tempdir().unwrap();
1227 let store = store_in(d.path());
1228 write_recipe(d.path(), "base", "b0", &[]);
1229 write_recipe(d.path(), "app", "a0", &["base"]);
1230 write_recipe(d.path(), "app_nodep", "a0", &[]); // misma fuente, sin la dep
1231
1232 let with_dep = artifact_hash(&load(d.path(), "app"), &store).unwrap();
1233 let no_dep = artifact_hash(&load(d.path(), "app_nodep"), &store).unwrap();
1234 // `app_nodep` se llama distinto, pero el nombre no entra al hash: la única diferencia
1235 // de entrada es la presencia de la dep. (Renombramos sólo para tener dos .toml.)
1236 assert_ne!(with_dep, no_dep, "la dep de build debe entrar al hash");
1237 }
1238
1239 #[test]
1240 fn transitive_dep_change_propagates() {
1241 let d = tempfile::tempdir().unwrap();
1242 let store = store_in(d.path());
1243 write_recipe(d.path(), "base", "b0", &[]);
1244 write_recipe(d.path(), "lib", "l0", &["base"]);
1245 write_recipe(d.path(), "app", "a0", &["lib"]);
1246 let before = artifact_hash(&load(d.path(), "app"), &store).unwrap();
1247
1248 // Cambiar la fuente de la dep transitiva `base` debe re-hashear `app`.
1249 write_recipe(d.path(), "base", "b1", &[]);
1250 let after = artifact_hash(&load(d.path(), "app"), &store).unwrap();
1251 assert_ne!(before, after, "un cambio transitivo debe propagarse");
1252 }
1253
1254 #[test]
1255 fn missing_dep_toml_errors_with_names() {
1256 let d = tempfile::tempdir().unwrap();
1257 let store = store_in(d.path());
1258 write_recipe(d.path(), "app", "a0", &["pcre2"]); // sin pcre2.toml
1259 let err = artifact_hash(&load(d.path(), "app"), &store).unwrap_err().to_string();
1260 assert!(err.contains("pcre2"), "el error debe nombrar la dep: {err}");
1261 assert!(err.contains("app"), "y la receta que la pide: {err}");
1262 }
1263
1264 #[test]
1265 fn cycle_is_detected() {
1266 let d = tempfile::tempdir().unwrap();
1267 let store = store_in(d.path());
1268 write_recipe(d.path(), "a", "a0", &["b"]);
1269 write_recipe(d.path(), "b", "b0", &["a"]);
1270 let err = artifact_hash(&load(d.path(), "a"), &store).unwrap_err().to_string();
1271 assert!(err.contains("ciclo"), "esperaba ciclo: {err}");
1272 assert!(err.contains("a -> b -> a"), "debe mostrar la cadena: {err}");
1273 }
1274
1275 #[test]
1276 fn deps_without_base_dir_errors() {
1277 let store = Store::open(tempfile::tempdir().unwrap().path().join("store")).unwrap();
1278 // Parseada de TOML crudo ⇒ base_dir vacío; con deps no hay catálogo que resolver.
1279 let r = Recipe::from_toml(
1280 "name=\"x\"\nversion=\"1\"\n[source]\nrepo=\"git://x\"\ncommit=\"c0\"\n[build]\n[deps]\nbuild=[\"y\"]\n",
1281 )
1282 .unwrap();
1283 let err = artifact_hash(&r, &store).unwrap_err().to_string();
1284 assert!(err.contains("base_dir"), "debe explicar la falta de base_dir: {err}");
1285 }
1286
1287 #[test]
1288 fn no_deps_unaffected() {
1289 let d = tempfile::tempdir().unwrap();
1290 let store = store_in(d.path());
1291 write_recipe(d.path(), "solo", "s0", &[]);
1292 let a = artifact_hash(&load(d.path(), "solo"), &store).unwrap();
1293 let b = artifact_hash(&load(d.path(), "solo"), &store).unwrap();
1294 assert_eq!(a, b, "sin deps el hash es estable");
1295 }
1296}