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 1572Shell 203Rust 109Markdown 993728 Definitions

⎇ main crates/takana-cli/src/nix_import.rs Rust · 514 lines · 22.9 KB History Who touched what raw

takana etapa 5d: comentarios de crates, CLAUDE.md y el skill de granja b818f5249 · Sergio ·

1//! Importador `nix → receta takana` (Etapa G: poblar el catálogo desde nixpkgs).
2//!
3//! El problema: a mano, 34 recetas = userland desierto; un distro usable necesita miles de
4//! paquetes. nixpkgs es el mayor set de recetas DESDE FUENTE (sources pineados+hash, deps
5//! explícitas), así que es la semilla natural del catálogo. Este importador toma la *receta* de
6//! nix (qué bajar, flags, deps) y la reexpresa como una `Recipe` de takana — **nunca el binario**
7//! del cache de nix (eso rompería "verificar, no confiar"; hammer reconstruye desde fuente igual).
8//!
9//! Consume un JSON NORMALIZADO (lo produce `scripts/nix-import.sh` vía `nix eval --apply`), no el
10//! `.drv` crudo: así el importador es puro y testeable sin nix, y la fricción de evaluar nixpkgs
11//! queda en el wrapper. Lo que el importador NO resuelve (y se documenta en la receta emitida): el
12//! build real en el lab de hammer (zig/musl estático) difiere del stdenv de nix (glibc/gcc) ⇒ cada
13//! paquete necesita adaptación de toolchain. El importador da el punto de partida, no el binario.
14
15use serde::Deserialize;
16
17/// Paquete nix normalizado (salida de `nix eval --apply` del wrapper). Campos opcionales con
18/// defaults para tolerar paquetes que no los declaran.
19#[derive(Debug, Deserialize)]
20pub struct NixPkg {
21    pub pname: String,
22    #[serde(default)]
23    pub version: String,
24    pub source: RawSource,
25    #[serde(default)]
26    pub configure_flags: Vec<String>,
27    #[serde(default)]
28    pub build_inputs: Vec<String>,
29    #[serde(default)]
30    pub native_build_inputs: Vec<String>,
31    /// `true` si nixpkgs lo construye con `rustPlatform.buildRustPackage` (el wrapper lo detecta
32    /// por `cargoDeps`). Activa la plantilla Cargo de hammer (`--bin` + cp target/release).
33    #[serde(default)]
34    pub is_rust: bool,
35    /// `true` si nixpkgs lo construye con `buildGoModule` (lo detecta por `vendorHash`/`goModules`).
36    /// Emite `deps.build = ["go"]` y deja las phases a `BuildSys::Go` (go install del main auto).
37    #[serde(default)]
38    pub is_go: bool,
39    /// El binario principal (`meta.mainProgram` de nix; p.ej. ripgrep→"rg"). Para el `--bin`.
40    #[serde(default)]
41    pub main_program: String,
42}
43
44/// El origen TAL CUAL lo da `nix eval` del `p.src`: la URL fetcheada + el `outputHash`/mode de la
45/// derivación fija. `outputHashMode = "flat"` ⇒ el hash es del FICHERO (sha256 de tarball, usable);
46/// `"recursive"` ⇒ hash NAR del árbol (no casa con un sha256 de tarball ⇒ usamos git+commit).
47#[derive(Debug, Deserialize)]
48pub struct RawSource {
49    pub url: String,
50    #[serde(default)]
51    pub output_hash: String,
52    #[serde(default)]
53    pub output_hash_mode: String,
54}
55
56/// El origen ya clasificado para takana. `fetchurl` plano → `tarball+sha256`; GitHub (codeload) →
57/// `repo+commit` (takana pinea por commit y reproduce clonando, sin depender del hash NAR de nix).
58#[derive(Debug, PartialEq, Eq)]
59pub enum NixSource {
60    Tarball { url: String, sha256: String },
61    Github { owner: String, repo: String, rev: String },
62}
63
64/// Clasifica el origen RAW de nix. Detecta GitHub por la URL de codeload/archive (de
65/// `fetchFromGitHub`), si no usa el modo del hash: `flat` ⇒ tarball con su sha256; `recursive`
66/// no-github ⇒ no podemos reusar el hash, error pidiendo pin manual.
67pub fn classify_source(raw: &RawSource) -> Result<NixSource, String> {
68    if let Some((owner, repo, rev)) = parse_github_url(&raw.url) {
69        return Ok(NixSource::Github { owner, repo, rev });
70    }
71    match raw.output_hash_mode.as_str() {
72        "flat" | "" => {
73            if raw.output_hash.is_empty() {
74                return Err(format!("source flat sin hash: {}", raw.url));
75            }
76            let hex = nix_hash_to_hex(&raw.output_hash)?;
77            Ok(NixSource::Tarball { url: expand_nix_mirror(&raw.url), sha256: hex })
78        }
79        "recursive" => Err(format!(
80            "source recursivo no-github (hash NAR no reusable): {} — fijá repo+commit a mano",
81            raw.url
82        )),
83        other => Err(format!("outputHashMode desconocido '{other}': {}", raw.url)),
84    }
85}
86
87/// Traduce el esquema `mirror://<sitio>/...` de nix a una URL concreta. nix resuelve estos
88/// mirrors en tiempo de eval; un import los deja literales y el fetch de takana (curl) no los
89/// entiende. Mapeamos los más comunes a un mirror real; lo no mapeado se deja igual + el usuario
90/// elige espejo. (nixpkgs tiene la lista completa en `pkgs/build-support/fetchurl/mirrors.nix`.)
91fn expand_nix_mirror(url: &str) -> String {
92    let Some(rest) = url.strip_prefix("mirror://") else {
93        return url.to_string();
94    };
95    let Some((site, path)) = rest.split_once('/') else {
96        return url.to_string();
97    };
98    let base = match site {
99        "gnu" => "https://ftp.gnu.org/gnu/",
100        "savannah" => "https://download.savannah.gnu.org/releases/",
101        "kernel" => "https://www.kernel.org/pub/",
102        "sourceforge" => "https://downloads.sourceforge.net/",
103        "gnome" => "https://download.gnome.org/",
104        "apache" => "https://dlcdn.apache.org/",
105        "xorg" => "https://www.x.org/releases/",
106        "pypi" => "https://files.pythonhosted.org/packages/",
107        "cpan" => "https://cpan.metacpan.org/",
108        "debian" => "https://deb.debian.org/debian/",
109        _ => return url.to_string(), // desconocido: dejamos el mirror:// (el humano resuelve)
110    };
111    format!("{base}{path}")
112}
113
114/// Extrae `(owner, repo, rev)` de una URL de descarga de GitHub (las que arma `fetchFromGitHub`):
115/// `https://github.com/<owner>/<repo>/archive/<rev>.tar.gz` o
116/// `https://codeload.github.com/<owner>/<repo>/tar.gz/<rev>`.
117fn parse_github_url(url: &str) -> Option<(String, String, String)> {
118    if let Some(rest) = url.strip_prefix("https://github.com/") {
119        // <owner>/<repo>/archive/<rev>.tar.gz  (rev puede traer 'refs/tags/..' → tomamos el final)
120        let parts: Vec<&str> = rest.splitn(4, '/').collect();
121        if parts.len() == 4 && parts[2] == "archive" {
122            let rev = parts[3]
123                .trim_end_matches(".tar.gz")
124                .trim_end_matches(".zip")
125                .rsplit('/')
126                .next()?
127                .to_string();
128            return Some((parts[0].to_string(), parts[1].to_string(), rev));
129        }
130    }
131    if let Some(rest) = url.strip_prefix("https://codeload.github.com/") {
132        // <owner>/<repo>/tar.gz/<rev>
133        let parts: Vec<&str> = rest.splitn(4, '/').collect();
134        if parts.len() == 4 {
135            return Some((parts[0].to_string(), parts[1].to_string(), parts[3].to_string()));
136        }
137    }
138    None
139}
140
141/// Convierte una receta nix normalizada en el TOML de una `Recipe` de takana. El resultado es un
142/// PUNTO DE PARTIDA: lleva comentarios marcando lo que falta adaptar (toolchain, nombres de deps).
143pub fn to_recipe_toml(pkg: &NixPkg) -> Result<String, String> {
144    let name = sanitize_name(&pkg.pname);
145    let version = if pkg.version.is_empty() { "0".to_string() } else { pkg.version.clone() };
146
147    let source = classify_source(&pkg.source)
148        .map_err(|e| format!("source de '{}': {e}", pkg.pname))?;
149    let source_block = match &source {
150        NixSource::Tarball { url, sha256 } => {
151            format!("tarball = \"{url}\"\nsha256  = \"{sha256}\"\n")
152        }
153        NixSource::Github { owner, repo, rev } => {
154            format!("repo   = \"https://github.com/{owner}/{repo}\"\ncommit = \"{rev}\"\n")
155        }
156    };
157
158    // deps: build = nativeBuildInputs ++ buildInputs (por nombre nix; el humano remapea al corpus
159    // de takana si difieren — p.ej. nix `pkg-config` ≈ takana `pkgconf`).
160    let mut deps: Vec<String> = Vec::new();
161    for d in pkg.native_build_inputs.iter().chain(pkg.build_inputs.iter()) {
162        let n = sanitize_name(d);
163        if !n.is_empty() && !is_nix_noise(&n) && !deps.contains(&n) {
164            deps.push(n);
165        }
166    }
167    // Rust (buildRustPackage): NO emitir los buildInputs como `[deps]` activas. nix lista el closure
168    // MAXIMAL (todos los backends C opcionales: zlib, pcre2, openssl, jemalloc…), pero el build Rust
169    // del lab usa las features DEFAULT de cargo, que suelen traer backend Rust puro (miniz_oxide vs
170    // zlib, rustls vs openssl) o lo dejan opt-in (pcre2). Cargo resuelve el grafo Rust por vendoring;
171    // un sys-lib C que SÍ haga falta es adaptación per-paquete (patch/feature, p.ej.
172    // ripgrep-no-jemalloc), no una dep de corpus. Las recetas Rust validadas (ripgrep/uutils) NO
173    // declaran `[deps]`; emitirlas rompe `takana build` (busca recipes/<dep>.toml). Quedan como
174    // COMENTARIO de provenance (no se pierden: señalan qué C podría necesitarse).
175    let deps_block = if pkg.is_go {
176        // Go: la única build-dep es el toolchain `go` (recipes/go.toml). Los buildInputs C de nix
177        // NO aplican (binario Go estático puro, CGO_ENABLED=0). Quedan como comentario de provenance.
178        let prov = if deps.is_empty() {
179            String::new()
180        } else {
181            format!("# buildInputs de nix (no usados con CGO off): {}\n", deps.join(", "))
182        };
183        format!("\n{prov}[deps]\nbuild = [\"go\"]\n")
184    } else if deps.is_empty() {
185        String::new()
186    } else if pkg.is_rust {
187        let list = deps.join(", ");
188        format!("\n# buildInputs de nix (NO deps de hammer — cargo vendorea; backend Rust por\n# defecto). Si algún sys-crate C falla, adaptá per-paquete (patch/feature): {list}\n")
189    } else {
190        let list = deps.iter().map(|d| format!("\"{d}\"")).collect::<Vec<_>>().join(", ");
191        format!("\n[deps]\nbuild = [{list}]\n")
192    };
193
194    // Rust (buildRustPackage): emite la plantilla Cargo de takana — `--bin <bin>` (cargo exige UN
195    // target) + install copiando `target/release/<bin>` a /out (mismo patrón que la receta ripgrep).
196    // El bin = meta.mainProgram de nix (ripgrep→rg), o el pname. Esto deja la receta build-ready.
197    let (flags_line, rust_install) = if pkg.is_rust {
198        let bin = if pkg.main_program.is_empty() { name.clone() } else { sanitize_name(&pkg.main_program) };
199        // `--bin <bin>`: default para el caso común (crate suelto, pname=paquete cargo). Los repos
200        // con WORKSPACE VIRTUAL en el root (p.ej. sd: el bin vive en el paquete `sd-cli`≠pname)
201        // necesitan que el humano agregue `-p <paquete-real>` — el import no puede adivinar el nombre.
202        (
203            format!("flags    = [\"--bin\", \"{bin}\"]\n"),
204            format!(
205                "\n[build.phases]\ninstall = \"mkdir -p /out/usr/bin && cp target/release/{bin} /out/usr/bin/{bin}\"\n"
206            ),
207        )
208    } else {
209        ("flags    = []\n".to_string(), String::new())
210    };
211
212    // configureFlags → una fase configure explícita (si las hay). Si no, se deja a la heurística
213    // del lab. NOTA: muchos paquetes nix no son autotools (cmake/meson); esto es best-effort.
214    let phases_block = if pkg.is_go {
215        // Go: sin phases. `BuildSys::Go` deriva el compile (go install del main auto-detectado).
216        String::new()
217    } else if !rust_install.is_empty() {
218        rust_install
219    } else if pkg.configure_flags.is_empty() {
220        String::new()
221    } else {
222        let flags = pkg
223            .configure_flags
224            .iter()
225            .map(|f| f.replace('"', "\\\""))
226            .collect::<Vec<_>>()
227            .join(" ");
228        format!("\n[build.phases]\nconfigure = \"./configure --prefix=/usr {flags}\"\n")
229    };
230
231    let toml = format!(
232        "# Importada de nixpkgs por `hammer import-nix` (Etapa G). PUNTO DE PARTIDA, no final:\n\
233         # - el build usa el lab de takana (zig-cc / musl estático), NO el stdenv de nix ⇒ revisá\n\
234         #   compiler/link/phases y adaptá hasta que compile.\n\
235         # - las deps van con su nombre NIX; remapealas a las recetas del corpus si difieren.\n\
236         name    = \"{name}\"\n\
237         version = \"{version}\"\n\
238         \n\
239         [source]\n\
240         {source_block}\
241         \n\
242         [build]\n\
243         compiler = \"zig-cc\"\n\
244         target   = \"x86_64-linux-musl\"\n\
245         link     = \"static\"\n\
246         {flags_line}\
247         {phases_block}{deps_block}"
248    );
249    Ok(toml)
250}
251
252/// Filtra el RUIDO de stdenv de nix que no son paquetes reales: setup-hooks, wrappers de
253/// compilador, e infraestructura de lenguaje (cargo/rustc-hooks). El humano añade lo que falte;
254/// preferimos un punto de partida limpio a arrastrar 10 hooks por receta. Conservador: sólo
255/// patrones inequívocamente internos de nix.
256fn is_nix_noise(name: &str) -> bool {
257    name.contains("hook")
258        || name.ends_with("-wrapper")
259        || name == "install-shell-files"
260        || name == "auditable-cargo"
261        || name.starts_with("auditable-cargo-")
262        || name == "version-check"
263        // El toolchain Rust ES el lab de takana (cargo vendorea las crates), NO un paquete dep.
264        || name == "rustc"
265        || name == "cargo"
266        || name == "rust"
267}
268
269/// Nombre de receta válido: minúsculas, sólo `[a-z0-9._-]`, sin espacios. nix usa nombres limpios
270/// pero por las dudas saneamos (también recorta sufijos de output tipo `-dev`).
271fn sanitize_name(s: &str) -> String {
272    let s = s.trim();
273    let s = s.strip_suffix("-dev").unwrap_or(s);
274    s.chars()
275        .map(|c| if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | '+') { c.to_ascii_lowercase() } else { '-' })
276        .collect::<String>()
277        .trim_matches('-')
278        .to_string()
279}
280
281/// Convierte un hash de nix a sha256 HEX (lo que pide `Recipe.source.sha256`). Acepta:
282/// - SRI `sha256-<base64>` (formato moderno de nixpkgs),
283/// - hex de 64 chars (ya en el formato deseado),
284/// - nix-base32 de 52 chars (formato clásico de `outputHash`).
285pub fn nix_hash_to_hex(h: &str) -> Result<String, String> {
286    let h = h.trim();
287    // SRI: sha256-<base64>
288    if let Some(b64) = h.strip_prefix("sha256-") {
289        use base64::Engine;
290        let bytes = base64::engine::general_purpose::STANDARD
291            .decode(b64)
292            .map_err(|e| format!("SRI base64 inválido: {e}"))?;
293        if bytes.len() != 32 {
294            return Err(format!("SRI sha256: esperaba 32 bytes, hay {}", bytes.len()));
295        }
296        return Ok(hex_encode(&bytes));
297    }
298    // hex puro (64 chars, todos hex)
299    if h.len() == 64 && h.bytes().all(|b| b.is_ascii_hexdigit()) {
300        return Ok(h.to_ascii_lowercase());
301    }
302    // nix-base32 (52 chars para sha256)
303    if h.len() == 52 {
304        let bytes = nix_base32_decode(h)?;
305        return Ok(hex_encode(&bytes));
306    }
307    Err(format!("formato de hash no reconocido ({} chars): {h}", h.len()))
308}
309
310fn hex_encode(bytes: &[u8]) -> String {
311    let mut s = String::with_capacity(bytes.len() * 2);
312    for b in bytes {
313        s.push_str(&format!("{b:02x}"));
314    }
315    s
316}
317
318/// Decodifica el base32 PROPIO de nix (alfabeto `0123456789abcdfghijklmnpqrsvwxyz`, sin e/o/u/t;
319/// orden LSB-first). Devuelve los 32 bytes del sha256. Port directo de `nixbase32::decode`.
320fn nix_base32_decode(s: &str) -> Result<Vec<u8>, String> {
321    const ALPHABET: &[u8; 32] = b"0123456789abcdfghijklmnpqrsvwxyz";
322    let chars = s.as_bytes();
323    let out_len = 32usize; // sha256
324    let mut out = vec![0u8; out_len];
325    for (n, &c) in chars.iter().rev().enumerate() {
326        let digit = ALPHABET
327            .iter()
328            .position(|&a| a == c)
329            .ok_or_else(|| format!("carácter no base32-nix: '{}'", c as char))?
330            as u32;
331        let b = n * 5;
332        let i = b / 8;
333        let j = (b % 8) as u32;
334        out[i] |= ((digit << j) & 0xff) as u8;
335        let carry = digit >> (8 - j);
336        if carry != 0 {
337            if i + 1 >= out_len {
338                return Err("base32-nix: desbordamiento (hash mal formado)".into());
339            }
340            out[i + 1] |= carry as u8;
341        }
342    }
343    Ok(out)
344}
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349
350    #[test]
351    fn sri_hash_to_hex() {
352        // sha256("") = e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
353        // SRI base64 de esos 32 bytes:
354        let sri = "sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=";
355        let hex = nix_hash_to_hex(sri).unwrap();
356        assert_eq!(hex, "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855");
357    }
358
359    #[test]
360    fn hex_passthrough() {
361        let h = "E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855";
362        assert_eq!(nix_hash_to_hex(h).unwrap(), h.to_ascii_lowercase());
363    }
364
365    #[test]
366    fn nixbase32_zeros_decode() {
367        // 52 dígitos '0' (el primer símbolo del alfabeto) ⇒ 32 bytes cero. Caso correcto por
368        // construcción que ejercita el lookup del alfabeto y el sin-desbordamiento. (El formato
369        // moderno de nixpkgs es SRI, cubierto arriba; base32 es fallback para drvs clásicos.)
370        let b32 = "0".repeat(52);
371        let hex = nix_hash_to_hex(&b32).unwrap();
372        assert_eq!(hex, "0".repeat(64));
373    }
374
375    #[test]
376    fn nixbase32_rejects_bad_char() {
377        // 'e' no está en el alfabeto de nix (omite e/o/u/t).
378        let b32 = format!("e{}", "0".repeat(51));
379        assert!(nix_hash_to_hex(&b32).is_err());
380    }
381
382    #[test]
383    fn classify_github_archive_url() {
384        let raw = RawSource {
385            url: "https://github.com/BurntSushi/ripgrep/archive/14.1.1.tar.gz".into(),
386            output_hash: "sha256-xxx".into(),
387            output_hash_mode: "recursive".into(),
388        };
389        assert_eq!(
390            classify_source(&raw).unwrap(),
391            NixSource::Github { owner: "BurntSushi".into(), repo: "ripgrep".into(), rev: "14.1.1".into() }
392        );
393    }
394
395    #[test]
396    fn classify_recursive_non_github_errors() {
397        let raw = RawSource {
398            url: "https://example.com/foo.tar.gz".into(),
399            output_hash: "sha256-xxx".into(),
400            output_hash_mode: "recursive".into(),
401        };
402        assert!(classify_source(&raw).is_err());
403    }
404
405    #[test]
406    fn import_tarball_autotools() {
407        let json = r#"{
408            "pname": "hello",
409            "version": "2.12.1",
410            "source": { "url": "mirror://gnu/hello/hello-2.12.1.tar.gz",
411                        "output_hash": "sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=",
412                        "output_hash_mode": "flat" },
413            "configure_flags": ["--disable-nls"],
414            "build_inputs": ["zlib"],
415            "native_build_inputs": ["pkg-config"]
416        }"#;
417        let pkg: NixPkg = serde_json::from_str(json).unwrap();
418        let toml = to_recipe_toml(&pkg).unwrap();
419        // Parsea como Recipe válida de takana.
420        let recipe = takana_core::Recipe::from_toml(&toml).expect("recipe válida");
421        assert_eq!(recipe.name, "hello");
422        assert_eq!(recipe.version, "2.12.1");
423        // mirror://gnu/ se traduce a una URL concreta que el fetch de takana entiende.
424        assert_eq!(recipe.source.tarball.as_deref(), Some("https://ftp.gnu.org/gnu/hello/hello-2.12.1.tar.gz"));
425        assert_eq!(
426            recipe.source.sha256.as_deref(),
427            Some("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")
428        );
429        assert_eq!(recipe.deps.build, vec!["pkg-config", "zlib"]);
430        assert!(recipe.build.phases.configure.as_deref().unwrap().contains("--disable-nls"));
431    }
432
433    #[test]
434    fn rust_package_gets_bin_and_install_template() {
435        let json = r#"{
436            "pname": "ripgrep", "version": "14.1.1",
437            "source": { "url": "https://github.com/BurntSushi/ripgrep/archive/14.1.1.tar.gz", "output_hash_mode": "recursive" },
438            "is_rust": true, "main_program": "rg"
439        }"#;
440        let pkg: NixPkg = serde_json::from_str(json).unwrap();
441        let recipe = takana_core::Recipe::from_toml(&to_recipe_toml(&pkg).unwrap()).unwrap();
442        assert_eq!(recipe.build.flags, vec!["--bin", "rg"]);
443        let inst = recipe.build.phases.install.as_deref().unwrap();
444        assert!(inst.contains("cp target/release/rg /out/usr/bin/rg"), "{inst}");
445    }
446
447    #[test]
448    fn rust_buildinputs_are_not_active_deps() {
449        // nix lista el closure maximal (pcre2/openssl…); para Rust NO deben volverse `[deps]` (cargo
450        // vendorea, backend Rust por defecto) — romperían `takana build`. Quedan como comentario.
451        let json = r#"{
452            "pname": "rg", "version": "14",
453            "source": { "url": "https://github.com/o/r/archive/abc.tar.gz", "output_hash_mode": "recursive" },
454            "is_rust": true, "main_program": "rg",
455            "build_inputs": ["pcre2", "openssl"]
456        }"#;
457        let pkg: NixPkg = serde_json::from_str(json).unwrap();
458        let toml = to_recipe_toml(&pkg).unwrap();
459        let recipe = takana_core::Recipe::from_toml(&toml).unwrap();
460        assert!(recipe.deps.build.is_empty(), "Rust no emite deps activas: {:?}", recipe.deps.build);
461        assert!(toml.contains("# buildInputs de nix"), "deja provenance comentada: {toml}");
462        assert!(toml.contains("pcre2"), "lista los nombres en el comentario: {toml}");
463    }
464
465    #[test]
466    fn filters_nix_stdenv_noise() {
467        let json = r#"{
468            "pname": "x", "version": "1",
469            "source": { "url": "https://github.com/o/r/archive/abc.tar.gz", "output_hash_mode": "recursive" },
470            "build_inputs": ["pcre2", "cargo-build-hook.sh", "rustc", "pkg-config-wrapper"],
471            "native_build_inputs": ["install-shell-files", "version-check-hook"]
472        }"#;
473        let pkg: NixPkg = serde_json::from_str(json).unwrap();
474        let recipe = takana_core::Recipe::from_toml(&to_recipe_toml(&pkg).unwrap()).unwrap();
475        // pcre2 sobrevive (paquete real); hooks/wrappers/shell-files y el toolchain rustc/cargo
476        // se filtran (rustc/cargo son el lab, no deps).
477        assert_eq!(recipe.deps.build, vec!["pcre2"]);
478    }
479
480    #[test]
481    fn expands_common_nix_mirrors() {
482        assert_eq!(
483            expand_nix_mirror("mirror://gnu/hello/hello-2.12.1.tar.gz"),
484            "https://ftp.gnu.org/gnu/hello/hello-2.12.1.tar.gz"
485        );
486        assert_eq!(
487            expand_nix_mirror("mirror://savannah/foo/foo-1.tar.gz"),
488            "https://download.savannah.gnu.org/releases/foo/foo-1.tar.gz"
489        );
490        // desconocido se deja igual (no rompe, el humano resuelve).
491        assert_eq!(expand_nix_mirror("mirror://raro/x"), "mirror://raro/x");
492        // url normal pasa sin tocar.
493        assert_eq!(expand_nix_mirror("https://x/y.tar.gz"), "https://x/y.tar.gz");
494    }
495
496    #[test]
497    fn import_github_uses_git() {
498        let json = r#"{
499            "pname": "ripgrep",
500            "version": "14.1.1",
501            "source": { "url": "https://github.com/BurntSushi/ripgrep/archive/4649aa9700619f94cf9c66876e9549d83420e16c.tar.gz",
502                        "output_hash": "sha256-zzz", "output_hash_mode": "recursive" }
503        }"#;
504        let pkg: NixPkg = serde_json::from_str(json).unwrap();
505        let toml = to_recipe_toml(&pkg).unwrap();
506        let recipe = takana_core::Recipe::from_toml(&toml).unwrap();
507        assert_eq!(recipe.source.repo.as_deref(), Some("https://github.com/BurntSushi/ripgrep"));
508        assert_eq!(
509            recipe.source.commit.as_deref(),
510            Some("4649aa9700619f94cf9c66876e9549d83420e16c")
511        );
512        assert!(recipe.source.sha256.is_none(), "github ⇒ pin por commit, sin sha256");
513    }
514}