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 1506Shell 193Rust 108Markdown 983676 Definitions

⎇ 4fe452848facd5d8e67009a8ae85ef0860cf656c crates/hammer-build/src/lib.rs Rust · 671 lines · 29.4 KB History Who touched what raw

bootstrap: -mcpu=baseline en zig cc — reproducibilidad CPU-independiente (Stage 2) 4fe452848 · 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, 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, 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 y hashea, en orden de declaración, cada `deps.build` de `recipe` cargándolo del
52/// catálogo (su `base_dir`). `stack` lleva la cadena en curso para detectar ciclos.
53fn resolve_build_dep_hashes(
54    recipe: &Recipe,
55    stack: &mut Vec<String>,
56) -> hammer_core::Result<Vec<ArtifactHash>> {
57    if recipe.deps.build.is_empty() {
58        return Ok(Vec::new());
59    }
60    if recipe.base_dir.as_os_str().is_empty() {
61        return Err(hammer_core::Error::Recipe(format!(
62            "la receta '{}' declara deps de build {:?} pero no tiene base_dir; \
63             cárgala con Recipe::load_from_path para resolver el catálogo",
64            recipe.name, recipe.deps.build
65        )));
66    }
67
68    stack.push(recipe.name.clone());
69    let mut hashes = Vec::with_capacity(recipe.deps.build.len());
70    for dep in &recipe.deps.build {
71        let dep_path = recipe.base_dir.join(format!("{dep}.toml"));
72        let dep_recipe = Recipe::load_from_path(&dep_path).map_err(|e| {
73            hammer_core::Error::Recipe(format!(
74                "no pude cargar la dep de build '{dep}' de '{}' ({}): {e}",
75                recipe.name,
76                dep_path.display()
77            ))
78        })?;
79        hashes.push(artifact_hash_rec(&dep_recipe, stack)?);
80    }
81    stack.pop();
82    Ok(hashes)
83}
84
85/// Compila una receta y devuelve el hash del artefacto sellado en el store.
86/// Si el hash ya existe en el store, devuelve sin recompilar (caché).
87pub fn build(
88    recipe: &Recipe,
89    cfg: &BuildConfig,
90    store: &Store,
91) -> hammer_core::Result<ArtifactHash> {
92    let h = artifact_hash(recipe, store)?;
93    if store.has(&h, &recipe.name) {
94        tracing::info!(hash = %h, name = %recipe.name, "caché: artefacto ya en el store");
95        return Ok(h);
96    }
97    sandbox::ensure_layout(&cfg.rootfs, &cfg.zig_dir)?;
98
99    tracing::info!(hash = %h, name = %recipe.name, "fetch");
100    let src_tree = fetch::fetch(recipe, &cfg.work_root)?;
101
102    if !recipe.source.patches.is_empty() {
103        tracing::info!(n = recipe.source.patches.len(), "aplicando patches");
104        fetch::apply_patches(recipe, &src_tree)?;
105    }
106
107    // Recetas Cargo: vendoreamos las deps (red permitida en el fetch) para que el build sea
108    // hermético `--offline` dentro del sandbox. Tras los patches, por si tocaron Cargo.toml/lock.
109    if matches!(detect_build_system(&src_tree), BuildSys::Cargo) {
110        tracing::info!("cargo: vendoreando deps para build offline");
111        fetch::vendor_cargo_deps(&src_tree)?;
112    }
113
114    let out_dir = unique_out_dir(&cfg.work_root, &recipe.name, &h)?;
115    let mut env: Vec<(String, String)> = Vec::new();
116    if matches!(recipe.build.link, LinkMode::Static) {
117        // autotools/cmake leen LDFLAGS para el link final; -static fuerza el binario
118        // monolítico que pide nuestra estrategia de hidratación primaria.
119        env.push(("LDFLAGS".into(), "-static".into()));
120    }
121    if let Some(cache) = &cfg.cache_root {
122        std::fs::create_dir_all(cache.join("zig"))?;
123    }
124    let sb = Sandbox {
125        rootfs: cfg.rootfs.clone(),
126        zig_dir: cfg.zig_dir.clone(),
127        src_dir: src_tree.clone(),
128        out_dir: out_dir.clone(),
129        cache_dir: cfg.cache_root.clone(),
130        env,
131    };
132
133    let phases = resolve_phases(recipe, &src_tree)?;
134    if let Some(cmd) = &phases.configure {
135        tracing::info!(cmd, "phase: configure");
136        sb.run(cmd)?;
137    }
138    if let Some(cmd) = &phases.compile {
139        tracing::info!(cmd, "phase: compile");
140        sb.run(cmd)?;
141    } else {
142        return Err(hammer_core::Error::Recipe(
143            "receta sin fase 'compile' y heurística no encontró ninguna".into(),
144        ));
145    }
146    if let Some(cmd) = &phases.install {
147        tracing::info!(cmd, "phase: install");
148        sb.run(cmd)?;
149    } else {
150        return Err(hammer_core::Error::Recipe(
151            "receta sin fase 'install' y heurística no encontró ninguna".into(),
152        ));
153    }
154
155    // Sidecar de provenance: escribimos la receta serializada DENTRO de out_dir antes del
156    // seal, para que quede congelada en el árbol read-only del store. `hammer export` la
157    // lee de vuelta para emitir `source_patch` con la receta original en vez de un
158    // `file_drop` opaco. Ver `docs/04-overlay.md` §provenance (Fase 4).
159    let sidecar_path = out_dir.join(hammer_core::store::RECIPE_SIDECAR_REL);
160    if let Some(parent) = sidecar_path.parent() {
161        std::fs::create_dir_all(parent)?;
162    }
163    let toml_text = recipe.to_toml()?;
164    std::fs::write(&sidecar_path, toml_text.as_bytes())?;
165
166    let sealed = store.seal(&out_dir, &h, &recipe.name)?;
167    tracing::info!(path = %sealed.display(), "sealed");
168    Ok(h)
169}
170
171/// Sistemas de build que reconoce la heurística por inspección del árbol del source.
172/// Ver `docs/02-build-lab.md` §4.
173#[derive(Debug, Clone, Copy, PartialEq, Eq)]
174enum BuildSys {
175    /// `configure.ac` (o `.in`) sin `configure` generado: hay que correr `autoreconf` primero.
176    AutoconfRaw,
177    /// `configure` ya está en el tarball release: vamos directo a `./configure`.
178    AutoconfReady,
179    /// `CMakeLists.txt`.
180    CMake,
181    /// `meson.build`.
182    Meson,
183    /// `Cargo.toml`: crate o workspace Rust. Build con `cargo`, link con `zig cc`.
184    Cargo,
185    /// `Makefile` plano (o `makefile`) sin sistema generador.
186    Make,
187    /// No detectamos nada: la receta debe traer overrides en `[build.phases]`.
188    Unknown,
189}
190
191fn detect_build_system(src: &Path) -> BuildSys {
192    let has = |p: &str| src.join(p).exists();
193    // El orden importa: cuando coexisten varios (un tarball release de autotools trae el
194    // `configure` regenerado y un `Makefile` huérfano de un build previo), preferimos el
195    // sistema "más alto" que sí sabe regenerar todo.
196    if has("configure.ac") || has("configure.in") {
197        if has("configure") {
198            return BuildSys::AutoconfReady;
199        }
200        return BuildSys::AutoconfRaw;
201    }
202    if has("configure") {
203        return BuildSys::AutoconfReady;
204    }
205    if has("CMakeLists.txt") {
206        return BuildSys::CMake;
207    }
208    if has("meson.build") {
209        return BuildSys::Meson;
210    }
211    // Cargo gana sobre un `Makefile` huérfano (algunos crates traen un wrapper `make`),
212    // pero cede ante autotools/cmake/meson (un políglota C+Rust suele querer el sistema C).
213    if has("Cargo.toml") {
214        return BuildSys::Cargo;
215    }
216    if has("Makefile") || has("makefile") || has("GNUmakefile") {
217        return BuildSys::Make;
218    }
219    BuildSys::Unknown
220}
221
222/// Target nativo del sandbox del lab (Alpine x86_64 musl). Una receta Cargo cuyo `build.target`
223/// sea éste se construye **nativa** (sin `--target`): el rust del sandbox sólo trae su std nativo
224/// (`x86_64-alpine-linux-musl`), no el `x86_64-unknown-linux-musl` que pediría un cross, y no hay
225/// rustup para agregarlo. Ver `docs/runbooks/stage1-vm-boot.md` §8 (primera corrida).
226const SANDBOX_NATIVE_TARGET: &str = "x86_64-linux-musl";
227
228/// Traduce el triple estilo zig/hammer (`x86_64-linux-musl`) al de rustc
229/// (`x86_64-unknown-linux-musl`). Un triple ya completo (con vendor) o ajeno al patrón
230/// `<arch>-linux-<libc>` se devuelve tal cual: la receta puede dar el triple exacto.
231fn rustc_triple(target: &str) -> String {
232    match target.split('-').collect::<Vec<_>>().as_slice() {
233        [arch, "linux", libc] => format!("{arch}-unknown-linux-{libc}"),
234        _ => target.to_string(),
235    }
236}
237
238/// Nombre de la env `CARGO_TARGET_<TRIPLE>_LINKER` para un triple de rustc.
239fn cargo_linker_env_var(triple: &str) -> String {
240    format!("CARGO_TARGET_{}_LINKER", triple.to_uppercase().replace(['-', '.'], "_"))
241}
242
243/// Devuelve los comandos a ejecutar para cada fase: overrides explícitos en la receta
244/// ganan; el resto lo deriva la heurística según el sistema de build detectado.
245fn resolve_phases(recipe: &Recipe, src: &Path) -> hammer_core::Result<Phases> {
246    let mut out = recipe.build.phases.clone();
247    let bs = detect_build_system(src);
248    let flags = recipe.build.flags.join(" ");
249    let flags_suffix = if flags.is_empty() { String::new() } else { format!(" {flags}") };
250
251    if out.configure.is_none() {
252        out.configure = match bs {
253            BuildSys::AutoconfRaw => Some(format!(
254                "autoreconf -fi && ./configure --prefix=/usr{flags_suffix}"
255            )),
256            BuildSys::AutoconfReady => Some(format!("./configure --prefix=/usr{flags_suffix}")),
257            BuildSys::CMake => Some(format!(
258                "cmake -S . -B _build -DCMAKE_INSTALL_PREFIX=/usr -DCMAKE_BUILD_TYPE=Release{flags_suffix}"
259            )),
260            BuildSys::Meson => Some(format!("meson setup _build --prefix=/usr{flags_suffix}")),
261            // Cargo no tiene fase configure separada.
262            BuildSys::Cargo | BuildSys::Make | BuildSys::Unknown => None,
263        };
264    }
265    if out.compile.is_none() {
266        out.compile = match bs {
267            BuildSys::AutoconfRaw | BuildSys::AutoconfReady | BuildSys::Make => {
268                Some(r#"make -j"$(nproc)""#.to_string())
269            }
270            BuildSys::CMake => Some(r#"cmake --build _build -j "$(nproc)""#.to_string()),
271            BuildSys::Meson => Some("meson compile -C _build".to_string()),
272            BuildSys::Cargo => {
273                // Hermético y reproducible: `--offline` (deps vendoreadas en la fuente) +
274                // `--locked` (Cargo.lock fijo, coherente con ADR 0006). El link va por un
275                // wrapper `zig cc` — cargo no acepta un linker de dos palabras, y zig provee
276                // el `libgcc_s` que el link dinámico musl pide (ver M2 del plan arje↔hammer).
277                if recipe.build.target == SANDBOX_NATIVE_TARGET {
278                    // NATIVO: el sandbox del lab YA es x86_64 musl, así que no cruzamos. El rust del
279                    // sandbox sólo trae su std nativo (`x86_64-alpine-linux-musl`), no
280                    // `x86_64-unknown-linux-musl`; con `--target` fallaría "can't find crate for
281                    // core" (no hay rustup). Ver docs/runbooks/stage1-vm-boot.md §8.
282                    //
283                    // El wrapper `.hammer-zig-cc` hace DOS cosas: (1) es un único ejecutable, lo que
284                    // resuelve el `zig cc` de dos palabras que cargo (linker) y cc-rs (CC)
285                    // mal-parsean; (2) **sanea el triple**: cc-rs (build scripts que compilan C/asm,
286                    // p.ej. blake3) detecta el host triple de Alpine rust y pasa
287                    // `--target=x86_64-alpine-linux-musl`, pero zig no conoce el vendor "alpine"
288                    // (`UnknownOperatingSystem`); lo reescribimos a `x86_64-linux-musl`. Se usa como
289                    // CC (cc-rs) y como linker (RUSTFLAGS, target-agnóstico — el default sería
290                    // cc/gcc, ausente en el sandbox zig-only).
291                    // Flags de codegen del BINARIO FINAL, vía `cargo rustc -- …` (NO RUSTFLAGS): en
292                    // build nativo RUSTFLAGS también alcanza a los proc-macros (deps), y
293                    // `+crt-static`/`relocation-model=static` los rompe ("cannot produce
294                    // proc-macro" — un proc-macro es un dylib PIC). `cargo rustc -- …` los aplica
295                    // sólo a la crate top-level, dejando los proc-macros intactos.
296                    let bin_flags = match recipe.build.link {
297                        // Estático AUTOCONTENIDO y no-PIE: `+crt-static` mete musl dentro (sin
298                        // DT_NEEDED — el rust de Alpine enlaza musl dinámico por defecto, lo que deja
299                        // `libc.musl-x86_64.so.1` y rompe el boot, runbook §8); `relocation-model=static`
300                        // ⇒ ET_EXEC sin interpreter (vía de oro: como busybox, sin libc.so/loader/soname).
301                        LinkMode::Static => "-C target-feature=+crt-static -C relocation-model=static",
302                        // Dinámico: apaga crt-static para poder dlopen (necesita el loader/musl en el rootfs).
303                        LinkMode::Dynamic => "-C target-feature=-crt-static",
304                    };
305                    Some(format!(
306                        "printf '%s\\n' '#!/bin/sh' 'for a do' \
307                         'case \"$a\" in --target=*) a=--target=x86_64-linux-musl ;; esac' \
308                         'set -- \"$@\" \"$a\"' 'shift' 'done' 'exec zig cc -mcpu=baseline \"$@\"' \
309                         > \"$PWD/.hammer-zig-cc\" && chmod +x \"$PWD/.hammer-zig-cc\" && \
310                         CC=\"$PWD/.hammer-zig-cc\" RUSTFLAGS=\"-C linker=$PWD/.hammer-zig-cc\" \
311                         cargo rustc --release --locked --offline{flags_suffix} -- {bin_flags}"
312                    ))
313                } else {
314                    // CROSS real (otro target que zig sí entiende): `--target` + linker por env
315                    // per-triple. Hermético y reproducible: `--offline` (deps vendoreadas) +
316                    // `--locked` (Cargo.lock fijo, ADR 0006). zig provee el `libgcc_s` que el link
317                    // dinámico musl pide (ver M2 del plan arje↔hammer).
318                    let setup = "printf '#!/bin/sh\\nexec zig cc -mcpu=baseline \"$@\"\\n' > \"$PWD/.hammer-zig-cc\" && \
319                                 chmod +x \"$PWD/.hammer-zig-cc\" && ";
320                    let triple = rustc_triple(&recipe.build.target);
321                    let linker_var = cargo_linker_env_var(&triple);
322                    let rustflags = match recipe.build.link {
323                        // musl es crt-static por defecto; el dinámico lo apaga para poder dlopen.
324                        LinkMode::Dynamic => "RUSTFLAGS='-C target-feature=-crt-static' ",
325                        LinkMode::Static => "",
326                    };
327                    Some(format!(
328                        "{setup}{rustflags}{linker_var}=\"$PWD/.hammer-zig-cc\" \
329                         cargo build --release --locked --offline --target {triple}{flags_suffix}"
330                    ))
331                }
332            }
333            BuildSys::Unknown => None,
334        };
335    }
336    if out.install.is_none() {
337        out.install = match bs {
338            BuildSys::AutoconfRaw | BuildSys::AutoconfReady | BuildSys::Make => {
339                Some("make install DESTDIR=/out PREFIX=/usr".to_string())
340            }
341            BuildSys::CMake => Some("DESTDIR=/out cmake --install _build".to_string()),
342            BuildSys::Meson => Some("DESTDIR=/out meson install -C _build --no-rebuild".to_string()),
343            BuildSys::Cargo => {
344                // Copia los ejecutables finales (top del dir release, bit de ejecución) a
345                // /out/usr/bin. `-perm -100` / `-exec cp \;` son busybox-safe (rootfs Alpine).
346                // Build nativo ⇒ `target/release`; cross ⇒ `target/<triple>/release`.
347                let rel = if recipe.build.target == SANDBOX_NATIVE_TARGET {
348                    "target/release".to_string()
349                } else {
350                    format!("target/{}/release", rustc_triple(&recipe.build.target))
351                };
352                Some(format!(
353                    "mkdir -p /out/usr/bin && \
354                     find {rel} -maxdepth 1 -type f -perm -100 \
355                     -exec cp {{}} /out/usr/bin/ \\;"
356                ))
357            }
358            BuildSys::Unknown => None,
359        };
360    }
361    Ok(out)
362}
363
364#[cfg(test)]
365mod heuristic_tests {
366    use super::*;
367
368    fn touch(dir: &Path, name: &str) {
369        std::fs::write(dir.join(name), b"").unwrap();
370    }
371
372    fn recipe(flags: &[&str]) -> Recipe {
373        let mut r = Recipe::from_toml(
374            r#"
375name = "x"
376version = "0"
377[source]
378repo = "git://x"
379commit = "deadbeef"
380[build]
381"#,
382        )
383        .unwrap();
384        r.build.flags = flags.iter().map(|s| (*s).to_string()).collect();
385        r
386    }
387
388    #[test]
389    fn detect_autoconf_raw_when_only_configure_ac() {
390        let d = tempfile::tempdir().unwrap();
391        touch(d.path(), "configure.ac");
392        assert_eq!(detect_build_system(d.path()), BuildSys::AutoconfRaw);
393        let p = resolve_phases(&recipe(&[]), d.path()).unwrap();
394        assert!(p.configure.as_deref().unwrap().starts_with("autoreconf -fi &&"));
395        assert!(p.install.as_deref().unwrap().contains("DESTDIR=/out"));
396    }
397
398    #[test]
399    fn detect_autoconf_ready_when_configure_exists() {
400        let d = tempfile::tempdir().unwrap();
401        touch(d.path(), "configure.ac");
402        touch(d.path(), "configure");
403        assert_eq!(detect_build_system(d.path()), BuildSys::AutoconfReady);
404        let p = resolve_phases(&recipe(&["--enable-foo"]), d.path()).unwrap();
405        let c = p.configure.unwrap();
406        assert!(c.starts_with("./configure --prefix=/usr"), "{c}");
407        assert!(c.ends_with("--enable-foo"), "{c}");
408    }
409
410    #[test]
411    fn detect_cmake() {
412        let d = tempfile::tempdir().unwrap();
413        touch(d.path(), "CMakeLists.txt");
414        assert_eq!(detect_build_system(d.path()), BuildSys::CMake);
415        let p = resolve_phases(&recipe(&[]), d.path()).unwrap();
416        assert!(p.configure.unwrap().contains("-DCMAKE_INSTALL_PREFIX=/usr"));
417        assert!(p.compile.unwrap().contains("cmake --build _build"));
418        assert!(p.install.unwrap().contains("DESTDIR=/out"));
419    }
420
421    #[test]
422    fn detect_meson() {
423        let d = tempfile::tempdir().unwrap();
424        touch(d.path(), "meson.build");
425        assert_eq!(detect_build_system(d.path()), BuildSys::Meson);
426        let p = resolve_phases(&recipe(&[]), d.path()).unwrap();
427        assert!(p.configure.unwrap().contains("meson setup _build"));
428        assert!(p.install.unwrap().contains("meson install"));
429    }
430
431    #[test]
432    fn detect_cargo() {
433        let d = tempfile::tempdir().unwrap();
434        touch(d.path(), "Cargo.toml");
435        assert_eq!(detect_build_system(d.path()), BuildSys::Cargo);
436        // Target default (x86_64-linux-musl) = nativo del sandbox ⇒ build SIN --target.
437        let p = resolve_phases(&recipe(&[]), d.path()).unwrap();
438        assert!(p.configure.is_none(), "cargo no usa configure");
439        let c = p.compile.unwrap();
440        // Nativo: `cargo rustc` (no `cargo build`) para pasar los flags de codegen tras `--` sólo
441        // a la crate final; sin `--target` (lo cross sí; el único --target es el del wrapper saneador).
442        assert!(c.contains("cargo rustc --release"), "{c}");
443        assert!(c.contains("--locked") && c.contains("--offline"), "{c}");
444        assert!(!c.contains("--offline --target"), "nativo: cargo sin --target ({c})");
445        assert!(c.contains("a=--target=x86_64-linux-musl"), "wrapper sanea el triple ({c})");
446        assert!(c.contains("-- -C target-feature=+crt-static"), "crt-static scoped tras -- ({c})");
447        assert!(c.contains("relocation-model=static"), "no-PIE: ET_EXEC sin interpreter ({c})");
448        assert!(c.contains("CC=\"$PWD/.hammer-zig-cc\""), "CC = wrapper para cc-rs ({c})");
449        assert!(c.contains("RUSTFLAGS=\"-C linker=$PWD/.hammer-zig-cc"), "linker por RUSTFLAGS ({c})");
450        let i = p.install.unwrap();
451        assert!(i.contains("/out/usr/bin"), "{i}");
452        assert!(i.contains("find target/release"), "nativo instala de target/release ({i})");
453    }
454
455    #[test]
456    fn cargo_cross_target_keeps_triple() {
457        // Un target NO nativo (cross real) sí pasa --target + el linker per-triple.
458        let d = tempfile::tempdir().unwrap();
459        touch(d.path(), "Cargo.toml");
460        let mut r = recipe(&[]);
461        r.build.target = "aarch64-linux-musl".into();
462        let p = resolve_phases(&r, d.path()).unwrap();
463        let c = p.compile.unwrap();
464        assert!(c.contains("--target aarch64-unknown-linux-musl"), "{c}");
465        assert!(c.contains("CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER="), "{c}");
466        assert!(p.install.unwrap().contains("target/aarch64-unknown-linux-musl/release"));
467    }
468
469    #[test]
470    fn cargo_wins_over_stray_makefile_but_not_cmake() {
471        let d = tempfile::tempdir().unwrap();
472        touch(d.path(), "Cargo.toml");
473        touch(d.path(), "Makefile");
474        assert_eq!(detect_build_system(d.path()), BuildSys::Cargo);
475        // pero un proyecto C+Rust con CMakeLists prefiere el sistema C
476        touch(d.path(), "CMakeLists.txt");
477        assert_eq!(detect_build_system(d.path()), BuildSys::CMake);
478    }
479
480    #[test]
481    fn cargo_flags_go_to_compile() {
482        let d = tempfile::tempdir().unwrap();
483        touch(d.path(), "Cargo.toml");
484        let p = resolve_phases(&recipe(&["--features", "gpu"]), d.path()).unwrap();
485        // Los flags de la receta van como args de cargo (antes del `--` que separa los de rustc).
486        assert!(p.compile.unwrap().contains("--features gpu -- "));
487    }
488
489    #[test]
490    fn cargo_dynamic_disables_crt_static() {
491        let d = tempfile::tempdir().unwrap();
492        touch(d.path(), "Cargo.toml");
493        let mut r = recipe(&[]);
494        r.build.link = hammer_core::LinkMode::Dynamic;
495        let c = resolve_phases(&r, d.path()).unwrap().compile.unwrap();
496        assert!(c.contains("target-feature=-crt-static"), "{c}");
497    }
498
499    #[test]
500    fn rustc_triple_translation() {
501        assert_eq!(rustc_triple("x86_64-linux-musl"), "x86_64-unknown-linux-musl");
502        assert_eq!(rustc_triple("aarch64-linux-gnu"), "aarch64-unknown-linux-gnu");
503        // un triple ya completo pasa intacto
504        assert_eq!(rustc_triple("x86_64-unknown-linux-musl"), "x86_64-unknown-linux-musl");
505        assert_eq!(
506            cargo_linker_env_var("x86_64-unknown-linux-musl"),
507            "CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER"
508        );
509    }
510
511    #[test]
512    fn detect_make() {
513        let d = tempfile::tempdir().unwrap();
514        touch(d.path(), "Makefile");
515        assert_eq!(detect_build_system(d.path()), BuildSys::Make);
516        let p = resolve_phases(&recipe(&[]), d.path()).unwrap();
517        assert!(p.configure.is_none(), "make plano no necesita configure");
518        assert!(p.compile.unwrap().starts_with("make"));
519    }
520
521    #[test]
522    fn unknown_returns_no_phases() {
523        let d = tempfile::tempdir().unwrap();
524        // sólo un README, ningún archivo característico.
525        std::fs::write(d.path().join("README"), b"hi").unwrap();
526        assert_eq!(detect_build_system(d.path()), BuildSys::Unknown);
527        let p = resolve_phases(&recipe(&[]), d.path()).unwrap();
528        assert!(p.compile.is_none());
529        assert!(p.install.is_none());
530    }
531
532    #[test]
533    fn recipe_override_wins_over_heuristic() {
534        let d = tempfile::tempdir().unwrap();
535        touch(d.path(), "Makefile");
536        let mut r = recipe(&[]);
537        r.build.phases.compile = Some("zig cc -static -o foo foo.c".to_string());
538        r.build.phases.install = Some("cp foo /out/bin/foo".to_string());
539        let p = resolve_phases(&r, d.path()).unwrap();
540        assert_eq!(p.compile.unwrap(), "zig cc -static -o foo foo.c");
541        assert_eq!(p.install.unwrap(), "cp foo /out/bin/foo");
542    }
543}
544
545
546fn unique_out_dir(
547    work_root: &Path,
548    name: &str,
549    h: &ArtifactHash,
550) -> hammer_core::Result<PathBuf> {
551    let dir = work_root
552        .join("out")
553        .join(h.store_dir_name(name));
554    if dir.exists() {
555        std::fs::remove_dir_all(&dir)?;
556    }
557    std::fs::create_dir_all(&dir)?;
558    Ok(dir)
559}
560
561/// Proyecta un artefacto del store al FHS (real o de overlay). Resuelve el directorio del
562/// artefacto a partir del hash y delega en [`hydrate::hydrate`].
563pub fn hydrate(
564    h: &ArtifactHash,
565    store: &Store,
566    target_fhs: &Path,
567    mode: LinkMode,
568) -> hammer_core::Result<HydrateReport> {
569    let artifact_dir = store.find_by_hash(h.as_str())?;
570    hydrate::hydrate(&artifact_dir, target_fhs, mode)
571}
572
573#[cfg(test)]
574mod dep_hash_tests {
575    use super::*;
576
577    /// Escribe `<dir>/<name>.toml` con `source.commit = commit` y, si hay, `deps.build = deps`.
578    fn write_recipe(dir: &Path, name: &str, commit: &str, deps: &[&str]) {
579        let deps_block = if deps.is_empty() {
580            String::new()
581        } else {
582            let list = deps.iter().map(|d| format!("\"{d}\"")).collect::<Vec<_>>().join(", ");
583            format!("\n[deps]\nbuild = [{list}]\n")
584        };
585        let toml = format!(
586            "name = \"{name}\"\nversion = \"1\"\n[source]\nrepo = \"git://{name}\"\ncommit = \"{commit}\"\n[build]\n{deps_block}"
587        );
588        std::fs::write(dir.join(format!("{name}.toml")), toml).unwrap();
589    }
590
591    fn load(dir: &Path, name: &str) -> Recipe {
592        Recipe::load_from_path(dir.join(format!("{name}.toml"))).unwrap()
593    }
594
595    fn store_in(dir: &Path) -> Store {
596        Store::open(dir.join("store")).unwrap()
597    }
598
599    #[test]
600    fn dep_contributes_to_hash() {
601        let d = tempfile::tempdir().unwrap();
602        let store = store_in(d.path());
603        write_recipe(d.path(), "base", "b0", &[]);
604        write_recipe(d.path(), "app", "a0", &["base"]);
605        write_recipe(d.path(), "app_nodep", "a0", &[]); // misma fuente, sin la dep
606
607        let with_dep = artifact_hash(&load(d.path(), "app"), &store).unwrap();
608        let no_dep = artifact_hash(&load(d.path(), "app_nodep"), &store).unwrap();
609        // `app_nodep` se llama distinto, pero el nombre no entra al hash: la única diferencia
610        // de entrada es la presencia de la dep. (Renombramos sólo para tener dos .toml.)
611        assert_ne!(with_dep, no_dep, "la dep de build debe entrar al hash");
612    }
613
614    #[test]
615    fn transitive_dep_change_propagates() {
616        let d = tempfile::tempdir().unwrap();
617        let store = store_in(d.path());
618        write_recipe(d.path(), "base", "b0", &[]);
619        write_recipe(d.path(), "lib", "l0", &["base"]);
620        write_recipe(d.path(), "app", "a0", &["lib"]);
621        let before = artifact_hash(&load(d.path(), "app"), &store).unwrap();
622
623        // Cambiar la fuente de la dep transitiva `base` debe re-hashear `app`.
624        write_recipe(d.path(), "base", "b1", &[]);
625        let after = artifact_hash(&load(d.path(), "app"), &store).unwrap();
626        assert_ne!(before, after, "un cambio transitivo debe propagarse");
627    }
628
629    #[test]
630    fn missing_dep_toml_errors_with_names() {
631        let d = tempfile::tempdir().unwrap();
632        let store = store_in(d.path());
633        write_recipe(d.path(), "app", "a0", &["pcre2"]); // sin pcre2.toml
634        let err = artifact_hash(&load(d.path(), "app"), &store).unwrap_err().to_string();
635        assert!(err.contains("pcre2"), "el error debe nombrar la dep: {err}");
636        assert!(err.contains("app"), "y la receta que la pide: {err}");
637    }
638
639    #[test]
640    fn cycle_is_detected() {
641        let d = tempfile::tempdir().unwrap();
642        let store = store_in(d.path());
643        write_recipe(d.path(), "a", "a0", &["b"]);
644        write_recipe(d.path(), "b", "b0", &["a"]);
645        let err = artifact_hash(&load(d.path(), "a"), &store).unwrap_err().to_string();
646        assert!(err.contains("ciclo"), "esperaba ciclo: {err}");
647        assert!(err.contains("a -> b -> a"), "debe mostrar la cadena: {err}");
648    }
649
650    #[test]
651    fn deps_without_base_dir_errors() {
652        let store = Store::open(tempfile::tempdir().unwrap().path().join("store")).unwrap();
653        // Parseada de TOML crudo ⇒ base_dir vacío; con deps no hay catálogo que resolver.
654        let r = Recipe::from_toml(
655            "name=\"x\"\nversion=\"1\"\n[source]\nrepo=\"git://x\"\ncommit=\"c0\"\n[build]\n[deps]\nbuild=[\"y\"]\n",
656        )
657        .unwrap();
658        let err = artifact_hash(&r, &store).unwrap_err().to_string();
659        assert!(err.contains("base_dir"), "debe explicar la falta de base_dir: {err}");
660    }
661
662    #[test]
663    fn no_deps_unaffected() {
664        let d = tempfile::tempdir().unwrap();
665        let store = store_in(d.path());
666        write_recipe(d.path(), "solo", "s0", &[]);
667        let a = artifact_hash(&load(d.path(), "solo"), &store).unwrap();
668        let b = artifact_hash(&load(d.path(), "solo"), &store).unwrap();
669        assert_eq!(a, b, "sin deps el hash es estable");
670    }
671}