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 1407Shell 141Markdown 84Rust 802745 Definiciones

⎇ 339a7b07ef8d72e101fecfbd722c2f8feb8a7d1c crates/hammer-core/src/swm.rs Rust · 731 líneas · 26.7 KB Historia Quién tocó qué crudo

Etapa F paquetería #1: `hammer pack` — receta del corpus → paquete .swm (source_patch) 339a7b07e · sergio ·

1//! El manifiesto `.swm` (Software Mutación). Ver `docs/06-swm-format.md`.
2//!
3//! Es la unidad de intercambio: receta de transformación sobre fuente pública + ediciones de
4//! config. NUNCA transporta binarios cocidos (salvo `FileDrop` con hash declarado y, opcional,
5//! contenido inline base64 para `.swm`s autocontenidos en tests/escenarios offline).
6
7use std::collections::BTreeMap;
8
9use serde::{Deserialize, Serialize};
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct Swm {
13    pub swm_version: u32,
14    pub base: Base,
15    pub mutations: Vec<Mutation>,
16    #[serde(default, skip_serializing_if = "Option::is_none")]
17    pub signature: Option<Signature>,
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct Base {
22    pub distro_version: String,
23    #[serde(default)]
24    pub pins: BTreeMap<String, String>,
25}
26
27/// Referencia a la base **local** — lo que el sistema receptor tiene cocido. Se compara
28/// contra `Swm::base` en `verify_base`. La construye el receptor leyendo, p. ej.,
29/// `/etc/hammer/base.json`; en tests la fabricamos a mano.
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct BaseRef {
32    pub distro_version: String,
33    #[serde(default)]
34    pub pins: BTreeMap<String, String>,
35}
36
37impl From<&Base> for BaseRef {
38    fn from(b: &Base) -> Self {
39        BaseRef {
40            distro_version: b.distro_version.clone(),
41            pins: b.pins.clone(),
42        }
43    }
44}
45
46/// Resultado de `verify_base`. `Ok` ⇒ las pins del SWM coinciden con las locales (al menos
47/// para las llaves que el SWM declara) y la `distro_version` es idéntica. Cualquier
48/// discrepancia se materializa como variante con detalle: `apply` puede decidir abortar,
49/// el humano puede leer qué difiere.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub enum BaseCompat {
52    /// La base local satisface todas las pins declaradas por el SWM.
53    Ok,
54    /// La `distro_version` no coincide.
55    DistroMismatch { local: String, swm: String },
56    /// Una o más pins difieren entre la base local y el SWM. Cada entrada es
57    /// `(pin_name, local_value, swm_value)`; un `local_value` ausente significa que la
58    /// llave no existe en la base local.
59    PinMismatch(Vec<PinDiff>),
60}
61
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct PinDiff {
64    pub name: String,
65    pub local: Option<String>,
66    pub swm: String,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
70#[serde(tag = "type", rename_all = "snake_case")]
71pub enum Mutation {
72    SourcePatch {
73        // Origen git (modo histórico) **o** tarball — exactamente uno, igual que
74        // [`crate::recipe::Source`]. Antes sólo se modelaba git y los tarballs caían a
75        // `file_drop`; ahora `hammer export` los reconstruye como source_patch. Los campos
76        // son `Option` (con `serde(default)`) para que los `.swm` git existentes —que sólo
77        // traen `repo`+`commit`— sigan parseando sin cambios.
78        #[serde(default, skip_serializing_if = "Option::is_none")]
79        repo: Option<String>,
80        #[serde(default, skip_serializing_if = "Option::is_none")]
81        commit: Option<String>,
82        #[serde(default, skip_serializing_if = "Option::is_none")]
83        tarball: Option<String>,
84        #[serde(default, skip_serializing_if = "Option::is_none")]
85        sha256: Option<String>,
86        /// Componentes a recortar al extraer un tarball (modo tarball). `None` ⇒ el default
87        /// del lab (1, un único top-level `proyecto-version/`). Sólo se emite si difiere.
88        #[serde(default, skip_serializing_if = "Option::is_none")]
89        strip_components: Option<usize>,
90        #[serde(default, skip_serializing_if = "Option::is_none")]
91        patch: Option<String>,
92        #[serde(default, skip_serializing_if = "Option::is_none")]
93        patch_url: Option<String>,
94        build: SwmBuild,
95        target_bin: String,
96        #[serde(default, skip_serializing_if = "Option::is_none")]
97        expected_hash: Option<String>,
98    },
99    ConfigEdit {
100        file: String,
101        inline_diff: String,
102    },
103    InitRule {
104        action: String,
105        service: String,
106        command: String,
107    },
108    FileDrop {
109        path: String,
110        /// hash BLAKE3 del contenido, declarado para verificación.
111        content_hash: String,
112        /// Contenido base64 inline (RFC 4648, sin saltos de línea). Si está presente, el
113        /// receptor lo decodifica directamente; si no, debe usar `content_url`. Permite
114        /// `.swm`s autocontenidos sin red — útil para tests, snapshots offline, e
115        /// intercambio de pequeños binarios de datos (catálogos, listas) cuyo hash basta.
116        #[serde(default, skip_serializing_if = "Option::is_none")]
117        content_b64: Option<String>,
118        #[serde(default, skip_serializing_if = "Option::is_none")]
119        content_url: Option<String>,
120    },
121}
122
123#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct SwmBuild {
125    #[serde(default = "default_compiler")]
126    pub compiler: String,
127    #[serde(default = "default_target")]
128    pub target: String,
129    #[serde(default = "default_link")]
130    pub link: String,
131    #[serde(default)]
132    pub flags: Vec<String>,
133    /// Overrides explícitos de las fases del build (configure/compile/install). Sin esto un
134    /// `source_patch` sólo reproduciría builds heurísticos; con esto un paquete que viene de
135    /// una `Recipe` con install custom (p. ej. los multicall coreutils/findutils) se rehace
136    /// fiel. Vacío ⇒ heurística del lab, idéntico a una receta sin `[build.phases]`.
137    #[serde(default, skip_serializing_if = "crate::recipe::Phases::is_empty")]
138    pub phases: crate::recipe::Phases,
139    /// Versión de zig por-paquete (escotilla a la regresión de zig 0.14; ver `Build::zig_version`).
140    /// `None` ⇒ el zig por defecto del receptor.
141    #[serde(default, skip_serializing_if = "Option::is_none")]
142    pub zig_version: Option<String>,
143}
144
145#[derive(Debug, Clone, Serialize, Deserialize)]
146pub struct Signature {
147    pub by: String,
148    pub alg: String,
149    pub sig: String,
150}
151
152fn default_compiler() -> String {
153    "zig-cc".into()
154}
155fn default_target() -> String {
156    "x86_64-linux-musl".into()
157}
158fn default_link() -> String {
159    "static".into()
160}
161
162impl Swm {
163    pub fn from_yaml(s: &str) -> crate::Result<Swm> {
164        serde_yaml::from_str(s).map_err(|e| crate::Error::Serde(e.to_string()))
165    }
166
167    pub fn to_yaml(&self) -> crate::Result<String> {
168        serde_yaml::to_string(self).map_err(|e| crate::Error::Serde(e.to_string()))
169    }
170
171    /// Sanity checks **estructurales** del manifiesto. Lo que aquí se valida no depende del
172    /// sistema local — sólo del documento. Llamarlo antes de `verify_base` evita malgastar
173    /// pasadas sobre un `.swm` mal formado.
174    pub fn verify_schema(&self) -> crate::Result<()> {
175        if self.swm_version != 1 {
176            return Err(crate::Error::Serde(format!(
177                "swm_version no soportada: {} (soportada: 1)",
178                self.swm_version
179            )));
180        }
181        if self.base.distro_version.is_empty() {
182            return Err(crate::Error::Serde(
183                "base.distro_version vacía".into(),
184            ));
185        }
186        for (i, m) in self.mutations.iter().enumerate() {
187            m.verify_schema().map_err(|e| {
188                crate::Error::Serde(format!("mutations[{i}]: {e}"))
189            })?;
190        }
191        Ok(())
192    }
193
194    /// Compara la base del `.swm` con la del sistema local. Una pin del SWM **debe** estar
195    /// presente en la base local con el mismo valor. Las pins extra de la base local (no
196    /// declaradas por el SWM) no son problema — el SWM sólo declara lo que necesita.
197    pub fn verify_base(&self, local: &BaseRef) -> BaseCompat {
198        if self.base.distro_version != local.distro_version {
199            return BaseCompat::DistroMismatch {
200                local: local.distro_version.clone(),
201                swm: self.base.distro_version.clone(),
202            };
203        }
204        let mut diffs = Vec::new();
205        for (name, swm_val) in &self.base.pins {
206            match local.pins.get(name) {
207                Some(local_val) if local_val == swm_val => {}
208                Some(local_val) => diffs.push(PinDiff {
209                    name: name.clone(),
210                    local: Some(local_val.clone()),
211                    swm: swm_val.clone(),
212                }),
213                None => diffs.push(PinDiff {
214                    name: name.clone(),
215                    local: None,
216                    swm: swm_val.clone(),
217                }),
218            }
219        }
220        if diffs.is_empty() {
221            BaseCompat::Ok
222        } else {
223            BaseCompat::PinMismatch(diffs)
224        }
225    }
226
227    /// Construye un `.swm` de un único `source_patch` a partir de una [`crate::Recipe`] del
228    /// corpus — la dirección **forward** que cierra la paquetería (Etapa F): una receta que el
229    /// sistema YA sabe construir se vuelve un paquete distribuible y reproducible-desde-fuente.
230    /// Es la inversa de `hammer_build::swm_bridge` (que va `source_patch` → `Recipe` → build).
231    ///
232    /// `hammer-core` no toca disco: el caller resuelve y **lee** los patches de la receta
233    /// (relativos a `recipe.base_dir`) y pasa su texto ya concatenado en `patch_text`. El
234    /// `expected_hash`, si se da, ancla "verificar, no confiar" (el receptor rehace y compara).
235    pub fn from_recipe(
236        recipe: &crate::Recipe,
237        target_bin: impl Into<String>,
238        patch_text: Option<String>,
239        expected_hash: Option<String>,
240        distro_version: impl Into<String>,
241    ) -> crate::Result<Swm> {
242        use crate::recipe::SourceKind;
243
244        // strip_components sólo viaja si difiere del default del lab (1) y es modo tarball.
245        let strip_components = match recipe.source.kind()? {
246            SourceKind::Tarball { .. } if recipe.source.strip_components != 1 => {
247                Some(recipe.source.strip_components)
248            }
249            _ => None,
250        };
251        let (repo, commit, tarball, sha256) = match recipe.source.kind()? {
252            SourceKind::Git { repo, commit } => {
253                (Some(repo.to_string()), Some(commit.to_string()), None, None)
254            }
255            SourceKind::Tarball { url, sha256 } => {
256                (None, None, Some(url.to_string()), Some(sha256.to_string()))
257            }
258        };
259
260        let build = SwmBuild {
261            compiler: recipe.build.compiler.as_str().to_string(),
262            target: recipe.build.target.clone(),
263            link: recipe.build.link.as_str().to_string(),
264            flags: recipe.build.flags.clone(),
265            phases: recipe.build.phases.clone(),
266            zig_version: recipe.build.zig_version.clone(),
267        };
268
269        let swm = Swm {
270            swm_version: 1,
271            base: Base {
272                distro_version: distro_version.into(),
273                pins: BTreeMap::new(),
274            },
275            mutations: vec![Mutation::SourcePatch {
276                repo,
277                commit,
278                tarball,
279                sha256,
280                strip_components,
281                patch: patch_text,
282                patch_url: None,
283                build,
284                target_bin: target_bin.into(),
285                expected_hash,
286            }],
287            signature: None,
288        };
289        swm.verify_schema()?;
290        Ok(swm)
291    }
292}
293
294/// Resuelve el modo de origen de un `source_patch` (git **xor** tarball) desde sus campos
295/// opcionales, con la misma regla que [`crate::recipe::Source::kind`]. Centraliza la
296/// validación para que `verify_schema`, el lab y el export compartan una sola fuente de verdad.
297pub fn swm_source_kind<'a>(
298    repo: Option<&'a str>,
299    commit: Option<&'a str>,
300    tarball: Option<&'a str>,
301    sha256: Option<&'a str>,
302) -> Result<crate::recipe::SourceKind<'a>, String> {
303    use crate::recipe::SourceKind;
304    match (repo, commit, tarball, sha256) {
305        (Some(repo), Some(commit), None, None) => Ok(SourceKind::Git { repo, commit }),
306        (None, None, Some(url), Some(sha256)) => Ok(SourceKind::Tarball { url, sha256 }),
307        (Some(_), Some(_), Some(_), _) | (Some(_), Some(_), _, Some(_)) => {
308            Err("source_patch: usa repo+commit O tarball+sha256, no ambos".into())
309        }
310        _ => Err("source_patch: faltan campos; necesito (repo+commit) o (tarball+sha256)".into()),
311    }
312}
313
314impl Mutation {
315    /// Sanity por mutación. Cada `type` tiene precondiciones distintas; la validación cruzada
316    /// (p. ej. "el `target_bin` apunta a un dir gestionado") la decide quien aplica, no el
317    /// schema.
318    pub fn verify_schema(&self) -> Result<(), String> {
319        use crate::recipe::SourceKind;
320        match self {
321            Mutation::SourcePatch {
322                repo, commit, tarball, sha256, patch, patch_url, target_bin, ..
323            } => {
324                match swm_source_kind(
325                    repo.as_deref(),
326                    commit.as_deref(),
327                    tarball.as_deref(),
328                    sha256.as_deref(),
329                )? {
330                    SourceKind::Git { repo, commit } => {
331                        if repo.is_empty() {
332                            return Err("source_patch: 'repo' vacío".into());
333                        }
334                        if commit.is_empty() {
335                            return Err("source_patch: 'commit' vacío".into());
336                        }
337                    }
338                    SourceKind::Tarball { url, sha256 } => {
339                        if url.is_empty() {
340                            return Err("source_patch: 'tarball' vacío".into());
341                        }
342                        if sha256.is_empty() {
343                            return Err("source_patch: 'sha256' vacío".into());
344                        }
345                    }
346                }
347                if patch.is_some() && patch_url.is_some() {
348                    return Err(
349                        "source_patch: usa 'patch' (inline) O 'patch_url', no ambos".into(),
350                    );
351                }
352                if !target_bin.starts_with('/') {
353                    return Err(format!(
354                        "source_patch: target_bin debe ser ruta absoluta, no '{target_bin}'"
355                    ));
356                }
357                Ok(())
358            }
359            Mutation::ConfigEdit { file, inline_diff } => {
360                if !file.starts_with('/') {
361                    return Err(format!("config_edit: file debe ser absoluto, no '{file}'"));
362                }
363                if inline_diff.is_empty() {
364                    return Err("config_edit: inline_diff vacío".into());
365                }
366                Ok(())
367            }
368            Mutation::InitRule { action, service, command } => {
369                if action.is_empty() || service.is_empty() || command.is_empty() {
370                    return Err("init_rule: action/service/command no pueden estar vacíos".into());
371                }
372                Ok(())
373            }
374            Mutation::FileDrop { path, content_hash, content_b64, content_url } => {
375                if !path.starts_with('/') {
376                    return Err(format!("file_drop: path debe ser absoluto, no '{path}'"));
377                }
378                if content_hash.is_empty() {
379                    return Err("file_drop: content_hash vacío".into());
380                }
381                if content_b64.is_none() && content_url.is_none() {
382                    return Err(
383                        "file_drop: hace falta 'content_b64' (inline) o 'content_url'".into(),
384                    );
385                }
386                if content_b64.is_some() && content_url.is_some() {
387                    return Err(
388                        "file_drop: usa 'content_b64' O 'content_url', no ambos".into(),
389                    );
390                }
391                Ok(())
392            }
393        }
394    }
395}
396
397#[cfg(test)]
398mod tests {
399    use super::*;
400
401    #[test]
402    fn roundtrip_yaml() {
403        let yaml = r#"
404swm_version: 1
405base:
406  distro_version: "2026-06-06"
407  pins:
408    grep: "a1b2c3d"
409mutations:
410  - type: config_edit
411    file: "/etc/network.conf"
412    inline_diff: |
413      - DHCP=yes
414      + IP=192.168.1.100
415"#;
416        let swm = Swm::from_yaml(yaml).expect("parse");
417        assert_eq!(swm.swm_version, 1);
418        assert_eq!(swm.mutations.len(), 1);
419        let back = swm.to_yaml().expect("serialize");
420        assert!(back.contains("config_edit"));
421    }
422
423    fn base_v(distro: &str, pins: &[(&str, &str)]) -> Base {
424        Base {
425            distro_version: distro.into(),
426            pins: pins.iter().map(|(k, v)| ((*k).into(), (*v).into())).collect(),
427        }
428    }
429    fn baseref_v(distro: &str, pins: &[(&str, &str)]) -> BaseRef {
430        BaseRef {
431            distro_version: distro.into(),
432            pins: pins.iter().map(|(k, v)| ((*k).into(), (*v).into())).collect(),
433        }
434    }
435
436    #[test]
437    fn verify_base_happy() {
438        let swm = Swm {
439            swm_version: 1,
440            base: base_v("2026-06-06", &[("grep", "a1b2")]),
441            mutations: vec![],
442            signature: None,
443        };
444        // Pins extra en local están bien.
445        let local = baseref_v("2026-06-06", &[("grep", "a1b2"), ("musl", "deadbeef")]);
446        assert_eq!(swm.verify_base(&local), BaseCompat::Ok);
447    }
448
449    #[test]
450    fn verify_base_distro_mismatch() {
451        let swm = Swm {
452            swm_version: 1,
453            base: base_v("2026-06-06", &[]),
454            mutations: vec![],
455            signature: None,
456        };
457        let local = baseref_v("2026-06-07", &[]);
458        assert_eq!(
459            swm.verify_base(&local),
460            BaseCompat::DistroMismatch {
461                local: "2026-06-07".into(),
462                swm: "2026-06-06".into(),
463            }
464        );
465    }
466
467    #[test]
468    fn verify_base_pin_mismatch_and_missing() {
469        let swm = Swm {
470            swm_version: 1,
471            base: base_v("2026-06-06", &[("grep", "a1b2"), ("musl", "FFFF")]),
472            mutations: vec![],
473            signature: None,
474        };
475        let local = baseref_v("2026-06-06", &[("grep", "OTRO")]); // musl no existe
476        let got = swm.verify_base(&local);
477        match got {
478            BaseCompat::PinMismatch(diffs) => {
479                assert_eq!(diffs.len(), 2);
480                let grep = diffs.iter().find(|d| d.name == "grep").unwrap();
481                assert_eq!(grep.local.as_deref(), Some("OTRO"));
482                assert_eq!(grep.swm, "a1b2");
483                let musl = diffs.iter().find(|d| d.name == "musl").unwrap();
484                assert!(musl.local.is_none());
485            }
486            other => panic!("esperaba PinMismatch, obtuve {other:?}"),
487        }
488    }
489
490    #[test]
491    fn verify_schema_rejects_bad_version() {
492        let swm = Swm {
493            swm_version: 9,
494            base: base_v("x", &[]),
495            mutations: vec![],
496            signature: None,
497        };
498        let err = swm.verify_schema().unwrap_err().to_string();
499        assert!(err.contains("swm_version"), "{err}");
500    }
501
502    #[test]
503    fn verify_schema_rejects_relative_path() {
504        let swm = Swm {
505            swm_version: 1,
506            base: base_v("x", &[]),
507            mutations: vec![Mutation::ConfigEdit {
508                file: "etc/network.conf".into(),
509                inline_diff: "- a\n+ b\n".into(),
510            }],
511            signature: None,
512        };
513        let err = swm.verify_schema().unwrap_err().to_string();
514        assert!(err.contains("absoluto"), "{err}");
515    }
516
517    #[test]
518    fn verify_schema_filedrop_needs_content_source() {
519        let swm = Swm {
520            swm_version: 1,
521            base: base_v("x", &[]),
522            mutations: vec![Mutation::FileDrop {
523                path: "/var/lib/x".into(),
524                content_hash: "b3:xxx".into(),
525                content_b64: None,
526                content_url: None,
527            }],
528            signature: None,
529        };
530        let err = swm.verify_schema().unwrap_err().to_string();
531        assert!(err.contains("content_b64") || err.contains("content_url"), "{err}");
532    }
533
534    #[test]
535    fn verify_schema_filedrop_rejects_both_inline_and_url() {
536        let swm = Swm {
537            swm_version: 1,
538            base: base_v("x", &[]),
539            mutations: vec![Mutation::FileDrop {
540                path: "/var/lib/x".into(),
541                content_hash: "b3:xxx".into(),
542                content_b64: Some("AA==".into()),
543                content_url: Some("https://x".into()),
544            }],
545            signature: None,
546        };
547        assert!(swm.verify_schema().is_err());
548    }
549
550    #[test]
551    fn verify_schema_source_patch_basic() {
552        let swm = Swm {
553            swm_version: 1,
554            base: base_v("x", &[]),
555            mutations: vec![Mutation::SourcePatch {
556                repo: Some("git://x".into()),
557                commit: Some("abc".into()),
558                tarball: None,
559                sha256: None,
560                strip_components: None,
561                patch: None,
562                patch_url: None,
563                build: SwmBuild {
564                    compiler: "zig-cc".into(),
565                    target: "x86_64-linux-musl".into(),
566                    link: "static".into(),
567                    flags: vec![],
568                    phases: Default::default(),
569                    zig_version: None,
570                },
571                target_bin: "/bin/x".into(),
572                expected_hash: None,
573            }],
574            signature: None,
575        };
576        swm.verify_schema().unwrap();
577    }
578
579    #[test]
580    fn verify_schema_source_patch_tarball() {
581        let swm = Swm {
582            swm_version: 1,
583            base: base_v("x", &[]),
584            mutations: vec![Mutation::SourcePatch {
585                repo: None,
586                commit: None,
587                tarball: Some("https://ftp.gnu.org/gnu/grep/grep-3.11.tar.gz".into()),
588                sha256: Some("deadbeef".into()),
589                strip_components: None,
590                patch: None,
591                patch_url: None,
592                build: SwmBuild {
593                    compiler: "zig-cc".into(),
594                    target: "x86_64-linux-musl".into(),
595                    link: "static".into(),
596                    flags: vec![],
597                    phases: Default::default(),
598                    zig_version: None,
599                },
600                target_bin: "/bin/grep".into(),
601                expected_hash: None,
602            }],
603            signature: None,
604        };
605        swm.verify_schema().unwrap();
606    }
607
608    #[test]
609    fn verify_schema_source_patch_rechaza_git_y_tarball_mezclados() {
610        let m = Mutation::SourcePatch {
611            repo: Some("git://x".into()),
612            commit: Some("abc".into()),
613            tarball: Some("https://x/a.tar.gz".into()),
614            sha256: Some("deadbeef".into()),
615            strip_components: None,
616            patch: None,
617            patch_url: None,
618            build: SwmBuild {
619                compiler: "zig-cc".into(),
620                target: "x86_64-linux-musl".into(),
621                link: "static".into(),
622                flags: vec![],
623                phases: Default::default(),
624                zig_version: None,
625            },
626            target_bin: "/bin/x".into(),
627            expected_hash: None,
628        };
629        let err = m.verify_schema().unwrap_err();
630        assert!(err.contains("no ambos"), "mensaje inesperado: {err}");
631    }
632
633    #[test]
634    fn from_recipe_git_preserva_build_phases_y_patch() {
635        // Receta git con install custom, flags, zig_version → el paquete debe reproducir todo.
636        let toml = r#"
637name = "ripgrep"
638version = "14.1.1"
639[source]
640repo   = "https://github.com/BurntSushi/ripgrep"
641commit = "4649aa9700619f94cf9c66876e9549d83420e16c"
642[build]
643compiler = "zig-cc"
644target   = "x86_64-linux-musl"
645link     = "static"
646flags    = ["--bin", "rg"]
647[build.phases]
648install = "cp target/release/rg /out/usr/bin/rg"
649"#;
650        let recipe = crate::Recipe::from_toml(toml).unwrap();
651        let swm = Swm::from_recipe(
652            &recipe,
653            "/usr/bin/rg",
654            Some("PATCH-TEXT".into()),
655            Some("b3:cafe".into()),
656            "2026-06-21",
657        )
658        .unwrap();
659        assert_eq!(swm.base.distro_version, "2026-06-21");
660        match &swm.mutations[0] {
661            Mutation::SourcePatch {
662                repo, commit, tarball, patch, build, target_bin, expected_hash, strip_components, ..
663            } => {
664                assert_eq!(repo.as_deref(), Some("https://github.com/BurntSushi/ripgrep"));
665                assert_eq!(commit.as_deref(), Some("4649aa9700619f94cf9c66876e9549d83420e16c"));
666                assert!(tarball.is_none());
667                assert_eq!(patch.as_deref(), Some("PATCH-TEXT"));
668                assert_eq!(target_bin, "/usr/bin/rg");
669                assert_eq!(expected_hash.as_deref(), Some("b3:cafe"));
670                assert!(strip_components.is_none(), "git no lleva strip_components");
671                assert_eq!(build.compiler, "zig-cc");
672                assert_eq!(build.link, "static");
673                assert_eq!(build.flags, vec!["--bin".to_string(), "rg".to_string()]);
674                assert_eq!(build.phases.install.as_deref(), Some("cp target/release/rg /out/usr/bin/rg"));
675            }
676            other => panic!("esperaba SourcePatch, obtuve {other:?}"),
677        }
678    }
679
680    #[test]
681    fn from_recipe_tarball_lleva_zig_version_y_strip() {
682        let toml = r#"
683name = "openssl"
684version = "3.5.4"
685[source]
686tarball = "https://example/openssl-3.5.4.tar.gz"
687sha256  = "deadbeef"
688strip_components = 2
689[build]
690compiler = "zig-cc"
691zig_version = "0.13.0"
692"#;
693        let recipe = crate::Recipe::from_toml(toml).unwrap();
694        let swm = Swm::from_recipe(&recipe, "/usr/bin/openssl", None, None, "dev").unwrap();
695        match &swm.mutations[0] {
696            Mutation::SourcePatch { tarball, sha256, strip_components, build, expected_hash, .. } => {
697                assert_eq!(tarball.as_deref(), Some("https://example/openssl-3.5.4.tar.gz"));
698                assert_eq!(sha256.as_deref(), Some("deadbeef"));
699                assert_eq!(*strip_components, Some(2), "strip != 1 debe viajar");
700                assert_eq!(build.zig_version.as_deref(), Some("0.13.0"));
701                assert!(expected_hash.is_none());
702            }
703            other => panic!("esperaba SourcePatch, obtuve {other:?}"),
704        }
705    }
706
707    #[test]
708    fn verify_schema_source_patch_rechaza_sin_origen() {
709        let m = Mutation::SourcePatch {
710            repo: None,
711            commit: None,
712            tarball: None,
713            sha256: None,
714            strip_components: None,
715            patch: None,
716            patch_url: None,
717            build: SwmBuild {
718                compiler: "zig-cc".into(),
719                target: "x86_64-linux-musl".into(),
720                link: "static".into(),
721                flags: vec![],
722                phases: Default::default(),
723                zig_version: None,
724            },
725            target_bin: "/bin/x".into(),
726            expected_hash: None,
727        };
728        let err = m.verify_schema().unwrap_err();
729        assert!(err.contains("faltan campos"), "mensaje inesperado: {err}");
730    }
731}