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 1569Shell 203Rust 109Markdown 993728 Definiciones

⎇ fe9e06de9e16e96617065d44ac5029b2033b2383 crates/hammer-cli/src/kernel_cmd.rs Rust · 1282 líneas · 45.8 KB Historia Quién tocó qué crudo

kernel: el gate contra un .config producido — ve los huecos que la receta base ya traía d797fc472 · Sergio ·

1//! `hammer kernel` — el armador de kernel (SDD 22).
2//!
3//! Superficie en inglés (Regla 7.bis), mensajes en castellano. El contrato con las UIs es JSON
4//! estable, mismo patrón que `/run/hammer/boot-graph.json`.
5
6use std::collections::{BTreeMap, BTreeSet};
7use std::path::{Path, PathBuf};
8
9use anyhow::{Context, Result};
10use clap::Subcommand;
11use hammer_core::kernel::kconfig::KconfigTree;
12use hammer_core::kernel::KernelConfig;
13use serde::Serialize;
14
15/// Dónde busca el árbol de Kconfig si no se pasa `--kconfig`.
16const KCONFIG_ENV: &str = "HAMMER_KCONFIG_ROOT";
17/// Ídem para el catálogo de bundles.
18const CATALOG_ENV: &str = "HAMMER_KERNEL_BUNDLES";
19/// Sitios donde buscarlo sin que nadie diga nada: el del repo y el del sistema instalado.
20const CATALOG_DEFAULTS: &[&str] = &[
21    "docs/state/kernel-bundles.toml",
22    "/usr/share/hammer/kernel-bundles.toml",
23];
24
25#[derive(Subcommand)]
26pub enum KernelCmd {
27    /// Analiza el grafo de Kconfig: qué muere si se apagan estos símbolos, y por dónde se cuela un
28    /// `select` de vuelta.
29    ///
30    /// Es la herramienta con la que se decide si un bundle «por clausura» es viable (SDD 22 §3).
31    /// No construye ni escribe nada.
32    Closure {
33        /// Símbolos raíz a apagar, SIN el prefijo `CONFIG_` (p.ej. `WIRELESS`).
34        #[arg(required = true)]
35        symbols: Vec<String>,
36        /// Raíz del árbol de fuentes del kernel (el directorio con el `Kconfig` de arriba).
37        #[arg(long)]
38        kconfig: Option<PathBuf>,
39        /// Contrasta la clausura contra los símbolos apagados en este `.config`.
40        #[arg(long)]
41        against: Option<PathBuf>,
42        /// Cierra la clausura contra sus propias fugas: apaga también a cada selector, y repite.
43        /// Muestra el PRECIO (qué hubo que tragarse de más) en vez de aplicarlo a ciegas.
44        #[arg(long)]
45        fixpoint: bool,
46        /// Emite JSON en vez del informe legible.
47        #[arg(long)]
48        json: bool,
49        /// Cuántos símbolos de la clausura listar en el informe legible.
50        #[arg(long, default_value_t = 20)]
51        limit: usize,
52    },
53    /// **Modo reversa**: lee el kernel que ya corre y lo muestra por el lente de los bundles.
54    ///
55    /// No compila ni escribe nada. Contesta tres cosas: cuánto de cada bundle ya rige, qué
56    /// capacidad carga este kernel que esta máquina no usa, y dónde el hardware CONTRADICE a un
57    /// bundle aplicado.
58    Probe {
59        /// `.config` a mirar. Por defecto `/proc/config.gz` y si no `/boot/config-<uname -r>`.
60        #[arg(long)]
61        config: Option<PathBuf>,
62        #[arg(long)]
63        kconfig: Option<PathBuf>,
64        #[arg(long)]
65        catalog: Option<PathBuf>,
66        /// No leer el hardware (útil para analizar el config de OTRA máquina).
67        #[arg(long)]
68        no_hardware: bool,
69        #[arg(long)]
70        json: bool,
71    },
72    /// Lista el catálogo con sus clausuras ya resueltas contra un árbol de Kconfig concreto.
73    ///
74    /// Es también el control de frescura: `--check` sale distinto de cero si algún bundle nombra
75    /// símbolos que ya no existen o dejó fugas `select` sin declarar.
76    Bundles {
77        #[arg(long)]
78        kconfig: Option<PathBuf>,
79        #[arg(long)]
80        catalog: Option<PathBuf>,
81        /// Falla si el catálogo envejeció respecto de este árbol.
82        #[arg(long)]
83        check: bool,
84        #[arg(long)]
85        json: bool,
86    },
87    /// De una selección de bundles a una **receta derivada**, sin construir nada.
88    ///
89    /// El config del kernel vive en la fase `configure` y las fases entran en `hash_inputs` ⇒ el
90    /// config ES la identidad del artefacto. Por eso esto emite una receta con su propio
91    /// `ArtifactHash`, y no finge que el kernel sea un binario parametrizable.
92    Plan {
93        /// Receta base (p.ej. `recipes/linux.toml`).
94        #[arg(long)]
95        recipe: PathBuf,
96        /// Id de bundle del catálogo. Repetible.
97        #[arg(long = "bundle")]
98        bundles: Vec<String>,
99        /// Id de perilla del catálogo. Repetible.
100        #[arg(long = "knob")]
101        knobs: Vec<String>,
102        #[arg(long)]
103        kconfig: Option<PathBuf>,
104        #[arg(long)]
105        catalog: Option<PathBuf>,
106        /// Escribe el plan en JSON (el contrato con las UIs).
107        #[arg(long)]
108        out: Option<PathBuf>,
109        /// Escribe la receta derivada. Para construirla hay que dejarla donde sus `deps.build`
110        /// resuelvan — es decir, junto a la receta base.
111        #[arg(long)]
112        recipe_out: Option<PathBuf>,
113        #[arg(long)]
114        json: bool,
115    },
116    /// **Diff-back**: contrasta un plan contra el `.config` que produjo el kernel.
117    ///
118    /// Sin esto la UI miente: un `-e FOO` cuya dependencia no se cumple se pierde en silencio.
119    DiffBack {
120        /// El JSON que emitió `hammer kernel plan --out`.
121        #[arg(long)]
122        plan: PathBuf,
123        /// El `.config` resultante (el que la receta instala en `/out/boot/config-<versión>`).
124        #[arg(long)]
125        config: PathBuf,
126        #[arg(long)]
127        json: bool,
128    },
129    /// **Gate de no-regresión, por objetivo**: ¿el plan deja sin driver algún dispositivo en uso?
130    ///
131    /// No corre sin `--objective`: la regla aplicada global rechazaría recetas sanas (el kernel de
132    /// QEMU apaga USB, HID e INPUT a propósito). Sale distinto de cero si bloquea.
133    Gate {
134        /// El JSON que emitió `hammer kernel plan --out`. Con `--config` es opcional: sólo sirve
135        /// para atribuir cada pérdida a su bundle.
136        #[arg(long)]
137        plan: Option<PathBuf>,
138        /// `.config` YA PRODUCIDO. Cambia la pregunta: en vez de «¿qué apaga el plan?» mide
139        /// «¿qué funcionaba y deja de funcionar?», que además ve los huecos de la receta base.
140        #[arg(long)]
141        config: Option<PathBuf>,
142        /// Referente contra el que medir la regresión (default `/proc/config.gz`): el kernel que
143        /// arrancó esta máquina es la prueba viva de qué hace falta para arrancarla.
144        #[arg(long)]
145        baseline: Option<PathBuf>,
146        /// Id de objetivo del catálogo (`qemu-serial`, `servidor`, `metal-escritorio`, `portatil`).
147        #[arg(long)]
148        objective: String,
149        /// Hardware de la máquina DESTINO, capturado con `hammer kernel hw --out`. Sin esto se
150        /// mira el de esta máquina, que no siempre es la misma.
151        #[arg(long)]
152        devices: Option<PathBuf>,
153        #[arg(long)]
154        kconfig: Option<PathBuf>,
155        #[arg(long)]
156        catalog: Option<PathBuf>,
157        #[arg(long)]
158        json: bool,
159    },
160    /// Vuelca el hardware de esta máquina (huella + dispositivos + drivers bindeados).
161    ///
162    /// Se corre en la máquina DESTINO y el JSON viaja a la de build: el gate necesita saber qué
163    /// funciona allá, no acá.
164    Hw {
165        #[arg(long)]
166        out: Option<PathBuf>,
167    },
168    /// Estadísticas del árbol de Kconfig parseado (control de salud del lector).
169    Stats {
170        #[arg(long)]
171        kconfig: Option<PathBuf>,
172        /// Muestra las primeras N advertencias del parseo.
173        #[arg(long, default_value_t = 10)]
174        warnings: usize,
175    },
176}
177
178pub fn run(cmd: KernelCmd, store: &str) -> Result<()> {
179    match cmd {
180        KernelCmd::Stats { kconfig, warnings } => stats(&kconfig_root(kconfig)?, warnings),
181        KernelCmd::Probe {
182            config,
183            kconfig,
184            catalog,
185            no_hardware,
186            json,
187        } => probe(
188            config.as_deref(),
189            &kconfig_root(kconfig)?,
190            &catalog_path(catalog)?,
191            !no_hardware,
192            json,
193        ),
194        KernelCmd::Bundles {
195            kconfig,
196            catalog,
197            check,
198            json,
199        } => bundles(&kconfig_root(kconfig)?, &catalog_path(catalog)?, check, json),
200        KernelCmd::Plan {
201            recipe,
202            bundles: bs,
203            knobs,
204            kconfig,
205            catalog,
206            out,
207            recipe_out,
208            json,
209        } => plan(
210            &recipe,
211            &bs,
212            &knobs,
213            &kconfig_root(kconfig)?,
214            &catalog_path(catalog)?,
215            out.as_deref(),
216            recipe_out.as_deref(),
217            store,
218            json,
219        ),
220        KernelCmd::DiffBack { plan, config, json } => diff_back(&plan, &config, json),
221        KernelCmd::Gate {
222            plan,
223            config,
224            baseline,
225            objective,
226            devices,
227            kconfig,
228            catalog,
229            json,
230        } => gate(
231            plan.as_deref(),
232            config.as_deref(),
233            baseline.as_deref(),
234            &objective,
235            devices.as_deref(),
236            &kconfig_root(kconfig)?,
237            &catalog_path(catalog)?,
238            json,
239        ),
240        KernelCmd::Hw { out } => hw(out.as_deref()),
241        KernelCmd::Closure {
242            symbols,
243            kconfig,
244            against,
245            fixpoint,
246            json,
247            limit,
248        } => closure(
249            &symbols,
250            &kconfig_root(kconfig)?,
251            against.as_deref(),
252            fixpoint,
253            json,
254            limit,
255        ),
256    }
257}
258
259/// Resuelve la raíz del árbol de Kconfig: `--kconfig` gana, si no la variable de entorno.
260fn kconfig_root(flag: Option<PathBuf>) -> Result<PathBuf> {
261    if let Some(p) = flag {
262        return Ok(p);
263    }
264    match std::env::var_os(KCONFIG_ENV) {
265        Some(v) => Ok(PathBuf::from(v)),
266        None => anyhow::bail!(
267            "falta el árbol de fuentes del kernel: pasá --kconfig <dir> o exportá {KCONFIG_ENV}"
268        ),
269    }
270}
271
272/// Resuelve el catálogo de bundles: `--catalog`, si no la variable de entorno, si no el del repo.
273fn catalog_path(flag: Option<PathBuf>) -> Result<PathBuf> {
274    if let Some(p) = flag {
275        return Ok(p);
276    }
277    if let Some(v) = std::env::var_os(CATALOG_ENV) {
278        return Ok(PathBuf::from(v));
279    }
280    for c in CATALOG_DEFAULTS {
281        let p = PathBuf::from(c);
282        if p.is_file() {
283            return Ok(p);
284        }
285    }
286    anyhow::bail!(
287        "no encontré el catálogo de bundles: pasá --catalog <path> o exportá {CATALOG_ENV}"
288    )
289}
290
291/// Dónde está el `.config` del kernel que corre. `/proc/config.gz` es el camino soberano (lo emite
292/// el propio kernel); el de `/boot` es el respaldo para kernels compilados sin `IKCONFIG_PROC`.
293fn running_config_path() -> Result<PathBuf> {
294    let gz = PathBuf::from("/proc/config.gz");
295    if gz.is_file() {
296        return Ok(gz);
297    }
298    if let Ok(out) = std::process::Command::new("uname").arg("-r").output() {
299        let rel = String::from_utf8_lossy(&out.stdout).trim().to_string();
300        let p = PathBuf::from(format!("/boot/config-{rel}"));
301        if p.is_file() {
302            return Ok(p);
303        }
304    }
305    anyhow::bail!(
306        "no encontré el config del kernel vivo (ni /proc/config.gz ni /boot/config-<release>).\n\
307         Pasá --config <path> para mirar el de otra máquina."
308    )
309}
310
311fn load_tree(root: &Path) -> Result<KconfigTree> {
312    if !root.join("Kconfig").is_file() {
313        anyhow::bail!(
314            "{} no parece un árbol de fuentes de kernel (falta ./Kconfig).\n\
315             Pasá --kconfig <dir> o exportá {KCONFIG_ENV}.",
316            root.display()
317        );
318    }
319    KconfigTree::parse(root, &KconfigTree::vars_x86_64())
320        .with_context(|| format!("parseando {}", root.display()))
321}
322
323fn stats(root: &Path, show_warnings: usize) -> Result<()> {
324    let t = load_tree(root)?;
325    let visible = t.symbols.values().filter(|s| s.is_visible()).count();
326    let multi = t.symbols.values().filter(|s| s.defs.len() > 1).count();
327    let with_help = t.symbols.values().filter(|s| s.help().is_some()).count();
328    let selects: usize = t.symbols.values().map(|s| s.select_targets().len()).sum();
329    let implies: usize = t.symbols.values().map(|s| s.imply_targets().len()).sum();
330
331    println!("árbol      {}", root.display());
332    println!("ficheros   {}", t.files.len());
333    println!("símbolos   {}", t.symbols.len());
334    println!("  visibles {visible} (marcables en menuconfig)");
335    println!("  con ayuda {with_help}");
336    println!("  definidos en >1 sitio {multi}");
337    println!("aristas    select {selects} · imply {implies}");
338    println!("avisos     {}", t.warnings.len());
339    for w in t.warnings.iter().take(show_warnings) {
340        println!("  · {w}");
341    }
342    if t.warnings.len() > show_warnings {
343        println!("  … y {} más", t.warnings.len() - show_warnings);
344    }
345    Ok(())
346}
347
348#[derive(Serialize)]
349struct ClosureReport {
350    roots: Vec<String>,
351    /// Todo lo que queda inalcanzable si se apagan las raíces (incluidas las raíces).
352    closure: Vec<String>,
353    /// De la clausura, los que un humano puede marcar en `menuconfig`.
354    visible: usize,
355    /// `select` que entran desde fuera: reencienden algo de la clausura ignorando sus `depends on`.
356    leaks: Vec<hammer_core::kernel::SelectLeak>,
357    /// Símbolos distintos de la clausura alcanzados por esas fugas.
358    leaked_targets: usize,
359    #[serde(skip_serializing_if = "Option::is_none")]
360    fixpoint: Option<FixpointReport>,
361    #[serde(skip_serializing_if = "Option::is_none")]
362    against: Option<AgainstReport>,
363}
364
365/// El precio de cerrar el bundle contra sus fugas.
366#[derive(Serialize)]
367struct FixpointReport {
368    /// La clausura ya cerrada, entera.
369    closure: Vec<String>,
370    /// Tamaño de la clausura una vez cerrada.
371    closure_len: usize,
372    /// Cuánto creció respecto de la clausura estricta.
373    grew_by: usize,
374    rounds: usize,
375    /// Lo que hubo que apagar de más, con la ronda en que apareció.
376    added: Vec<(usize, String)>,
377    /// Fugas que sobrevivieron al tope de rondas. Vacío = cerró.
378    remaining_leaks: Vec<hammer_core::kernel::SelectLeak>,
379}
380
381/// Contraste contra un `.config` real: la validación §3 del SDD 22.
382#[derive(Serialize)]
383struct AgainstReport {
384    config: String,
385    /// De la clausura, los que el `.config` tiene ENCENDIDOS (la clausura predice de más, o el
386    /// config tiene una fuga real).
387    on_in_config: Vec<String>,
388    /// De la clausura, los que el `.config` apaga explícitamente.
389    off_in_config: usize,
390    /// De la clausura, los que el `.config` ni menciona.
391    absent_in_config: usize,
392}
393
394fn closure(
395    roots: &[String],
396    kroot: &Path,
397    against: Option<&Path>,
398    fixpoint: bool,
399    json: bool,
400    limit: usize,
401) -> Result<()> {
402    let t = load_tree(kroot)?;
403
404    // Avisar de raíces que no existen: un símbolo mal escrito daría clausura vacía en silencio,
405    // que es exactamente el modo de fallo que este comando existe para no tener.
406    let desconocidas: Vec<&String> = roots.iter().filter(|r| !t.symbols.contains_key(*r)).collect();
407    if !desconocidas.is_empty() {
408        eprintln!(
409            "aviso: {} símbolo(s) no existen en este árbol: {}",
410            desconocidas.len(),
411            desconocidas
412                .iter()
413                .map(|s| s.as_str())
414                .collect::<Vec<_>>()
415                .join(", ")
416        );
417    }
418
419    let cl = t.closure_off(roots);
420    let leaks = t.select_leaks(&cl);
421    let leaked_targets = leaks
422        .iter()
423        .map(|l| l.target.as_str())
424        .collect::<BTreeSet<_>>()
425        .len();
426    let visible = cl
427        .iter()
428        .filter(|s| t.symbols.get(*s).is_some_and(|s| s.is_visible()))
429        .count();
430
431    let fx = fixpoint.then(|| {
432        let f = t.closure_off_fixpoint(roots, 16);
433        FixpointReport {
434            closure_len: f.closure.len(),
435            closure: f.closure.iter().cloned().collect(),
436            grew_by: f.closure.len().saturating_sub(cl.len()),
437            rounds: f.rounds,
438            added: f.added,
439            remaining_leaks: f.remaining_leaks,
440        }
441    });
442
443    let against_report = match against {
444        None => None,
445        Some(p) => {
446            let text = read_maybe_gz(p)?;
447            let cfg = KernelConfig::parse(&text);
448            let on: Vec<String> = cl.iter().filter(|s| cfg.is_on(s)).cloned().collect();
449            let off = cl
450                .iter()
451                .filter(|s| matches!(cfg.get(s), Some(hammer_core::kernel::ConfigValue::No)))
452                .count();
453            let absent = cl.iter().filter(|s| cfg.get(s).is_none()).count();
454            Some(AgainstReport {
455                config: p.display().to_string(),
456                on_in_config: on,
457                off_in_config: off,
458                absent_in_config: absent,
459            })
460        }
461    };
462
463    let rep = ClosureReport {
464        roots: roots.to_vec(),
465        closure: cl.iter().cloned().collect(),
466        visible,
467        leaks,
468        leaked_targets,
469        fixpoint: fx,
470        against: against_report,
471    };
472
473    if json {
474        println!("{}", serde_json::to_string_pretty(&rep)?);
475        return Ok(());
476    }
477
478    println!("raíces apagadas   {}", rep.roots.join(" "));
479    println!(
480        "clausura          {} símbolo(s) ({} visibles en menuconfig)",
481        rep.closure.len(),
482        rep.visible
483    );
484    for s in rep.closure.iter().take(limit) {
485        let prompt = t
486            .symbols
487            .get(s)
488            .and_then(|x| x.prompt())
489            .unwrap_or("(sin prompt: sólo entra por select)");
490        println!("  · {s} — {prompt}");
491    }
492    if rep.closure.len() > limit {
493        println!("  … y {} más", rep.closure.len() - limit);
494    }
495
496    println!();
497    if rep.leaks.is_empty() {
498        println!("fugas por `select`  ninguna — la clausura es cerrada");
499    } else {
500        println!(
501            "fugas por `select`  {} arista(s) desde fuera reencienden {} símbolo(s) de la clausura",
502            rep.leaks.len(),
503            rep.leaked_targets
504        );
505        println!("  (`select` FUERZA el destino ignorando sus `depends on`: apagar la raíz no basta)");
506        let mut por_destino: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
507        for l in &rep.leaks {
508            por_destino
509                .entry(&l.target)
510                .or_default()
511                .push(&l.selector);
512        }
513        for (target, selectors) in por_destino.iter().take(limit) {
514            println!("  · {target} ← {}", selectors.join(", "));
515        }
516        if por_destino.len() > limit {
517            println!("  … y {} destino(s) más", por_destino.len() - limit);
518        }
519    }
520
521    if let Some(f) = &rep.fixpoint {
522        println!();
523        println!(
524            "punto fijo        {} símbolo(s) tras {} ronda(s) — {:+} sobre la clausura estricta",
525            f.closure_len, f.rounds, f.grew_by as i64
526        );
527        if f.remaining_leaks.is_empty() {
528            println!("  cerró: ninguna fuga sobrevive");
529        } else {
530            println!(
531                "  ⚠ NO cerró: {} fuga(s) siguen abiertas tras el tope de rondas",
532                f.remaining_leaks.len()
533            );
534        }
535        if f.added.is_empty() {
536            println!("  no hizo falta apagar nada de más");
537        } else {
538            println!("  precio: {} símbolo(s) apagados de más", f.added.len());
539            for (r, s) in f.added.iter().take(limit) {
540                println!("    · ronda {r}: {s}");
541            }
542            if f.added.len() > limit {
543                println!("    … y {} más", f.added.len() - limit);
544            }
545        }
546    }
547
548    if let Some(a) = &rep.against {
549        println!();
550        println!("contraste contra {}", a.config);
551        println!("  apagados explícitos  {}", a.off_in_config);
552        println!("  ausentes             {}", a.absent_in_config);
553        println!("  ENCENDIDOS           {}", a.on_in_config.len());
554        for s in a.on_in_config.iter().take(limit) {
555            println!("    · {s}");
556        }
557        if a.on_in_config.len() > limit {
558            println!("    … y {} más", a.on_in_config.len() - limit);
559        }
560    }
561    Ok(())
562}
563
564// ---------------------------------------------------------------------------------------------
565// probe — el modo reversa (#9)
566// ---------------------------------------------------------------------------------------------
567
568fn probe(
569    config: Option<&Path>,
570    kroot: &Path,
571    catalog: &Path,
572    with_hw: bool,
573    json: bool,
574) -> Result<()> {
575    use hammer_core::kernel::reverse::{BundleState, HwSignal};
576
577    let cfg_path = match config {
578        Some(p) => p.to_path_buf(),
579        None => running_config_path()?,
580    };
581    let cfg = KernelConfig::parse(&read_maybe_gz(&cfg_path)?);
582    if cfg.is_empty() {
583        anyhow::bail!("{} no tiene un solo CONFIG_*: ¿es un .config?", cfg_path.display());
584    }
585    let tree = load_tree(kroot)?;
586    let cat = hammer_core::kernel::Catalog::load(catalog)?;
587    let hw = with_hw.then(hammer_core::kernel::Hardware::probe_local);
588    let rep = hammer_core::kernel::reverse::analyze(&cat, &tree, &cfg, hw.as_ref());
589
590    if json {
591        println!("{}", serde_json::to_string_pretty(&rep)?);
592        return Ok(());
593    }
594
595    println!("config      {}", cfg_path.display());
596    if let Some(k) = &rep.kernel {
597        println!("kernel      {k}");
598    }
599    // El modo reversa cruza DOS árboles: el del config vivo y el que se parseó. Si no son la misma
600    // versión, las clausuras son aproximadas — un símbolo puede haberse renombrado o retirado entre
601    // medias (que es justo lo que le pasó a THUNDERBOLT y a REISERFS_FS). Vale igual, pero decirlo.
602    if let (Some(banner), Some(rev)) = (&rep.kernel, &cat.reviewed_against) {
603        if let (Some(a), Some(b)) = (serie(banner), serie(rev)) {
604            if a != b {
605                println!(
606                    "            ⚠ el config vivo es de la serie {a} y el catálogo se revisó contra \
607                     la {b}: las clausuras son aproximadas"
608                );
609            }
610        }
611    }
612    println!("símbolos    {} declarados", rep.config_symbols);
613    if let Some(h) = &hw {
614        println!("huella      {}", rep.fingerprint);
615        if let Some(m) = &h.cpu_model {
616            println!("            {m}");
617        }
618        let maquina = [h.dmi.get("sys_vendor"), h.dmi.get("product_name")]
619            .into_iter()
620            .flatten()
621            .cloned()
622            .collect::<Vec<_>>()
623            .join(" ");
624        if !maquina.is_empty() {
625            println!("            {maquina}");
626        }
627        println!(
628            "            {} dispositivos PCI · {} drivers bindeados",
629            h.pci.len(),
630            h.bound_drivers.len()
631        );
632        for g in &h.gaps {
633            println!("            ⚠ {g}");
634        }
635    }
636    println!();
637
638    for b in &rep.bundles {
639        let on = b.on_builtin + b.on_module;
640        let estado = match b.state {
641            BundleState::Aplicado => "APLICADO ",
642            BundleState::Parcial => "parcial  ",
643            BundleState::Sin => "sin      ",
644        };
645        let hwtxt = match &b.hardware {
646            HwSignal::SinSeñal => String::new(),
647            HwSignal::Ausente => "  · hardware ausente".to_string(),
648            HwSignal::Presente { evidencia } => {
649                format!("  · ⚠ HARDWARE PRESENTE ({})", evidencia.len())
650            }
651        };
652        println!(
653            "{estado} {:<28} {on:>5}/{:<5} encendidos{hwtxt}",
654            b.id, b.closure_len
655        );
656        if let HwSignal::Presente { evidencia } = &b.hardware {
657            for e in evidencia.iter().take(4) {
658                println!("              {e}");
659            }
660        }
661        if !b.unknown_symbols.is_empty() {
662            println!(
663                "              ⚠ símbolos que este kernel no tiene: {}",
664                b.unknown_symbols.join(", ")
665            );
666        }
667        if !b.undeclared_leaks.is_empty() {
668            println!(
669                "              ⚠ {} fuga(s) `select` sin declarar en el catálogo",
670                b.undeclared_leaks.len()
671            );
672        }
673    }
674
675    println!();
676    println!(
677        "resumen     {} bundle(s): {} ya aplicados, {} con capacidad que este hardware NO usa",
678        rep.bundles.len(),
679        rep.bundles
680            .iter()
681            .filter(|b| b.state == BundleState::Aplicado)
682            .count(),
683        rep.applicable_savings
684    );
685    if rep.contradictions > 0 {
686        println!(
687            "            ⚠ {} bundle(s) aplicados CONTRADICEN al hardware presente",
688            rep.contradictions
689        );
690    }
691    println!("            (detectar sirve para contradecir, nunca para podar solo)");
692    Ok(())
693}
694
695/// Serie `mayor.menor` de un texto que contenga una versión (`"Linux/x86 7.1.4-artix1 …"` → `7.1`,
696/// `"linux-6.16.12"` → `6.16`). `None` si no hay ninguna.
697fn serie(texto: &str) -> Option<String> {
698    for tok in texto.split(|c: char| !(c.is_ascii_digit() || c == '.')) {
699        let mut it = tok.split('.');
700        if let (Some(a), Some(b)) = (it.next(), it.next()) {
701            if !a.is_empty() && !b.is_empty() {
702                return Some(format!("{a}.{b}"));
703            }
704        }
705    }
706    None
707}
708
709// ---------------------------------------------------------------------------------------------
710// bundles — catálogo + control de frescura
711// ---------------------------------------------------------------------------------------------
712
713#[derive(Serialize)]
714struct BundleView {
715    id: String,
716    title: String,
717    #[serde(skip_serializing_if = "Option::is_none")]
718    help: Option<String>,
719    disable: Vec<String>,
720    close_leaks: Vec<String>,
721    closure_len: usize,
722    visible: usize,
723    unknown_symbols: Vec<String>,
724    undeclared_leaks: Vec<hammer_core::kernel::SelectLeak>,
725    accepted_leaks: usize,
726}
727
728#[derive(Serialize)]
729struct BundlesReport {
730    catalog: String,
731    #[serde(skip_serializing_if = "Option::is_none")]
732    reviewed_against: Option<String>,
733    bundles: Vec<BundleView>,
734    knobs: Vec<KnobView>,
735    stale: bool,
736}
737
738#[derive(Serialize)]
739struct KnobView {
740    id: String,
741    title: String,
742    side: hammer_core::kernel::Side,
743    enable: Vec<String>,
744    disable: Vec<String>,
745    #[serde(skip_serializing_if = "Option::is_none")]
746    recipe_field: Option<String>,
747    unknown_symbols: Vec<String>,
748}
749
750fn bundles(kroot: &Path, catalog: &Path, check: bool, json: bool) -> Result<()> {
751    let tree = load_tree(kroot)?;
752    let cat = hammer_core::kernel::Catalog::load(catalog)?;
753
754    let mut views = Vec::new();
755    for b in &cat.bundles {
756        let mut roots = b.disable.clone();
757        roots.extend(b.close_leaks.iter().cloned());
758        let unknown: Vec<String> = roots
759            .iter()
760            .chain(b.enable.iter())
761            .filter(|s| !tree.symbols.contains_key(*s))
762            .cloned()
763            .collect();
764        let cl = tree.closure_off(&roots);
765        let aceptadas: BTreeSet<&str> = b.accept_leaks.keys().map(|s| s.as_str()).collect();
766        let undeclared: Vec<_> = tree
767            .select_leaks(&cl)
768            .into_iter()
769            .filter(|l| !aceptadas.contains(l.target.as_str()))
770            .collect();
771        views.push(BundleView {
772            id: b.id.clone(),
773            title: b.title.clone(),
774            help: b.help.clone(),
775            disable: b.disable.clone(),
776            close_leaks: b.close_leaks.clone(),
777            closure_len: cl.len(),
778            visible: cl
779                .iter()
780                .filter(|s| tree.symbols.get(*s).is_some_and(|x| x.is_visible()))
781                .count(),
782            unknown_symbols: unknown,
783            undeclared_leaks: undeclared,
784            accepted_leaks: b.accept_leaks.len(),
785        });
786    }
787
788    let knobs: Vec<KnobView> = cat
789        .knobs
790        .iter()
791        .map(|k| KnobView {
792            id: k.id.clone(),
793            title: k.title.clone(),
794            side: k.side,
795            enable: k.enable.clone(),
796            disable: k.disable.clone(),
797            recipe_field: k.recipe_field.clone(),
798            unknown_symbols: k
799                .enable
800                .iter()
801                .chain(k.disable.iter())
802                .filter(|s| !tree.symbols.contains_key(*s))
803                .cloned()
804                .collect(),
805        })
806        .collect();
807
808    let stale = views
809        .iter()
810        .any(|v| !v.unknown_symbols.is_empty() || !v.undeclared_leaks.is_empty())
811        || knobs.iter().any(|k| !k.unknown_symbols.is_empty());
812
813    let rep = BundlesReport {
814        catalog: catalog.display().to_string(),
815        reviewed_against: cat.reviewed_against.clone(),
816        bundles: views,
817        knobs,
818        stale,
819    };
820
821    if json {
822        println!("{}", serde_json::to_string_pretty(&rep)?);
823    } else {
824        println!("catálogo    {}", rep.catalog);
825        if let Some(r) = &rep.reviewed_against {
826            println!("revisado contra {r}");
827        }
828        println!("árbol       {}", kroot.display());
829        println!();
830        println!("NIVEL 1 — aserciones del dueño de la máquina");
831        for v in &rep.bundles {
832            println!(
833                "  {:<26} {:>5} símbolos ({} visibles)  ← {}",
834                v.id,
835                v.closure_len,
836                v.visible,
837                v.disable.join(" ")
838            );
839            println!("     {}", v.title);
840            if !v.close_leaks.is_empty() {
841                println!("     fugas cerradas: {}", v.close_leaks.join(", "));
842            }
843            if v.accepted_leaks > 0 {
844                println!("     fugas aceptadas a sabiendas: {}", v.accepted_leaks);
845            }
846            if !v.unknown_symbols.is_empty() {
847                println!(
848                    "     ⚠ NO EXISTEN en este árbol: {}",
849                    v.unknown_symbols.join(", ")
850                );
851            }
852            for l in v.undeclared_leaks.iter().take(6) {
853                println!("     ⚠ fuga sin declarar: {} ← {}", l.target, l.selector);
854            }
855            if v.undeclared_leaks.len() > 6 {
856                println!("     ⚠ … y {} fuga(s) más", v.undeclared_leaks.len() - 6);
857            }
858        }
859        println!();
860        println!("NIVEL 2 — política y rendimiento (ojo al lado del que cae cada perilla)");
861        for k in &rep.knobs {
862            let lado = match k.side {
863                hammer_core::kernel::Side::Kconfig => "kconfig",
864                hammer_core::kernel::Side::Recipe => "receta ",
865            };
866            let que = match k.side {
867                hammer_core::kernel::Side::Kconfig => {
868                    let mut v: Vec<String> =
869                        k.enable.iter().map(|s| format!("+{s}")).collect();
870                    v.extend(k.disable.iter().map(|s| format!("-{s}")));
871                    v.join(" ")
872                }
873                hammer_core::kernel::Side::Recipe => {
874                    k.recipe_field.clone().unwrap_or_default()
875                }
876            };
877            println!("  [{lado}] {:<26} {que}", k.id);
878            println!("     {}", k.title);
879            if !k.unknown_symbols.is_empty() {
880                println!(
881                    "     ⚠ NO EXISTEN en este árbol: {}",
882                    k.unknown_symbols.join(", ")
883                );
884            }
885        }
886        println!();
887        if rep.stale {
888            println!("⚠ el catálogo ENVEJECIÓ respecto de este árbol (símbolos idos o fugas nuevas)");
889        } else {
890            println!("✓ el catálogo está al día con este árbol");
891        }
892    }
893
894    if check && rep.stale {
895        anyhow::bail!("catálogo desactualizado");
896    }
897    Ok(())
898}
899
900// ---------------------------------------------------------------------------------------------
901// plan / diff-back
902// ---------------------------------------------------------------------------------------------
903
904#[allow(clippy::too_many_arguments)]
905fn plan(
906    recipe_path: &Path,
907    bundle_ids: &[String],
908    knob_ids: &[String],
909    kroot: &Path,
910    catalog: &Path,
911    out: Option<&Path>,
912    recipe_out: Option<&Path>,
913    store: &str,
914    json: bool,
915) -> Result<()> {
916    if bundle_ids.is_empty() && knob_ids.is_empty() {
917        anyhow::bail!("un plan sin bundles ni perillas es la receta base: pasá --bundle/--knob");
918    }
919    let base = hammer_core::Recipe::load_from_path(recipe_path)?;
920    let tree = load_tree(kroot)?;
921    let cat = hammer_core::kernel::Catalog::load(catalog)?;
922
923    // Las recetas de kernel del repo NO son todas la misma versión (`linux`/`linux-metal` van por
924    // 6.16.12; `linux-metal-dual`/`linux-generic` por 7.1.2). Planear una contra el árbol de la otra
925    // calcularía clausuras sobre símbolos que ahí no existen, y saldría SIN RUIDO. Como el plan
926    // determina un artefacto, acá se falla en vez de avisar.
927    match tree.version.as_deref() {
928        Some(v) if v != base.version => anyhow::bail!(
929            "la receta «{}» es del kernel {} y el árbol de {} es el {v}.\n\
930             Las clausuras saldrían calculadas sobre otro juego de símbolos. Pasá el árbol correcto \
931             con --kconfig.",
932            base.name,
933            base.version,
934            kroot.display()
935        ),
936        None => eprintln!(
937            "aviso: {} no trae el Makefile de arriba, así que no puedo comprobar que sea el kernel \
938             {} de la receta «{}»",
939            kroot.display(),
940            base.version,
941            base.name
942        ),
943        Some(_) => {}
944    }
945
946    let mut p = hammer_core::kernel::plan::make_plan(
947        &base,
948        &recipe_path.display().to_string(),
949        &cat,
950        &tree,
951        bundle_ids,
952        knob_ids,
953    )?;
954
955    // El plan DETERMINA el artefacto (SDD 22 §1), así que el hash va en el plan: la UI puede decir
956    // «esto ya está construido y firmado» sin construir nada. Si las deps no están resueltas, se
957    // dice que no se pudo en vez de emitir un hash inventado.
958    let derived = hammer_core::kernel::plan::derive_recipe(&base, &p);
959    match hammer_core::Store::open(Path::new(store))
960        .map_err(anyhow::Error::from)
961        .and_then(|s| {
962            let cfg = hammer_build::BuildConfig::from_env_or_defaults(s.root());
963            let lab = hammer_core::lab::LabFingerprint::from_rootfs(&cfg.rootfs)
964                .map_err(anyhow::Error::from)?;
965            hammer_build::artifact_hash(&derived, &s, &lab).map_err(anyhow::Error::from)
966        })
967    {
968        Ok(h) => p.artifact_hash = Some(h.to_string()),
969        Err(e) => eprintln!("aviso: no pude calcular el ArtifactHash ({e})"),
970    }
971
972    if let Some(o) = out {
973        if let Some(d) = o.parent() {
974            std::fs::create_dir_all(d).ok();
975        }
976        std::fs::write(o, serde_json::to_string_pretty(&p)?)
977            .with_context(|| format!("escribiendo {}", o.display()))?;
978    }
979    if let Some(ro) = recipe_out {
980        if let Some(d) = ro.parent() {
981            std::fs::create_dir_all(d).ok();
982        }
983        let cabecera = format!(
984            "# Receta DERIVADA, generada por `hammer kernel plan`. No editar a mano: regenerar.\n\
985             #   base    {}\n#   bundles {}\n#   perillas {}\n\
986             # Para construirla tiene que vivir junto a la receta base, o sus `deps.build` no\n\
987             # resuelven ({}).\n",
988            p.base_recipe,
989            if p.bundles.is_empty() { "—".into() } else { p.bundles.join(" ") },
990            if p.knobs.is_empty() { "—".into() } else { p.knobs.join(" ") },
991            recipe_path
992                .parent()
993                .map(|d| d.display().to_string())
994                .unwrap_or_default(),
995        );
996        let cuerpo = hammer_core::kernel::plan::render_recipe_toml(&derived)?;
997        std::fs::write(ro, format!("{cabecera}{cuerpo}"))
998            .with_context(|| format!("escribiendo {}", ro.display()))?;
999    }
1000
1001    if json {
1002        println!("{}", serde_json::to_string_pretty(&p)?);
1003        return Ok(());
1004    }
1005
1006    println!("base        {} ({})", p.base_name, p.base_recipe);
1007    println!("derivada    {}", p.derived_name);
1008    match &p.artifact_hash {
1009        Some(h) => println!("hash        {h}"),
1010        None => println!("hash        (no calculado)"),
1011    }
1012    println!(
1013        "selección   {} bundle(s), {} perilla(s)",
1014        p.bundles.len(),
1015        p.knobs.len()
1016    );
1017    println!();
1018    println!("emite {} bandera(s); con su clausura caen {} símbolo(s)", p.symbols.len(), p.closure_size);
1019    println!("(se emiten RAÍCES: la clausura la calcula el olddefconfig del propio kernel)");
1020    for s in &p.symbols {
1021        let signo = match s.action {
1022            hammer_core::kernel::plan::Action::Enable => "+",
1023            hammer_core::kernel::plan::Action::Disable => "-",
1024        };
1025        println!("  {signo}{:<24} ← {}  ({})", s.symbol, s.from, s.reason);
1026    }
1027    if !p.unknown_symbols.is_empty() {
1028        println!();
1029        println!("⚠ {} símbolo(s) pedidos NO EXISTEN en este árbol y no se emiten:", p.unknown_symbols.len());
1030        for s in &p.unknown_symbols {
1031            println!("  {} ← {}", s.symbol, s.from);
1032        }
1033    }
1034    if !p.recipe_side.is_empty() {
1035        println!();
1036        println!("perillas del lado RECETA (declaradas, este plan no las aplica):");
1037        for k in &p.recipe_side {
1038            println!("  {:<24} {} — {}", k.id, k.recipe_field, k.title);
1039        }
1040    }
1041    println!();
1042    if let Some(o) = out {
1043        println!("plan     → {}", o.display());
1044    }
1045    if let Some(ro) = recipe_out {
1046        println!("receta   → {}", ro.display());
1047    }
1048    Ok(())
1049}
1050
1051fn diff_back(plan_path: &Path, config: &Path, json: bool) -> Result<()> {
1052    let p: hammer_core::kernel::Plan = serde_json::from_str(
1053        &std::fs::read_to_string(plan_path)
1054            .with_context(|| format!("leyendo {}", plan_path.display()))?,
1055    )
1056    .with_context(|| format!("{} no es un plan de `hammer kernel plan`", plan_path.display()))?;
1057    let cfg = KernelConfig::parse(&read_maybe_gz(config)?);
1058    if cfg.is_empty() {
1059        anyhow::bail!("{} no tiene un solo CONFIG_*: ¿es un .config?", config.display());
1060    }
1061    let d = hammer_core::kernel::diff_back(&p, &cfg);
1062
1063    if json {
1064        println!("{}", serde_json::to_string_pretty(&d)?);
1065    } else {
1066        println!("plan        {} ({})", p.derived_name, plan_path.display());
1067        println!("config      {}", config.display());
1068        println!("cumplidos   {}", d.honored.len());
1069        // Kconfig no emite un símbolo cuyas dependencias no se cumplen: para un `-d`, que no
1070        // aparezca ES el resultado buscado. Contarlo como fallo reportaba planes perfectos como
1071        // rotos — lo aprendí corriendo un olddefconfig de verdad.
1072        println!("apagados por dependencia  {} (no aparecen porque cayeron con su raíz)", d.off_by_dependency.len());
1073        if !d.never_existed.is_empty() {
1074            println!("no existen en este árbol  {}", d.never_existed.len());
1075            for s in &d.never_existed {
1076                println!("  {s} — la bandera no hace nada");
1077            }
1078        }
1079        println!("INCUMPLIDOS {}", d.violated.len());
1080        for v in &d.violated {
1081            println!(
1082                "  {} — se pidió {:?} y quedó {} (lo pidió {})",
1083                v.symbol, v.asked, v.got, v.from
1084            );
1085        }
1086    }
1087    if !d.is_clean() {
1088        anyhow::bail!("el .config no honra el plan");
1089    }
1090    Ok(())
1091}
1092
1093// ---------------------------------------------------------------------------------------------
1094// gate / hw
1095// ---------------------------------------------------------------------------------------------
1096
1097fn hw(out: Option<&Path>) -> Result<()> {
1098    let h = hammer_core::kernel::Hardware::probe_local();
1099    let texto = serde_json::to_string_pretty(&h)?;
1100    match out {
1101        Some(o) => {
1102            if let Some(d) = o.parent() {
1103                std::fs::create_dir_all(d).ok();
1104            }
1105            std::fs::write(o, &texto).with_context(|| format!("escribiendo {}", o.display()))?;
1106            println!("huella   {}", h.fingerprint());
1107            println!("hardware → {}", o.display());
1108            println!("  {} PCI · {} USB · {} drivers bindeados", h.pci.len(), h.usb.len(), h.bound_drivers.len());
1109            for g in &h.gaps {
1110                println!("  ⚠ {g}");
1111            }
1112        }
1113        None => println!("{texto}"),
1114    }
1115    Ok(())
1116}
1117
1118#[allow(clippy::too_many_arguments)]
1119fn gate(
1120    plan_path: Option<&Path>,
1121    config: Option<&Path>,
1122    baseline: Option<&Path>,
1123    objective: &str,
1124    devices: Option<&Path>,
1125    kroot: &Path,
1126    catalog: &Path,
1127    json: bool,
1128) -> Result<()> {
1129    if plan_path.is_none() && config.is_none() {
1130        anyhow::bail!("pasá --plan (qué apagaría) o --config (qué quedó): sin uno de los dos no hay nada que comparar");
1131    }
1132    let p: Option<hammer_core::kernel::Plan> = match plan_path {
1133        None => None,
1134        Some(pp) => Some(
1135            serde_json::from_str(
1136                &std::fs::read_to_string(pp).with_context(|| format!("leyendo {}", pp.display()))?,
1137            )
1138            .with_context(|| format!("{} no es un plan de `hammer kernel plan`", pp.display()))?,
1139        ),
1140    };
1141    let cat = hammer_core::kernel::Catalog::load(catalog)?;
1142    let tree = load_tree(kroot)?;
1143    let modmap = hammer_core::kernel::ModMap::parse(kroot)
1144        .with_context(|| format!("leyendo los Makefiles de {}", kroot.display()))?;
1145    if modmap.is_empty() {
1146        anyhow::bail!(
1147            "no encontré una sola regla `obj-$(CONFIG_…)` en {}: sin los Makefiles del kernel el \
1148             gate no puede mapear driver → símbolo, y un gate que no puede mirar no es un gate.",
1149            kroot.display()
1150        );
1151    }
1152
1153    let hardware = match devices {
1154        Some(d) => serde_json::from_str::<hammer_core::kernel::Hardware>(
1155            &std::fs::read_to_string(d).with_context(|| format!("leyendo {}", d.display()))?,
1156        )
1157        .with_context(|| format!("{} no es un volcado de `hammer kernel hw`", d.display()))?,
1158        None => hammer_core::kernel::Hardware::probe_local(),
1159    };
1160
1161    let r = match config {
1162        // Modo fuerte: comparar dos configs reales. Ve también los huecos que ya venían en la base.
1163        Some(c) => {
1164            let base_path = baseline
1165                .map(|b| b.to_path_buf())
1166                .map_or_else(running_config_path, Ok)?;
1167            let base = KernelConfig::parse(&read_maybe_gz(&base_path)?);
1168            let nuevo = KernelConfig::parse(&read_maybe_gz(c)?);
1169            if nuevo.is_empty() {
1170                anyhow::bail!("{} no tiene un solo CONFIG_*: ¿es un .config?", c.display());
1171            }
1172            println!("referente   {} ({} símbolos)", base_path.display(), base.len());
1173            hammer_core::kernel::gate::run_against_config(
1174                &base,
1175                &nuevo,
1176                &cat,
1177                &modmap,
1178                &hardware.bound_drivers,
1179                objective,
1180                p.as_ref(),
1181                Some(&tree),
1182            )?
1183        }
1184        None => hammer_core::kernel::gate::run(
1185            p.as_ref().expect("validado arriba"),
1186            &cat,
1187            &tree,
1188            &modmap,
1189            &hardware.bound_drivers,
1190            objective,
1191        )?,
1192    };
1193
1194    if json {
1195        println!("{}", serde_json::to_string_pretty(&r)?);
1196    } else {
1197        let obj = cat.objective(objective).map(|o| o.title.as_str()).unwrap_or("");
1198        if !r.plan.is_empty() {
1199            println!("plan        {}", r.plan);
1200        }
1201        println!("objetivo    {} — {obj}", r.objective);
1202        println!(
1203            "hardware    {} ({} drivers bindeados, {} reglas obj- en {} Makefiles)",
1204            devices.map(|d| d.display().to_string()).unwrap_or_else(|| "esta máquina".into()),
1205            r.drivers_in_use,
1206            modmap.len(),
1207            modmap.files
1208        );
1209        println!();
1210        if r.regressions.is_empty() {
1211            println!("✓ ningún dispositivo en uso se queda sin driver");
1212        } else {
1213            println!("✗ {} REGRESIÓN(ES) — dispositivos en uso que se quedarían sin driver:", r.regressions.len());
1214            for l in &r.regressions {
1215                // Sin culpable atribuido la causa NO es el plan: es que la receta base nunca
1216                // enciende ese símbolo. Decir «lo apaga una perilla» mandaría a buscar donde no está.
1217                let causa = if l.blamed_on.is_empty() {
1218                    "la receta base no lo enciende".to_string()
1219                } else {
1220                    format!("lo apaga {}", l.blamed_on.join(", "))
1221                };
1222                println!("  {:<20} {causa}  (símbolos: {})", l.driver, l.symbols.join(", "));
1223            }
1224        }
1225        if !r.allowed.is_empty() {
1226            println!();
1227            println!("{} pérdida(s) AUTORIZADAS por el objetivo:", r.allowed.len());
1228            for l in &r.allowed {
1229                println!("  {:<20} ← {}", l.driver, l.blamed_on.join(", "));
1230            }
1231        }
1232        if !r.unmapped.is_empty() {
1233            println!();
1234            println!(
1235                "⚠ {} driver(s) SIN COMPROBAR (no se pudieron mapear a ningún CONFIG_*):",
1236                r.unmapped.len()
1237            );
1238            println!("  {}", r.unmapped.join(" "));
1239            println!("  no cuentan como aprobados: el gate no los miró.");
1240        }
1241    }
1242
1243    if r.verdict == hammer_core::kernel::Verdict::Bloquea {
1244        anyhow::bail!("el gate bloquea");
1245    }
1246    Ok(())
1247}
1248
1249/// Lee un `.config`, descomprimiendo si es `.gz` (el caso de `/proc/config.gz`).
1250///
1251/// Se delega a `gzip -dc` en vez de arrastrar un descompresor al core: es una entrada de
1252/// diagnóstico, no del camino de build, y el core queda sin dependencias nuevas.
1253pub fn read_maybe_gz(p: &Path) -> Result<String> {
1254    if p.extension().is_some_and(|e| e == "gz") {
1255        let out = std::process::Command::new("gzip")
1256            .arg("-dc")
1257            .arg(p)
1258            .output()
1259            .with_context(|| format!("descomprimiendo {}", p.display()))?;
1260        if !out.status.success() {
1261            anyhow::bail!(
1262                "gzip -dc {} falló: {}",
1263                p.display(),
1264                String::from_utf8_lossy(&out.stderr).trim()
1265            );
1266        }
1267        return Ok(String::from_utf8_lossy(&out.stdout).into_owned());
1268    }
1269    std::fs::read_to_string(p).with_context(|| format!("leyendo {}", p.display()))
1270}
1271
1272#[cfg(test)]
1273mod tests {
1274    use super::*;
1275
1276    #[test]
1277    fn serie_de_las_dos_formas_que_se_cruzan() {
1278        assert_eq!(serie("Linux/x86 7.1.4-artix1 Kernel Configuration").as_deref(), Some("7.1"));
1279        assert_eq!(serie("linux-6.16.12").as_deref(), Some("6.16"));
1280        assert_eq!(serie("sin versión"), None);
1281    }
1282}