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…
⎇ fe9e06de9e16e96617065d44ac5029b2033b2383
crates/hammer-cli/src/kernel_cmd.rs
History
Who touched what
raw
kernel: el gate contra un .config producido — ve los huecos que la receta base ya traía
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}