tawasuyu

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…

TOML 1346Shell 97Rust 63Markdown 571650 Sutichaykuna

⎇ c583c4d679523abe02800398b711708b447d3a0f crates/hammer-build/src/lib.rs Rust · 1296 siq'ikuna · 61.7 KB Ñawpa willay Pi imata llamkarqan ch'uya

Etapa G: frontera go.mod-en-subdir — knob [build] subdir (vendor/detect/compile en src/<subdir>) c583c4d67 · sergio ·

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}