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…
⎇ 630cde65510c2d1e57f33ecd0a5432c2e380c8d1
crates/hammer-cli/src/alpine_import.rs
Historia
Quién tocó qué
crudo
Etapa G: importador Alpine APKBUILD→receta (carga los parches de musl)
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 // deps: makedepends + depends (nombres; filtramos self-subpaquetes y los negados `!x`).
64 let mut deps: Vec<String> = Vec::new();
65 let self_prefix = format!("{pkgname}-");
66 for src in [get("makedepends"), get("depends")] {
67 for d in src.split_whitespace() {
68 let d = d.trim();
69 // Saltamos: negados (!x), variables sin expandir ($x), pins versionados a subpaquetes
70 // (`foo=1.2-r3`), y auto-referencias al propio paquete/subpaquetes (`pkgname-...`).
71 if d.is_empty()
72 || d.starts_with('!')
73 || d.starts_with('$')
74 || d.contains('=')
75 || d.starts_with(&self_prefix)
76 || d == pkgname
77 {
78 continue;
79 }
80 // Alpine usa `foo-dev`, `cmd:make`, `pc:libfoo`, `so:libbar.so` — normalizamos lo común.
81 let name = d
82 .strip_prefix("cmd:")
83 .or_else(|| d.strip_prefix("pc:"))
84 .unwrap_or(d)
85 .trim_end_matches("-dev")
86 .to_string();
87 if !name.is_empty() && !name.starts_with("so:") && !deps.contains(&name) {
88 deps.push(name);
89 }
90 }
91 }
92 let deps_block = if deps.is_empty() {
93 String::new()
94 } else {
95 let list = deps.iter().map(|d| format!("\"{d}\"")).collect::<Vec<_>>().join(", ");
96 format!("\n[deps]\nbuild = [{list}]\n")
97 };
98
99 // build()/package() → fases (best-effort, comentadas como abuild-shell a adaptar).
100 let phases_block = {
101 let compile = func_body(text, "build");
102 let install = func_body(text, "package");
103 if compile.is_none() && install.is_none() {
104 String::new()
105 } else {
106 let mut s = String::from("\n[build.phases]\n");
107 if let Some(b) = compile {
108 s.push_str(&format!("# de build() de Alpine (abuild: CHOST/--shared/etc; adaptá a estático):\ncompile = {}\n", toml_multiline(&b)));
109 }
110 if let Some(p) = install {
111 s.push_str(&format!("# de package() de Alpine (usa $pkgdir → ajustá a /out):\ninstall = {}\n", toml_multiline(&p)));
112 }
113 s
114 }
115 };
116
117 let patches_line = if patches.is_empty() {
118 String::new()
119 } else {
120 let list = patches.iter().map(|p| format!("\"{p}\"")).collect::<Vec<_>>().join(", ");
121 format!("patches = [{list}]\n")
122 };
123
124 let toml = format!(
125 "# Importada de Alpine aports por `hammer import-alpine` (Etapa G). PUNTO DE PARTIDA — pero\n\
126 # YA trae los parches de musl de Alpine (lo que un import de nix pierde). Pendiente: el\n\
127 # sha256 del tarball (el wrapper lo calcula), y adaptar build/install del shell de abuild.\n\
128 name = \"{pkgname}\"\n\
129 version = \"{version}\"\n\
130 \n\
131 [source]\n\
132 {source_block}{patches_line}\
133 \n\
134 [build]\n\
135 compiler = \"zig-cc\"\n\
136 target = \"x86_64-linux-musl\"\n\
137 link = \"static\"\n\
138 flags = []\n\
139 {phases_block}{deps_block}"
140 );
141 Ok(toml)
142}
143
144/// Recoge asignaciones `clave=valor` y `clave="...multilínea..."` del APKBUILD (ignora funciones y
145/// comentarios). Suficiente para los campos de metadatos; expande `$var`/`${var}` conocidas.
146fn parse_vars(text: &str) -> BTreeMap<String, String> {
147 let mut raw: BTreeMap<String, String> = BTreeMap::new();
148 let bytes: Vec<&str> = text.lines().collect();
149 let mut i = 0;
150 while i < bytes.len() {
151 let line = bytes[i];
152 let trimmed = line.trim_start();
153 // Asignación simple `key=...` al inicio de línea (no dentro de función: heurística por '=').
154 if let Some(eq) = simple_assign(trimmed) {
155 let key = trimmed[..eq].to_string();
156 let mut val = trimmed[eq + 1..].to_string();
157 // Valor multilínea entre comillas dobles: acumular hasta cerrar.
158 if let Some(rest) = val.strip_prefix('"') {
159 if let Some(end) = rest.find('"') {
160 val = rest[..end].to_string();
161 } else {
162 let mut acc = rest.to_string();
163 i += 1;
164 while i < bytes.len() {
165 if let Some(end) = bytes[i].find('"') {
166 acc.push('\n');
167 acc.push_str(&bytes[i][..end]);
168 break;
169 } else {
170 acc.push('\n');
171 acc.push_str(bytes[i]);
172 }
173 i += 1;
174 }
175 val = acc;
176 }
177 } else {
178 // valor sin comillas: hasta fin de línea, sin comentario.
179 val = val.split('#').next().unwrap_or("").trim().to_string();
180 val = val.trim_matches('\'').to_string();
181 }
182 raw.insert(key, val);
183 }
184 i += 1;
185 }
186 // Expansión de variables (varias pasadas para anidamiento simple).
187 let keys: Vec<String> = raw.keys().cloned().collect();
188 for _ in 0..3 {
189 for k in &keys {
190 let mut v = raw[k].clone();
191 for k2 in &keys {
192 let val2 = raw[k2].clone();
193 v = v.replace(&format!("${{{k2}}}"), &val2).replace(&format!("${k2}"), &val2);
194 }
195 raw.insert(k.clone(), v);
196 }
197 }
198 raw
199}
200
201/// `Some(pos_del_=)` si la línea es una asignación `nombre=...` válida (nombre shell). Evita
202/// confundir `foo() {`, comparaciones, o `export X=Y`.
203fn simple_assign(line: &str) -> Option<usize> {
204 let eq = line.find('=')?;
205 if eq == 0 {
206 return None;
207 }
208 let name = &line[..eq];
209 if name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') && name.chars().next()?.is_ascii_alphabetic() || name.starts_with('_') {
210 // siguiente char no debe ser '=' (==) ni la asignación es vacía rara.
211 if line.as_bytes().get(eq + 1) == Some(&b'=') {
212 return None;
213 }
214 return Some(eq);
215 }
216 None
217}
218
219/// Extrae el cuerpo de una función shell `name() { ... }`: el texto entre la primera `{` y la `}`
220/// que la cierra a nivel 0. Best-effort (no parsea shell de verdad; cuenta llaves).
221fn func_body(text: &str, name: &str) -> Option<String> {
222 let pat_a = format!("{name}()");
223 let start = text.find(&pat_a)?;
224 let brace = text[start..].find('{')? + start;
225 let mut depth = 0i32;
226 let mut end = None;
227 for (idx, ch) in text[brace..].char_indices() {
228 match ch {
229 '{' => depth += 1,
230 '}' => {
231 depth -= 1;
232 if depth == 0 {
233 end = Some(brace + idx);
234 break;
235 }
236 }
237 _ => {}
238 }
239 }
240 let body = text[brace + 1..end?].trim().to_string();
241 if body.is_empty() {
242 None
243 } else {
244 Some(body)
245 }
246}
247
248/// Primer hash de un bloque `sha512sums="<hash> <fichero>\n..."`.
249fn first_sha(block: &str) -> String {
250 block
251 .split_whitespace()
252 .next()
253 .unwrap_or("")
254 .to_string()
255}
256
257/// Serializa un cuerpo multilínea como string TOML (literal multi-línea `'''…'''` si tiene saltos).
258fn toml_multiline(s: &str) -> String {
259 if s.contains('\n') {
260 format!("'''\n{s}\n'''")
261 } else {
262 format!("\"{}\"", s.replace('"', "\\\""))
263 }
264}
265
266#[cfg(test)]
267mod tests {
268 use super::*;
269
270 const APKBUILD_PATCHED: &str = r#"
271pkgname=foo
272pkgver=2.5
273pkgrel=1
274url="https://example.com/foo"
275makedepends="zlib-dev openssl-dev linux-headers"
276depends="bar"
277source="https://example.com/foo-$pkgver.tar.gz
278 foo-musl-fix.patch
279 foo-static.patch
280 "
281sha512sums="abc123 foo-2.5.tar.gz"
282
283build() {
284 ./configure --prefix=/usr
285 make
286}
287
288package() {
289 make DESTDIR="$pkgdir" install
290}
291"#;
292
293 #[test]
294 fn extracts_source_patches_deps_phases() {
295 let toml = recipe_from_apkbuild(APKBUILD_PATCHED).unwrap();
296 let recipe = hammer_core::Recipe::from_toml(&toml).expect("recipe válida");
297 assert_eq!(recipe.name, "foo");
298 assert_eq!(recipe.version, "2.5");
299 // tarball con $pkgver expandido:
300 assert_eq!(recipe.source.tarball.as_deref(), Some("https://example.com/foo-2.5.tar.gz"));
301 // LOS PARCHES DE MUSL cargados (el punto central):
302 assert_eq!(recipe.source.patches, vec!["foo-musl-fix.patch", "foo-static.patch"]);
303 // deps: makedepends + depends, sin `-dev`:
304 assert!(recipe.deps.build.contains(&"zlib".to_string()));
305 assert!(recipe.deps.build.contains(&"openssl".to_string()));
306 assert!(recipe.deps.build.contains(&"linux-headers".to_string()));
307 assert!(recipe.deps.build.contains(&"bar".to_string()));
308 // fases capturadas de build()/package():
309 assert!(recipe.build.phases.compile.as_deref().unwrap().contains("./configure"));
310 assert!(recipe.build.phases.install.as_deref().unwrap().contains("DESTDIR"));
311 }
312
313 #[test]
314 fn no_patches_is_empty() {
315 let apk = "pkgname=zlib\npkgver=1.3\nsource=\"https://zlib.net/zlib-$pkgver.tar.gz\"\nsha512sums=\"x z\"\n";
316 let recipe = hammer_core::Recipe::from_toml(&recipe_from_apkbuild(apk).unwrap()).unwrap();
317 assert_eq!(recipe.name, "zlib");
318 assert!(recipe.source.patches.is_empty());
319 assert_eq!(recipe.source.tarball.as_deref(), Some("https://zlib.net/zlib-1.3.tar.gz"));
320 }
321
322 #[test]
323 fn rejects_no_pkgname() {
324 assert!(recipe_from_apkbuild("pkgver=1\n").is_err());
325 }
326}