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 1430Shell 145Markdown 87Rust 852973 Sutichaykuna

⎇ db84ee6647cd8a890298f76ff1e03258ffe42091 crates/hammer-cli/src/alpine_import.rs Rust · 364 siq'ikuna · 14.7 KB Ñawpa willay Pi imata llamkarqan ch'uya

Etapa G: tanda C tier-2 — import-batch PREFER=alpine + importador separa runtime depends db84ee664 · sergio ·

1//! Importador `Alpine APKBUILD → receta hammer` (Etapa G, fuente #2).
2//!
3//! **Por qué Alpine y no sólo nix:** Alpine ya porta miles de paquetes a **musl** (y a menudo
4//! estático), CON los parches de portabilidad. Eso es justo lo que un import de nixpkgs PIERDE (los
5//! parches de nix viven aparte y asumen glibc). Por eso Alpine es la mejor respuesta a "¿qué si el
6//! build falla en musl?": su APKBUILD trae el `source=` con los `.patch` que lo hacen compilar.
7//!
8//! Este importador PARSEA el APKBUILD (no lo ejecuta — es shell; sourcearlo correría código ajeno)
9//! y extrae: pkgname/pkgver, la URL de la fuente, **los `.patch` (los carga a `source.patches`)**,
10//! makedepends → deps, y `build()/package()` → fases (best-effort: son shell de abuild con CHOST/
11//! --shared/etc. ⇒ necesitan adaptación al lab estático de hammer). Es un PUNTO DE PARTIDA, pero
12//! uno que YA incluye el trabajo de musl — mucho más cerca de compilar que un import crudo.
13
14use std::collections::BTreeMap;
15
16/// Convierte el texto de un APKBUILD en el TOML de una receta hammer.
17pub fn recipe_from_apkbuild(text: &str) -> Result<String, String> {
18    let vars = parse_vars(text);
19    let get = |k: &str| vars.get(k).cloned().unwrap_or_default();
20
21    let pkgname = get("pkgname");
22    if pkgname.is_empty() {
23        return Err("APKBUILD sin pkgname".into());
24    }
25    let pkgver = get("pkgver");
26    let version = if pkgver.is_empty() { "0".into() } else { pkgver };
27
28    // source=: tokens separados por espacios/saltos. Cada uno: `[name::]url-o-fichero`.
29    // El primero con esquema (://) es el tarball; los `.patch` se cargan como parches.
30    let mut tarball = String::new();
31    let mut patches: Vec<String> = Vec::new();
32    for tok in get("source").split_whitespace() {
33        let (name, url) = match tok.split_once("::") {
34            Some((n, u)) => (n.to_string(), Some(u.to_string())),
35            None => {
36                let base = tok.rsplit('/').next().unwrap_or(tok).to_string();
37                let url = if tok.contains("://") { Some(tok.to_string()) } else { None };
38                (base, url)
39            }
40        };
41        if name.ends_with(".patch") || name.ends_with(".diff") {
42            patches.push(name);
43        } else if tarball.is_empty() {
44            if let Some(u) = url {
45                tarball = u;
46            }
47        }
48    }
49
50    let source_block = if tarball.is_empty() {
51        // Sin tarball (paquetes git-based usan $_commit + snapshot); dejamos un FIXME claro.
52        "# FIXME: no detecté tarball; fijá `tarball+sha256` o `repo+commit` a mano\ntarball = \"\"\nsha256  = \"\"\n".to_string()
53    } else {
54        let sha512 = first_sha(&get("sha512sums"));
55        format!(
56            "tarball = \"{tarball}\"\n\
57             # FIXME sha256: el wrapper lo calcula (Alpine publica sha512). sha512 de Alpine:\n\
58             # sha512 = \"{sha512}\"\n\
59             sha256  = \"FIXME-sha256\"\n"
60        )
61    };
62
63    // build-deps: SÓLO `makedepends`. El `depends` de abuild es RUNTIME (p.ej. gzip depends="less"
64    // para `zless`) — no hace falta para compilar y rompería `hammer build` (busca recipes/less.toml).
65    // Lo emitimos como comentario de provenance, no como `[deps] build`. (checkdepends ni se lee: no
66    // corremos la suite de tests del paquete.)
67    let self_prefix = format!("{pkgname}-");
68    let normalize = |src: &str| -> Vec<String> {
69        let mut v: Vec<String> = Vec::new();
70        for d in src.split_whitespace() {
71            let d = d.trim();
72            // Saltamos: negados (!x), variables sin expandir ($x), pins versionados a subpaquetes
73            // (`foo=1.2-r3`), y auto-referencias al propio paquete/subpaquetes (`pkgname-...`).
74            if d.is_empty()
75                || d.starts_with('!')
76                || d.starts_with('$')
77                || d.contains('=')
78                || d.starts_with(&self_prefix)
79                || d == pkgname
80            {
81                continue;
82            }
83            // Alpine usa `foo-dev`, `cmd:make`, `pc:libfoo`, `so:libbar.so` — normalizamos lo común.
84            let name = d
85                .strip_prefix("cmd:")
86                .or_else(|| d.strip_prefix("pc:"))
87                .unwrap_or(d)
88                .trim_end_matches("-dev")
89                .to_string();
90            if !name.is_empty() && !name.starts_with("so:") && !v.contains(&name) {
91                v.push(name);
92            }
93        }
94        v
95    };
96    let deps = normalize(&get("makedepends"));
97    let runtime = normalize(&get("depends"));
98    let mut deps_block = String::new();
99    if !runtime.is_empty() {
100        deps_block.push_str(&format!("\n# depends de runtime de Alpine (NO build-deps): {}\n", runtime.join(", ")));
101    }
102    if !deps.is_empty() {
103        let list = deps.iter().map(|d| format!("\"{d}\"")).collect::<Vec<_>>().join(", ");
104        deps_block.push_str(&format!("\n[deps]\nbuild = [{list}]\n"));
105    }
106
107    // build()/package() → fases (best-effort, comentadas como abuild-shell a adaptar).
108    let phases_block = {
109        let compile = func_body(text, "build");
110        let install = func_body(text, "package");
111        if compile.is_none() && install.is_none() {
112            String::new()
113        } else {
114            let mut s = String::from("\n[build.phases]\n");
115            if let Some(b) = compile {
116                let t = translate_abuild(&b, &pkgname, &version);
117                s.push_str(&format!("# de build() de Alpine (traducido; el lab provee $CBUILD/$CHOST — Etapa G Fase 3; revisá --shared para estático):\ncompile = {}\n", toml_multiline(&t)));
118            }
119            if let Some(p) = install {
120                let t = translate_abuild(&p, &pkgname, &version);
121                s.push_str(&format!("# de package() de Alpine (traducido $pkgdir→/out):\ninstall = {}\n", toml_multiline(&t)));
122            }
123            s
124        }
125    };
126
127    let patches_line = if patches.is_empty() {
128        String::new()
129    } else {
130        let list = patches.iter().map(|p| format!("\"{p}\"")).collect::<Vec<_>>().join(", ");
131        format!("patches = [{list}]\n")
132    };
133
134    let toml = format!(
135        "# Importada de Alpine aports por `hammer import-alpine` (Etapa G). PUNTO DE PARTIDA — pero\n\
136         # YA trae los parches de musl de Alpine (lo que un import de nix pierde). Pendiente: el\n\
137         # sha256 del tarball (el wrapper lo calcula), y adaptar build/install del shell de abuild.\n\
138         name    = \"{pkgname}\"\n\
139         version = \"{version}\"\n\
140         \n\
141         [source]\n\
142         {source_block}{patches_line}\
143         \n\
144         [build]\n\
145         compiler = \"zig-cc\"\n\
146         target   = \"x86_64-linux-musl\"\n\
147         link     = \"static\"\n\
148         flags    = []\n\
149         {phases_block}{deps_block}"
150    );
151    Ok(toml)
152}
153
154/// Recoge asignaciones `clave=valor` y `clave="...multilínea..."` del APKBUILD (ignora funciones y
155/// comentarios). Suficiente para los campos de metadatos; expande `$var`/`${var}` conocidas.
156fn parse_vars(text: &str) -> BTreeMap<String, String> {
157    let mut raw: BTreeMap<String, String> = BTreeMap::new();
158    let bytes: Vec<&str> = text.lines().collect();
159    let mut i = 0;
160    while i < bytes.len() {
161        let line = bytes[i];
162        let trimmed = line.trim_start();
163        // Asignación simple `key=...` al inicio de línea (no dentro de función: heurística por '=').
164        if let Some(eq) = simple_assign(trimmed) {
165            let key = trimmed[..eq].to_string();
166            let mut val = trimmed[eq + 1..].to_string();
167            // Valor multilínea entre comillas dobles: acumular hasta cerrar.
168            if let Some(rest) = val.strip_prefix('"') {
169                if let Some(end) = rest.find('"') {
170                    val = rest[..end].to_string();
171                } else {
172                    let mut acc = rest.to_string();
173                    i += 1;
174                    while i < bytes.len() {
175                        if let Some(end) = bytes[i].find('"') {
176                            acc.push('\n');
177                            acc.push_str(&bytes[i][..end]);
178                            break;
179                        } else {
180                            acc.push('\n');
181                            acc.push_str(bytes[i]);
182                        }
183                        i += 1;
184                    }
185                    val = acc;
186                }
187            } else {
188                // valor sin comillas: hasta fin de línea, sin comentario.
189                val = val.split('#').next().unwrap_or("").trim().to_string();
190                val = val.trim_matches('\'').to_string();
191            }
192            raw.insert(key, val);
193        }
194        i += 1;
195    }
196    // Expansión de variables (varias pasadas para anidamiento simple).
197    let keys: Vec<String> = raw.keys().cloned().collect();
198    for _ in 0..3 {
199        for k in &keys {
200            let mut v = raw[k].clone();
201            for k2 in &keys {
202                let val2 = raw[k2].clone();
203                v = v.replace(&format!("${{{k2}}}"), &val2).replace(&format!("${k2}"), &val2);
204            }
205            raw.insert(k.clone(), v);
206        }
207    }
208    raw
209}
210
211/// `Some(pos_del_=)` si la línea es una asignación `nombre=...` válida (nombre shell). Evita
212/// confundir `foo() {`, comparaciones, o `export X=Y`.
213fn simple_assign(line: &str) -> Option<usize> {
214    let eq = line.find('=')?;
215    if eq == 0 {
216        return None;
217    }
218    let name = &line[..eq];
219    if name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') && name.chars().next()?.is_ascii_alphabetic() || name.starts_with('_') {
220        // siguiente char no debe ser '=' (==) ni la asignación es vacía rara.
221        if line.as_bytes().get(eq + 1) == Some(&b'=') {
222            return None;
223        }
224        return Some(eq);
225    }
226    None
227}
228
229/// Extrae el cuerpo de una función shell `name() { ... }`: el texto entre la primera `{` y la `}`
230/// que la cierra a nivel 0. Best-effort (no parsea shell de verdad; cuenta llaves).
231fn func_body(text: &str, name: &str) -> Option<String> {
232    let pat_a = format!("{name}()");
233    let start = text.find(&pat_a)?;
234    let brace = text[start..].find('{')? + start;
235    let mut depth = 0i32;
236    let mut end = None;
237    for (idx, ch) in text[brace..].char_indices() {
238        match ch {
239            '{' => depth += 1,
240            '}' => {
241                depth -= 1;
242                if depth == 0 {
243                    end = Some(brace + idx);
244                    break;
245                }
246            }
247            _ => {}
248        }
249    }
250    let body = text[brace + 1..end?].trim().to_string();
251    if body.is_empty() {
252        None
253    } else {
254        Some(body)
255    }
256}
257
258/// Traduce las variables de abuild a su equivalente en el lab de hammer. Substituciones SEGURAS
259/// (inequívocas): `$pkgdir`→`/out` (el DESTDIR del lab), `$pkgname`→nombre, `$pkgver`→versión.
260/// NO toca `$CBUILD/$CHOST/$srcdir/$builddir` ni `--shared`. `$CBUILD/$CHOST` los RESUELVE el lab en
261/// runtime (env con el triple nativo saneado, Etapa G Fase 3 — `hammer-build`); `$srcdir/$builddir` y
262/// `--shared` quedan para el humano. `make DESTDIR="$pkgdir"` → `make DESTDIR="/out"`.
263fn translate_abuild(body: &str, name: &str, version: &str) -> String {
264    body.replace("${pkgdir}", "/out")
265        .replace("$pkgdir", "/out")
266        .replace("${pkgname}", name)
267        .replace("$pkgname", name)
268        .replace("${pkgver}", version)
269        .replace("$pkgver", version)
270}
271
272/// Primer hash de un bloque `sha512sums="<hash>  <fichero>\n..."`.
273fn first_sha(block: &str) -> String {
274    block
275        .split_whitespace()
276        .next()
277        .unwrap_or("")
278        .to_string()
279}
280
281/// Serializa un cuerpo multilínea como string TOML (literal multi-línea `'''…'''` si tiene saltos).
282fn toml_multiline(s: &str) -> String {
283    if s.contains('\n') {
284        format!("'''\n{s}\n'''")
285    } else {
286        format!("\"{}\"", s.replace('"', "\\\""))
287    }
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293
294    const APKBUILD_PATCHED: &str = r#"
295pkgname=foo
296pkgver=2.5
297pkgrel=1
298url="https://example.com/foo"
299makedepends="zlib-dev openssl-dev linux-headers"
300depends="bar"
301source="https://example.com/foo-$pkgver.tar.gz
302	foo-musl-fix.patch
303	foo-static.patch
304	"
305sha512sums="abc123  foo-2.5.tar.gz"
306
307build() {
308	./configure --prefix=/usr
309	make
310}
311
312package() {
313	make DESTDIR="$pkgdir" install
314}
315"#;
316
317    #[test]
318    fn extracts_source_patches_deps_phases() {
319        let toml = recipe_from_apkbuild(APKBUILD_PATCHED).unwrap();
320        let recipe = hammer_core::Recipe::from_toml(&toml).expect("recipe válida");
321        assert_eq!(recipe.name, "foo");
322        assert_eq!(recipe.version, "2.5");
323        // tarball con $pkgver expandido:
324        assert_eq!(recipe.source.tarball.as_deref(), Some("https://example.com/foo-2.5.tar.gz"));
325        // LOS PARCHES DE MUSL cargados (el punto central):
326        assert_eq!(recipe.source.patches, vec!["foo-musl-fix.patch", "foo-static.patch"]);
327        // build-deps: SÓLO makedepends, sin `-dev`. El runtime `depends="bar"` NO entra como
328        // build-dep (rompería `hammer build`); queda como comentario de provenance.
329        assert!(recipe.deps.build.contains(&"zlib".to_string()));
330        assert!(recipe.deps.build.contains(&"openssl".to_string()));
331        assert!(recipe.deps.build.contains(&"linux-headers".to_string()));
332        assert!(!recipe.deps.build.contains(&"bar".to_string()), "depends de runtime no es build-dep");
333        assert!(toml.contains("# depends de runtime de Alpine (NO build-deps): bar"), "provenance: {toml}");
334        // fases capturadas de build()/package() y TRADUCIDAS: $pkgdir → /out.
335        assert!(recipe.build.phases.compile.as_deref().unwrap().contains("./configure"));
336        let install = recipe.build.phases.install.as_deref().unwrap();
337        assert!(install.contains("DESTDIR=\"/out\""), "instala a /out: {install}");
338        assert!(!install.contains("$pkgdir"), "no debe quedar $pkgdir: {install}");
339    }
340
341    #[test]
342    fn translate_abuild_substitutes_vars() {
343        let body = "make DESTDIR=\"$pkgdir\" install\ncp x ${pkgdir}/usr/share/$pkgname/\necho $pkgver";
344        let t = translate_abuild(body, "foo", "2.5");
345        assert!(t.contains("DESTDIR=\"/out\""));
346        assert!(t.contains("/out/usr/share/foo/"));
347        assert!(t.contains("echo 2.5"));
348        assert!(!t.contains("$pkg"));
349    }
350
351    #[test]
352    fn no_patches_is_empty() {
353        let apk = "pkgname=zlib\npkgver=1.3\nsource=\"https://zlib.net/zlib-$pkgver.tar.gz\"\nsha512sums=\"x  z\"\n";
354        let recipe = hammer_core::Recipe::from_toml(&recipe_from_apkbuild(apk).unwrap()).unwrap();
355        assert_eq!(recipe.name, "zlib");
356        assert!(recipe.source.patches.is_empty());
357        assert_eq!(recipe.source.tarball.as_deref(), Some("https://zlib.net/zlib-1.3.tar.gz"));
358    }
359
360    #[test]
361    fn rejects_no_pkgname() {
362        assert!(recipe_from_apkbuild("pkgver=1\n").is_err());
363    }
364}