gioser

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 815Rust 56Shell 37Markdown 291229 Definiciones

⎇ 9fe1e010e21199c12896ff156f85884a83f04ce6 crates/takana-build/src/swm_bridge.rs Rust · 588 líneas · 24.7 KB Historia Quién tocó qué crudo

paquetería: zsh se puede instalar — los parches múltiples viajaban unidos y el target_bin era una adivinanza 9fe1e010e · Sergio ·

1//! Puente entre `takana_core::Mutation::SourcePatch` y el laboratorio.
2//!
3//! Convierte la mutación serializada en una `Recipe` efímera + un archivo de patch
4//! temporal, y delega en `build` para sellar el artefacto en el store. Luego se hidrata.
5//!
6//! Este módulo vive en `takana-build` (no en `takana-core`) porque depende del store y del
7//! sandbox. `takana-core` debe permanecer puro (sin red ni mounts) para que el watcher y los
8//! tests no arrastren toda la cadena del lab.
9
10use std::path::{Path, PathBuf};
11
12use takana_core::{
13    swm::{Mutation, SwmBuild},
14    ArtifactHash, Compiler, LinkMode, Recipe, Store,
15};
16
17use crate::{build, BuildConfig};
18
19/// Construye una `SourcePatch` y devuelve el hash sellado en el store. El llamador hidrata
20/// luego (típicamente al overlay activo).
21///
22/// `scratch_root` sirve para escribir el patch inline a disco — usamos `cfg.work_root` por
23/// defecto si el caller no quiere gestionarlo a mano.
24pub fn build_source_patch(
25    mutation: &Mutation,
26    cfg: &BuildConfig,
27    store: &Store,
28    scratch_root: Option<&Path>,
29    name: Option<&str>,
30) -> takana_core::Result<ArtifactHash> {
31    let catalog_dir = catalog_dir_for(cfg, scratch_root);
32    // El NOMBRE de la receta sintetizada decide el sufijo del store (`<hash>-<name>`) Y la
33    // clave de cache-hit (`store.has(hash, name)`). Si quien llama conoce el nombre del paquete
34    // (p.ej. `install ripgrep`), lo pasa: así reproduce como "ripgrep" y cache-hitea el artefacto
35    // del corpus, en vez de derivar "rg" del target_bin y sellar un duplicado bajo otro nombre.
36    let recipe = recipe_from_source_patch(mutation, name, &catalog_dir)?;
37
38    let expected = match mutation {
39        Mutation::SourcePatch { expected_hash, .. } => expected_hash.clone(),
40        _ => None,
41    };
42    let hash = build(&recipe, cfg, store)?;
43    if let Some(want) = &expected {
44        let want_hex = want.strip_prefix("b3:").unwrap_or(want);
45        let got_hex = hash
46            .as_str()
47            .strip_prefix("b3:")
48            .expect("of_inputs siempre devuelve 'b3:'");
49        if want_hex != got_hex {
50            return Err(takana_core::Error::Recipe(format!(
51                "expected_hash no coincide: declarado=b3:{want_hex}, obtenido={hash}"
52            )));
53        }
54    }
55    Ok(hash)
56}
57
58/// El directorio-catálogo donde se materializan los patches y los `{dep}.toml`. Determinista a
59/// partir de `cfg.work_root` (o `scratch_root`): por eso `install` puede pre-poblarlo con los
60/// `{dep}.toml` y `build_source_patch` los encontrará al resolver build-deps del mismo `base_dir`.
61pub fn catalog_dir_for(cfg: &BuildConfig, scratch_root: Option<&Path>) -> PathBuf {
62    scratch_root.unwrap_or(&cfg.work_root).join("swm-recipes")
63}
64
65/// Sintetiza (SIN construir) la `Recipe` efímera de un `source_patch`: parsea el origen,
66/// materializa el patch inline/remoto a disco bajo `catalog_dir`, y arma la receta con sus
67/// `deps` y `base_dir = catalog_dir` (para que las build-deps resuelvan `{dep}.toml` ahí).
68///
69/// `name` sobreescribe el nombre derivado del `target_bin`. Es OBLIGATORIO al escribir un
70/// `{dep}.toml`: el nombre de la receta DEBE ser el de la dep para que el dependiente la resuelva.
71pub fn recipe_from_source_patch(
72    mutation: &Mutation,
73    name: Option<&str>,
74    catalog_dir: &Path,
75) -> takana_core::Result<Recipe> {
76    let (repo, commit, tarball, sha256, strip_components, patch, patch_list, patch_url, build_cfg, target_bin, deps, evidence, slots) =
77        match mutation {
78            Mutation::SourcePatch {
79                repo, commit, tarball, sha256, strip_components, patch, patches, patch_url, build,
80                target_bin, deps, evidence, slots, ..
81            } => (
82                repo, commit, tarball, sha256, strip_components, patch, patches, patch_url, build,
83                target_bin, deps, evidence, slots,
84            ),
85            _ => {
86                return Err(takana_core::Error::Recipe(
87                    "recipe_from_source_patch: la mutación no es 'source_patch'".into(),
88                ));
89            }
90        };
91
92    // git xor tarball — misma regla que el schema (`swm_source_kind`).
93    let source_kind = takana_core::swm::swm_source_kind(
94        repo.as_deref(),
95        commit.as_deref(),
96        tarball.as_deref(),
97        sha256.as_deref(),
98    )
99    .map_err(takana_core::Error::Recipe)?;
100
101    std::fs::create_dir_all(catalog_dir)?;
102
103    // Clave estable del origen para nombrar el patch a disco: el commit (git) o el sha256
104    // (tarball). Idéntica reaplicación ⇒ mismo nombre ⇒ hash de receta determinista.
105    let source_key: &str = match &source_kind {
106        takana_core::SourceKind::Git { commit, .. } => commit,
107        takana_core::SourceKind::Tarball { sha256, .. } => sha256,
108        // Inalcanzable por construcción —`swm_source_kind` sólo produce Git o Tarball— pero se
109        // escribe como error y no como `unreachable!`: esto es una librería, y un panic aquí
110        // mataría al llamador por una receta mal formada en vez de decírselo.
111        takana_core::SourceKind::Dir { .. } => {
112            return Err(takana_core::Error::Recipe(
113                "un .swm no puede llevar un source por directorio".into(),
114            ))
115        }
116    };
117
118    // Los parches se materializan **uno por fichero y en el orden del manifiesto**: el lab los
119    // aplica en ese orden y `Recipe::hash_inputs` mete una entrada por fichero. Unirlos en uno
120    // solo sella en otra dirección — ver `Mutation::SourcePatch::patches`.
121    let mut patches: Vec<String> = Vec::new();
122    if !patch_list.is_empty() || patch.is_some() || patch_url.is_some() {
123        // Patch bajo un nombre derivado del nombre-de-receta + clave de origen, para no colisionar
124        // entre `{dep}.patch` distintos en el mismo catálogo.
125        let label = name.map(|n| n.to_string()).unwrap_or_else(|| derive_name(target_bin));
126        // `source.patches` guarda el NOMBRE relativo a `base_dir` (= catalog_dir); el lab resuelve
127        // `base_dir.join(nombre)` UNA vez al construir (fetch::apply_patches / Recipe hash). Guardar el
128        // path completo acá lo haría re-unir a base_dir aguas abajo (doble/triple prefijo).
129        if !patch_list.is_empty() {
130            // El índice va en el nombre para conservar el orden Y para no pisar un parche con otro
131            // cuando la receta trae varios del mismo origen.
132            for (i, text) in patch_list.iter().enumerate() {
133                let filename = format!("{label}-{source_key}-{i:02}.patch");
134                std::fs::write(catalog_dir.join(&filename), text.as_bytes())?;
135                patches.push(filename);
136            }
137        } else {
138            // Formato histórico: un único parche inline (o remoto).
139            let filename = format!("{label}-{source_key}.patch");
140            let patch_path = catalog_dir.join(&filename);
141            if let Some(text) = patch {
142                std::fs::write(&patch_path, text.as_bytes())?;
143            } else if let Some(url) = patch_url {
144                tracing::info!(%url, "fetch: descargando patch remoto");
145                crate::download::fetch_url_to_file(url, &patch_path)?;
146            }
147            patches.push(filename);
148        }
149    }
150
151    let name = name.map(|n| n.to_string()).unwrap_or_else(|| derive_name(target_bin));
152    synthesize_recipe(
153        &name,
154        &source_kind,
155        build_cfg,
156        *strip_components,
157        deps,
158        evidence,
159        slots,
160        patches,
161        catalog_dir,
162    )
163}
164
165/// Nombre legible para el directorio del store. `/usr/bin/grep` → `grep`. Si el path no
166/// tiene basename (raro), usamos `swm-bin`. El nombre afecta SÓLO al sufijo legible
167/// (`<hash>-<name>`), no al hash; cambiarlo no rompe la caché del store.
168fn derive_name(target_bin: &str) -> String {
169    Path::new(target_bin)
170        .file_name()
171        .and_then(|s| s.to_str())
172        .unwrap_or("swm-bin")
173        .to_string()
174}
175
176#[allow(clippy::too_many_arguments)]
177fn synthesize_recipe(
178    name: &str,
179    source: &takana_core::SourceKind<'_>,
180    build_cfg: &SwmBuild,
181    strip_components: Option<usize>,
182    deps: &takana_core::Deps,
183    evidence: &takana_core::Evidence,
184    slots: &takana_core::Slots,
185    patches: Vec<String>,
186    base_dir: &Path,
187) -> takana_core::Result<Recipe> {
188    let compiler = parse_compiler(&build_cfg.compiler)?;
189    let link = parse_link(&build_cfg.link)?;
190    // Sección `[source]` y sufijo de versión según el modo de origen. El validador de
191    // `Source::kind` ya garantiza git xor tarball; acá sólo emitimos el TOML correspondiente.
192    let (version_key, source_block) = match source {
193        takana_core::SourceKind::Git { repo, commit } => (
194            *commit,
195            format!("repo   = \"{repo}\"\ncommit = \"{commit}\"\n"),
196        ),
197        takana_core::SourceKind::Tarball { url, sha256 } => (
198            *sha256,
199            format!("tarball = \"{url}\"\nsha256  = \"{sha256}\"\n"),
200        ),
201        // Ver la nota del `source_key` de arriba: un `.swm` no puede traer un `dir`.
202        takana_core::SourceKind::Dir { .. } => {
203            return Err(takana_core::Error::Recipe(
204                "un .swm no puede llevar un source por directorio".into(),
205            ))
206        }
207    };
208    // Construimos la receta vía TOML para reusar las defaults y el validador de `Source`.
209    let toml_text = format!(
210        r#"
211name = "{name}"
212version = "swm-{version_short}"
213
214[source]
215{source_block}
216[build]
217compiler = "{compiler}"
218target   = "{target}"
219link     = "{link}"
220flags    = []
221"#,
222        name = name,
223        version_short = &version_key[..version_key.len().min(12)],
224        source_block = source_block,
225        compiler = compiler.as_str(),
226        target = build_cfg.target,
227        link = link.as_str(),
228    );
229    let mut recipe = Recipe::from_toml(&toml_text)?;
230    recipe.build.flags = build_cfg.flags.clone();
231    // Fidelidad de reconstrucción: fases custom, zig por-paquete y strip del tarball viajan en
232    // el .swm (si vienen) y se reinyectan en la receta efímera tal cual los tenía el original.
233    recipe.build.phases = build_cfg.phases.clone();
234    recipe.build.zig_version = build_cfg.zig_version.clone();
235    // `strip_debug` ENTRA EN `hash_inputs`: sin esta línea el receptor reconstruye con `None`,
236    // sella en otra dirección y el `expected_hash` anclado no coincide NUNCA. Ver la doc del campo
237    // en `swm::SwmBuild::strip_debug`.
238    recipe.build.strip_debug = build_cfg.strip_debug;
239    if let Some(n) = strip_components {
240        recipe.source.strip_components = n;
241    }
242    // `patches` ya viene como nombres relativos a `base_dir` (o rutas absolutas que generamos
243    // nosotros): el lab los resuelve con `base_dir.join(p)` UNA vez al construir. NO los re-unimos
244    // acá — hacerlo doblaba el prefijo `base_dir` y el join del build lo triplicaba (bug que el
245    // dogfood de `jq`, primer paquete con patch + build-dep instalado, destapó).
246    recipe.source.patches = patches;
247    // Deps por NOMBRE (de la receta original): el lab las resolverá como `{dep}.toml` bajo el
248    // `base_dir` — por eso base_dir = el catálogo (no "/"): los patches son absolutos y no lo
249    // necesitan, pero la resolución de build-deps SÍ. `install` puebla el catálogo con los
250    // `{dep}.toml` antes de construir; sin deps, el dir simplemente no se consulta.
251    recipe.deps = deps.clone();
252    // Evidencia de comportamiento (H1): viaja en el .swm y se reinyecta en la receta efímera para
253    // que el checker la vea al reproducir. No afecta el hash del artefacto (comportamiento ≠ identidad).
254    recipe.evidence = evidence.clone();
255    // Superficies del sistema (H4b): viajan en el .swm y se reinyectan en la receta efímera para
256    // que el gate de compatibilidad de `install` las evalúe. Como la evidencia, no afectan el hash.
257    recipe.slots = slots.clone();
258    recipe.base_dir = base_dir.to_path_buf();
259    Ok(recipe)
260}
261
262fn parse_compiler(s: &str) -> takana_core::Result<Compiler> {
263    match s {
264        "zig-cc" => Ok(Compiler::ZigCc),
265        "clang" => Ok(Compiler::Clang),
266        "gcc" => Ok(Compiler::Gcc),
267        other => Err(takana_core::Error::Recipe(format!(
268            "compiler desconocido: '{other}' (esperado zig-cc/clang/gcc)"
269        ))),
270    }
271}
272
273fn parse_link(s: &str) -> takana_core::Result<LinkMode> {
274    match s {
275        "static" => Ok(LinkMode::Static),
276        "dynamic" => Ok(LinkMode::Dynamic),
277        other => Err(takana_core::Error::Recipe(format!(
278            "link desconocido: '{other}' (esperado static/dynamic)"
279        ))),
280    }
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286
287    #[test]
288    fn slots_sobreviven_receta_a_swm_a_receta() {
289        // H4b: el gate de compatibilidad depende de que las superficies declaradas en la receta
290        // viajen intactas por el `.swm` y se recuperen al reproducir. Ejercita AMBAS direcciones
291        // del puente (Swm::from_recipe → recipe_from_source_patch).
292        let toml = r#"
293name = "tema-oscuro"
294version = "1.0"
295[source]
296repo = "git://x/tema.git"
297commit = "abc123"
298[build]
299compiler = "zig-cc"
300[slots.claims]
301logo = "b3:azul"
302[slots.requires]
303"wayland-protocol" = "b3:stock"
304"#;
305        let recipe = takana_core::Recipe::from_toml(toml).unwrap();
306        let swm =
307            takana_core::Swm::from_recipe(&recipe, "/usr/bin/tema", Vec::new(), None, "2026-06-06").unwrap();
308        // El .swm YAML debe llevar el bloque slots.
309        let yaml = swm.to_yaml().unwrap();
310        assert!(yaml.contains("slots"), "el .swm debe transportar slots: {yaml}");
311
312        // Vuelta: source_patch → Recipe recupera exactamente los slots.
313        let d = tempfile::tempdir().unwrap();
314        let mutation = swm
315            .mutations
316            .iter()
317            .find(|m| matches!(m, Mutation::SourcePatch { .. }))
318            .unwrap();
319        let back = recipe_from_source_patch(mutation, Some("tema-oscuro"), d.path()).unwrap();
320        assert_eq!(back.slots, recipe.slots);
321        assert_eq!(back.slots.claims.get("logo").unwrap(), "b3:azul");
322        assert_eq!(back.slots.requires.get("wayland-protocol").unwrap(), "b3:stock");
323    }
324
325    fn fake_swm_build() -> SwmBuild {
326        SwmBuild {
327            compiler: "zig-cc".into(),
328            target: "x86_64-linux-musl".into(),
329            link: "static".into(),
330            flags: vec!["--enable-foo".into()],
331            phases: Default::default(),
332            zig_version: None,
333            // Agregado en 9bc51889 («no viajaba en el paquete, y es ENTRADA DE HASH»): este
334            // constructor de test se quedó sin él y dejó al crate ENTERO sin compilar sus tests.
335            strip_debug: None,
336        }
337    }
338
339    #[test]
340    fn derive_name_from_target() {
341        assert_eq!(derive_name("/usr/bin/grep"), "grep");
342        assert_eq!(derive_name("/bin/ls"), "ls");
343        assert_eq!(derive_name("/"), "swm-bin");
344    }
345
346    #[test]
347    fn synthesize_recipe_basic() {
348        let d = tempfile::tempdir().unwrap();
349        let r = synthesize_recipe(
350            "grep",
351            &takana_core::SourceKind::Git {
352                repo: "git://example/grep.git",
353                commit: "a1b2c3d4e5f6a7b8c9",
354            },
355            &fake_swm_build(),
356            None,
357            &takana_core::Deps::default(),
358            &takana_core::Evidence::default(),
359            &takana_core::Slots::default(),
360            vec![],
361            d.path(),
362        )
363        .unwrap();
364        assert_eq!(r.name, "grep");
365        assert!(r.version.starts_with("swm-a1b2c3d4"));
366        assert_eq!(r.build.flags, vec!["--enable-foo"]);
367        match r.source.kind().unwrap() {
368            takana_core::SourceKind::Git { repo, commit } => {
369                assert_eq!(repo, "git://example/grep.git");
370                assert_eq!(commit, "a1b2c3d4e5f6a7b8c9");
371            }
372            _ => panic!("modo git esperado"),
373        }
374    }
375
376    #[test]
377    fn synthesize_recipe_tarball() {
378        let d = tempfile::tempdir().unwrap();
379        let r = synthesize_recipe(
380            "grep",
381            &takana_core::SourceKind::Tarball {
382                url: "https://ftp.gnu.org/gnu/grep/grep-3.11.tar.gz",
383                sha256: "abcdef0123456789",
384            },
385            &fake_swm_build(),
386            None,
387            &takana_core::Deps::default(),
388            &takana_core::Evidence::default(),
389            &takana_core::Slots::default(),
390            vec![],
391            d.path(),
392        )
393        .unwrap();
394        assert_eq!(r.name, "grep");
395        assert!(r.version.starts_with("swm-abcdef012345"));
396        match r.source.kind().unwrap() {
397            takana_core::SourceKind::Tarball { url, sha256 } => {
398                assert_eq!(url, "https://ftp.gnu.org/gnu/grep/grep-3.11.tar.gz");
399                assert_eq!(sha256, "abcdef0123456789");
400            }
401            _ => panic!("modo tarball esperado"),
402        }
403    }
404
405    /// Regresión del 2026-09-21: **una receta con VARIOS parches tiene que reproducir su hash**.
406    ///
407    /// `pack` concatenaba los N parches en el `patch` inline único, y `Recipe::hash_inputs` mete
408    /// **una entrada por parche** (con `of_inputs` length-prefijado) ⇒ el receptor sellaba en otra
409    /// dirección y el `expected_hash` anclado no coincidía NUNCA. Medido sobre `zsh` (6 parches):
410    /// anclado `b3:0be3630d…`, reproducido `b3:d6329a62…`. Afectaba a 16 recetas del corpus.
411    ///
412    /// El test compara los `hash_inputs` —no el hash final— porque así, cuando falle, dice *qué*
413    /// entrada se movió en vez de sólo que dos hex difieren. Lleva su **control negativo**: con
414    /// los parches concatenados (el formato viejo) los inputs TIENEN que diferir, o el test
415    /// pasaría por la razón equivocada.
416    #[test]
417    fn varios_parches_viajan_sueltos_y_el_hash_reproduce() {
418        let d = tempfile::tempdir().unwrap();
419        let recipes = d.path().join("recipes");
420        std::fs::create_dir_all(&recipes).unwrap();
421        std::fs::write(recipes.join("uno.patch"), b"--- a/uno\n+++ b/uno\n").unwrap();
422        std::fs::write(recipes.join("dos.patch"), b"--- a/dos\n+++ b/dos\n").unwrap();
423
424        let toml = r#"
425name    = "multi"
426version = "1.0"
427[source]
428tarball = "https://x/multi-1.0.tar.gz"
429sha256  = "abcdef0123456789"
430patches = ["uno.patch", "dos.patch"]
431[build]
432compiler = "zig-cc"
433target   = "x86_64-linux-musl"
434link     = "static"
435"#;
436        let mut original = Recipe::from_toml(toml).unwrap();
437        original.base_dir = recipes.clone();
438
439        let textos: Vec<String> = original
440            .source
441            .patches
442            .iter()
443            .map(|p| std::fs::read_to_string(recipes.join(p)).unwrap())
444            .collect();
445        let swm =
446            takana_core::Swm::from_recipe(&original, "/usr/bin/multi", textos.clone(), None, "dev")
447                .unwrap();
448
449        let catalog = d.path().join("swm-recipes");
450        let rehecha = recipe_from_source_patch(&swm.mutations[0], Some("multi"), &catalog).unwrap();
451        assert_eq!(
452            rehecha.source.patches.len(),
453            2,
454            "los parches tienen que llegar SUELTOS, no unidos: {:?}",
455            rehecha.source.patches
456        );
457
458        let lab = takana_core::lab::LabFingerprint::for_tests();
459        assert_eq!(
460            original.hash_inputs(&[], &lab).unwrap(),
461            rehecha.hash_inputs(&[], &lab).unwrap(),
462            "la receta rehecha desde el .swm debe hashear igual que la del corpus"
463        );
464
465        // Control negativo: el formato viejo (los dos parches en un solo `patch`) NO reproduce.
466        let Mutation::SourcePatch { tarball, sha256, build, deps, evidence, slots, .. } =
467            swm.mutations[0].clone()
468        else {
469            panic!("esperaba source_patch");
470        };
471        let concatenado = Mutation::SourcePatch {
472            repo: None,
473            commit: None,
474            tarball,
475            sha256,
476            strip_components: None,
477            patch: Some(textos.concat()),
478            patches: Vec::new(),
479            patch_url: None,
480            build,
481            target_bin: "/usr/bin/multi".into(),
482            expected_hash: None,
483            deps,
484            evidence,
485            slots,
486        };
487        let viejo = d.path().join("swm-recipes-viejo");
488        let rehecha_vieja =
489            recipe_from_source_patch(&concatenado, Some("multi"), &viejo).unwrap();
490        assert_ne!(
491            original.hash_inputs(&[], &lab).unwrap(),
492            rehecha_vieja.hash_inputs(&[], &lab).unwrap(),
493            "control: concatenar los parches TIENE que mover el hash (si no, este test no prueba nada)"
494        );
495    }
496
497    #[test]
498    fn inline_patch_resuelve_con_un_solo_join_de_base_dir() {
499        // Regresión: `source.patches` debe guardar el NOMBRE relativo a base_dir, de modo que el
500        // lab resuelva `base_dir.join(p)` UNA sola vez. El bug doblaba/triplicaba el prefijo
501        // `swm-recipes/` (lo destapó el dogfood de `jq`, primer paquete con patch instalado).
502        let d = tempfile::tempdir().unwrap();
503        let catalog = d.path().join("swm-recipes");
504        let m = Mutation::SourcePatch {
505            repo: None,
506            commit: None,
507            tarball: Some("https://x/jq-1.8.1.tar.gz".into()),
508            sha256: Some("2be64e7129cecb11d5906290eba10af694fb9e3e7f9fc208a311dc33ca837eb0".into()),
509            strip_components: None,
510            patch: Some("--- a\n+++ b\n".into()),
511            patches: Vec::new(),
512            patch_url: None,
513            build: fake_swm_build(),
514            target_bin: "/usr/bin/jq".into(),
515            expected_hash: None,
516            deps: Default::default(),
517            evidence: Default::default(),
518                slots: Default::default(),
519        };
520        let recipe = recipe_from_source_patch(&m, None, &catalog).unwrap();
521        assert_eq!(recipe.source.patches.len(), 1);
522        let p = &recipe.source.patches[0];
523        // El nombre NO debe llevar embebido el dir del catálogo (eso causaba el doble prefijo).
524        assert!(!p.contains("swm-recipes"), "patch debe ser relativo a base_dir, no llevar el catálogo: {p}");
525        // Y base_dir.join(p) — el ÚNICO join que hace el lab — debe dar el fichero real escrito.
526        let resolved = recipe.base_dir.join(p);
527        assert!(resolved.is_file(), "base_dir.join(patch) debe existir en un solo paso: {}", resolved.display());
528        assert_eq!(std::fs::read(&resolved).unwrap(), b"--- a\n+++ b\n");
529    }
530
531    #[test]
532    fn parse_compiler_and_link() {
533        assert_eq!(parse_compiler("zig-cc").unwrap(), Compiler::ZigCc);
534        assert!(parse_compiler("xlc").is_err());
535        assert_eq!(parse_link("static").unwrap(), LinkMode::Static);
536        assert!(parse_link("partial").is_err());
537    }
538
539    #[test]
540    fn rejects_non_source_patch_mutation() {
541        let m = Mutation::ConfigEdit {
542            file: "/etc/x".into(),
543            inline_diff: "- a\n+ b\n".into(),
544        };
545        let d = tempfile::tempdir().unwrap();
546        let store = Store::open(d.path().join("store")).unwrap();
547        let cfg = BuildConfig::defaults_for_store(store.root());
548        let err = build_source_patch(&m, &cfg, &store, None, None).unwrap_err().to_string();
549        assert!(err.contains("source_patch"), "{err}");
550    }
551
552    #[test]
553    fn patch_url_is_downloaded_before_build() {
554        // Servimos el patch por file:// (sin red). build_source_patch debe descargarlo y
555        // materializarlo en swm-recipes/<commit>.patch ANTES de fallar aguas abajo por no
556        // haber rootfs de build en este entorno. Eso prueba que el patch_url quedó cableado.
557        let d = tempfile::tempdir().unwrap();
558        let patch_src = d.path().join("remote.patch");
559        std::fs::write(&patch_src, b"--- a\n+++ b\n").unwrap();
560        let commit = "deadbeefcafe";
561        let m = Mutation::SourcePatch {
562            repo: Some("git://x".into()),
563            commit: Some(commit.into()),
564            tarball: None,
565            sha256: None,
566            strip_components: None,
567            patch: None,
568            patches: Vec::new(),
569            patch_url: Some(format!("file://{}", patch_src.display())),
570            build: fake_swm_build(),
571            target_bin: "/bin/x".into(),
572            expected_hash: None,
573            deps: Default::default(),
574            evidence: Default::default(),
575                slots: Default::default(),
576        };
577        let store = Store::open(d.path().join("store")).unwrap();
578        let cfg = BuildConfig::defaults_for_store(store.root());
579        let scratch = d.path().join("scratch");
580        // Falla (no hay rootfs), pero el patch ya debe estar descargado.
581        let _ = build_source_patch(&m, &cfg, &store, Some(&scratch), None);
582        // El patch se materializa como `{label}-{source_key}.patch` (label = derive_name del
583        // target_bin) para no colisionar entre deps en el catálogo.
584        let landed = scratch.join("swm-recipes").join(format!("x-{commit}.patch"));
585        assert!(landed.is_file(), "el patch remoto debió descargarse a {}", landed.display());
586        assert_eq!(std::fs::read(&landed).unwrap(), b"--- a\n+++ b\n");
587    }
588}