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…
⎇ f41022d81f87b38135f1faca5821e31ceaf7ee90
crates/hammer-cli/src/main.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
Fase 5 — bus de agente (/run/agent.sock) + FIFO de control humano
1//! `hammer` — el punto de entrada de todo flujo manual. Ver `docs/`.
2//!
3//! Los subcomandos están mapeados a las fases del roadmap (`docs/10-roadmap.md`). Los que aún
4//! no están implementados devuelven un error claro indicando su fase, para que el esqueleto
5//! sea navegable desde el día uno.
6
7use std::path::PathBuf;
8
9use clap::{Parser, Subcommand};
10
11const DEFAULT_STORE: &str = "/store";
12
13#[derive(Parser)]
14#[command(
15 name = "hammer",
16 version,
17 about = "Laboratorio funcional en el sótano, terminal mutable arriba.",
18 long_about = "hammer: distro AI-nativa. Ver docs/ para el diseño completo."
19)]
20struct Cli {
21 /// Ruta del content-addressed store.
22 #[arg(long, default_value = DEFAULT_STORE, global = true)]
23 store: String,
24
25 #[command(subcommand)]
26 cmd: Cmd,
27}
28
29#[derive(Subcommand)]
30enum Cmd {
31 /// [Fase 0] Compila una receta y la sella en el store.
32 Build {
33 /// Ruta a la receta TOML.
34 recipe: String,
35 },
36 /// [Fase 1] Proyecta un artefacto del store al FHS (real o de overlay).
37 Hydrate {
38 /// Hash del artefacto (acepta prefijos cortos; con o sin `b3:`).
39 hash: String,
40 #[arg(long, default_value = "/")]
41 into: String,
42 /// Modo de enlazado: `static` (hardlink directo) | `dynamic` (requiere patchelf).
43 #[arg(long, default_value = "static")]
44 link: String,
45 },
46 /// [Fase 2] Monta un overlay de experimentación sobre los directorios del sistema.
47 /// Sin targets → cubre el FHS clásico (/usr/bin, /bin, /usr/lib, /lib, /etc, …).
48 Try {
49 /// Directorios a poner bajo overlay. Si vacío, usa la lista por defecto.
50 targets: Vec<PathBuf>,
51 /// Raíz donde viven los manifiestos y los upper/work. Default `/var/lib/hammer/overlays`.
52 #[arg(long)]
53 state_root: Option<PathBuf>,
54 },
55 /// [Fase 2+3] Fusiona un overlay al FHS real y desmonta. Por defecto registra cada
56 /// archivo promocionado en el diario; usa `--no-journal` para saltarse el registro.
57 Commit {
58 id: String,
59 #[arg(long)]
60 state_root: Option<PathBuf>,
61 /// Directorio del diario donde anotar la promoción.
62 #[arg(long, default_value = "/var/lib/hammer/journal")]
63 journal: PathBuf,
64 /// Saltarse el registro en el diario (útil para experimentos efímeros).
65 #[arg(long)]
66 no_journal: bool,
67 },
68 /// [Fase 2] Desmonta un overlay y borra su upper. El sistema vuelve al estado base.
69 Discard {
70 id: String,
71 #[arg(long)]
72 state_root: Option<PathBuf>,
73 },
74 /// [Fase 2] Lista los overlays activos según los manifiestos persistidos.
75 Status {
76 #[arg(long)]
77 state_root: Option<PathBuf>,
78 },
79 /// [Fase 3] Ver/seguir el diario de mutaciones.
80 Journal {
81 /// Directorio del diario (mismo que `hammerd --journal`).
82 #[arg(long, default_value = "/var/lib/hammer/journal")]
83 dir: PathBuf,
84 /// Muestra los últimos N eventos. Default: 20.
85 #[arg(long, short, default_value = "20")]
86 tail: usize,
87 /// Sigue el archivo: imprime nuevos eventos según aparecen.
88 #[arg(long, short)]
89 follow: bool,
90 /// Formato. `pretty` (humano, default) o `json` (raw passthrough).
91 #[arg(long, default_value = "pretty")]
92 format: String,
93 },
94 /// [Fase 4] Aplica un manifiesto .swm. Por defecto abre un overlay sobre el FHS y aplica
95 /// las mutaciones ahí; con `--prefix DIR` opera directamente en `DIR` sin overlay
96 /// (útil para tests offline o staging en otro filesystem).
97 Apply {
98 file: String,
99 /// Si está, re-rootea todas las rutas absolutas del .swm bajo este prefix y NO abre
100 /// overlay. `--prefix /mnt/foo` ⇒ `/etc/network.conf` → `/mnt/foo/etc/network.conf`.
101 #[arg(long)]
102 prefix: Option<PathBuf>,
103 /// Si está, salta source_patch silenciosamente. Útil para probar el flujo de
104 /// config_edit/file_drop sin lab disponible.
105 #[arg(long)]
106 skip_source_patch: bool,
107 /// Tras aplicar, intenta verificar pin contra base local de `--base-ref`.
108 #[arg(long)]
109 base_ref: Option<PathBuf>,
110 /// Raíz del overlay (sólo en modo overlay).
111 #[arg(long)]
112 state_root: Option<PathBuf>,
113 },
114 /// [Fase 4] Verifica un .swm: schema, base (si se da `--base-ref`), y opcionalmente el
115 /// hash de cada `file_drop` inline.
116 SwmVerify {
117 file: String,
118 #[arg(long)]
119 base_ref: Option<PathBuf>,
120 },
121 /// [Fase 4] Exporta el diario actual como manifiesto .swm a stdout.
122 /// Mutaciones trazables ⇒ file_drop con content_b64 y hash. Mutaciones opacas ⇒
123 /// mismas pero con `note` indicando el origen (warning legible para el receptor).
124 Export {
125 /// Archivo JSON con la base local; se inserta como `base` en el .swm. Si no se da,
126 /// se emite una base con `distro_version: "unknown"` y se imprime un warning.
127 #[arg(long)]
128 base_ref: Option<PathBuf>,
129 /// Directorio del diario.
130 #[arg(long, default_value = "/var/lib/hammer/journal")]
131 journal: PathBuf,
132 /// Sólo eventos a partir de este timestamp (RFC 3339). Si no se da, exporta todo.
133 #[arg(long)]
134 since: Option<String>,
135 },
136 /// [Fase 5] Envía un comando al FIFO de control humano del init.
137 /// Por defecto: /run/init.control. Equivalente a `echo "<line>" > /run/init.control`,
138 /// pero con un mensaje de error claro si el FIFO no existe.
139 Ctl {
140 /// La línea a enviar (p. ej. "start web", "restart network").
141 line: String,
142 #[arg(long, default_value = "/run/init.control")]
143 fifo: PathBuf,
144 },
145}
146
147fn print_event(ev: &hammer_journal::MutationEvent, format: &str) {
148 match format {
149 "json" => {
150 // Re-serializa. No imprimimos la línea original (no la tenemos a mano), pero el
151 // formato es estable porque MutationEvent es la fuente de verdad del schema.
152 match serde_json::to_string(ev) {
153 Ok(s) => println!("{s}"),
154 Err(e) => eprintln!("journal: error serializando: {e}"),
155 }
156 }
157 _ => {
158 let actor = match &ev.by.source {
159 hammer_journal::Source::HammerHydrate { artifact } => format!("hydrate {artifact}"),
160 hammer_journal::Source::HammerCommit { overlay, artifact } => match artifact {
161 Some(a) => format!("commit {overlay} ({a})"),
162 None => format!("commit {overlay}"),
163 },
164 hammer_journal::Source::External => {
165 let pid = ev.by.pid.map(|p| format!("pid={p}")).unwrap_or_default();
166 let uid = ev.by.uid.map(|u| format!("uid={u}")).unwrap_or_default();
167 format!("external {pid} {uid}").trim().to_string()
168 }
169 };
170 println!(
171 "{ts} {op:<7} {path} ← {actor}",
172 ts = ev.ts,
173 op = ev.op.as_str(),
174 path = ev.path.display(),
175 );
176 }
177 }
178}
179
180fn main() -> anyhow::Result<()> {
181 tracing_subscriber::fmt()
182 .with_writer(std::io::stderr)
183 .with_env_filter(
184 tracing_subscriber::EnvFilter::try_from_default_env()
185 .unwrap_or_else(|_| "info".into()),
186 )
187 .init();
188
189 let cli = Cli::parse();
190
191 // Sólo las cmds que de verdad tocan artefactos abren (y posiblemente crean) el store.
192 // Las cmds de overlay/diario/apply pueden correr en sistemas sin store local todavía.
193 match cli.cmd {
194 Cmd::Build { recipe } => {
195 let store = hammer_core::Store::open(&cli.store)?;
196 let recipe = hammer_core::Recipe::load_from_path(&recipe)?;
197 let cfg = hammer_build::BuildConfig::from_env_or_defaults(store.root());
198 let hash = hammer_build::build(&recipe, &cfg, &store)?;
199 println!("{hash}");
200 }
201 Cmd::Hydrate { hash, into, link } => {
202 let store = hammer_core::Store::open(&cli.store)?;
203 let mode = match link.as_str() {
204 "static" => hammer_core::LinkMode::Static,
205 "dynamic" => hammer_core::LinkMode::Dynamic,
206 other => anyhow::bail!("--link debe ser 'static' o 'dynamic', no '{other}'"),
207 };
208 let artifact_dir = store.find_by_hash(&hash)?;
209 let report = hammer_build::run_hydrate(
210 &artifact_dir,
211 std::path::Path::new(&into),
212 mode,
213 )?;
214 println!(
215 "hydrated {} archivo(s) ({} dir(s), {} symlink(s)) → {}",
216 report.files.len(),
217 report.dirs_created,
218 report.symlinks,
219 into
220 );
221 for f in &report.files {
222 println!(" {}", f.dst.display());
223 }
224 }
225 Cmd::Try { targets, state_root } => {
226 let root = state_root
227 .unwrap_or_else(|| PathBuf::from(hammer_overlay::DEFAULT_STATE_ROOT));
228 std::fs::create_dir_all(&root)?;
229 let id = hammer_overlay::try_overlay(&targets, &root)?;
230 println!("{id}");
231 }
232 Cmd::Commit { id, state_root, journal, no_journal } => {
233 let root = state_root
234 .unwrap_or_else(|| PathBuf::from(hammer_overlay::DEFAULT_STATE_ROOT));
235 let id = hammer_overlay::OverlayId(id);
236 let report = if no_journal {
237 hammer_overlay::commit(&id, &root)?
238 } else {
239 let j = hammer_journal::Journal::open(&journal)?;
240 hammer_overlay::commit_with_journal(&id, &root, &j)?
241 };
242 println!(
243 "commit OK — {} archivo(s) promocionados, {} eliminado(s){}",
244 report.copied.len(),
245 report.removed.len(),
246 if no_journal { "" } else { " (anotado en el diario)" }
247 );
248 }
249 Cmd::Discard { id, state_root } => {
250 let root = state_root
251 .unwrap_or_else(|| PathBuf::from(hammer_overlay::DEFAULT_STATE_ROOT));
252 hammer_overlay::discard(&hammer_overlay::OverlayId(id), &root)?;
253 println!("discard OK");
254 }
255 Cmd::Status { state_root } => {
256 let root = state_root
257 .unwrap_or_else(|| PathBuf::from(hammer_overlay::DEFAULT_STATE_ROOT));
258 let overlays = hammer_overlay::status(&root)?;
259 if overlays.is_empty() {
260 println!("(sin overlays activos)");
261 } else {
262 for o in overlays {
263 println!(
264 "{} created={} mounts={}",
265 o.id,
266 o.created_at,
267 o.mounts.len()
268 );
269 for m in &o.mounts {
270 println!(" └─ {} upper={}", m.target.display(), m.upper.display());
271 }
272 }
273 }
274 }
275 Cmd::Journal { dir, tail, follow, format } => {
276 let j = hammer_journal::Journal::open(&dir)?;
277 let initial = j.tail(tail)?;
278 for ev in &initial {
279 print_event(ev, &format);
280 }
281 if follow {
282 use std::io::Write;
283 let _ = std::io::stdout().flush();
284 let mut cursor = j.end_offset()?;
285 loop {
286 std::thread::sleep(std::time::Duration::from_millis(500));
287 let (news, new_cursor) = j.read_from_offset(cursor)?;
288 cursor = new_cursor;
289 for ev in &news {
290 print_event(ev, &format);
291 }
292 let _ = std::io::stdout().flush();
293 }
294 }
295 }
296 Cmd::Apply { file, prefix, skip_source_patch, base_ref, state_root } => {
297 run_apply(
298 &cli.store,
299 &file,
300 prefix.as_deref(),
301 skip_source_patch,
302 base_ref.as_deref(),
303 state_root.as_deref(),
304 )?;
305 }
306 Cmd::SwmVerify { file, base_ref } => {
307 run_swm_verify(&file, base_ref.as_deref())?;
308 }
309 Cmd::Export { base_ref, journal, since } => {
310 run_export(base_ref.as_deref(), &journal, since.as_deref())?;
311 }
312 Cmd::Ctl { line, fifo } => {
313 run_ctl(&line, &fifo)?;
314 }
315 }
316 Ok(())
317}
318
319/// Lee y deserializa un `.swm` desde disco; falla con mensaje útil si no parsea.
320fn load_swm(file: &str) -> anyhow::Result<hammer_core::Swm> {
321 let yaml = std::fs::read_to_string(file)
322 .map_err(|e| anyhow::anyhow!("no pude leer {file}: {e}"))?;
323 hammer_core::Swm::from_yaml(&yaml)
324 .map_err(|e| anyhow::anyhow!("no pude parsear {file}: {e}"))
325}
326
327/// Lee una `BaseRef` local desde JSON; usada por `apply` y `swm verify` para verificar la
328/// compatibilidad de pins. Si la ruta no existe, devuelve `Ok(None)` para que el caller
329/// pueda decidir entre fallar o seguir con un warning.
330fn load_local_base(path: Option<&std::path::Path>) -> anyhow::Result<Option<hammer_core::BaseRef>> {
331 let Some(p) = path else { return Ok(None) };
332 if !p.is_file() {
333 return Err(anyhow::anyhow!("base-ref {} no existe", p.display()));
334 }
335 let bytes = std::fs::read(p)?;
336 let base: hammer_core::BaseRef =
337 serde_json::from_slice(&bytes).map_err(|e| anyhow::anyhow!("base-ref inválido: {e}"))?;
338 Ok(Some(base))
339}
340
341fn print_base_compat(c: &hammer_core::BaseCompat) {
342 use hammer_core::BaseCompat::*;
343 match c {
344 Ok => eprintln!("verify_base: OK"),
345 DistroMismatch { local, swm } => eprintln!(
346 "verify_base: distro_version no coincide — local={local} swm={swm}"
347 ),
348 PinMismatch(diffs) => {
349 eprintln!("verify_base: {} pin(s) divergente(s):", diffs.len());
350 for d in diffs {
351 let local = d.local.as_deref().unwrap_or("(ausente)");
352 eprintln!(" {}: local={local} swm={}", d.name, d.swm);
353 }
354 }
355 }
356}
357
358fn run_swm_verify(file: &str, base_ref: Option<&std::path::Path>) -> anyhow::Result<()> {
359 let swm = load_swm(file)?;
360 swm.verify_schema()
361 .map_err(|e| anyhow::anyhow!("schema inválido: {e}"))?;
362 println!("schema: OK ({} mutación(es))", swm.mutations.len());
363 // Verificación de hash de los file_drop inline (no toca disco).
364 for (i, m) in swm.mutations.iter().enumerate() {
365 if let hammer_core::Mutation::FileDrop { content_b64: Some(b64), content_hash, path, .. } = m {
366 hammer_core::apply::verify_content_hash(b64, content_hash).map_err(|e| {
367 anyhow::anyhow!("file_drop #{} en {path}: {e}", i + 1)
368 })?;
369 }
370 }
371 println!("file_drop inline: OK");
372 if let Some(local) = load_local_base(base_ref)? {
373 print_base_compat(&swm.verify_base(&local));
374 } else {
375 eprintln!("verify_base: omitido (sin --base-ref)");
376 }
377 Ok(())
378}
379
380/// Aplica un `.swm`. Dos modos:
381/// - **prefix** (`--prefix DIR`): re-rootea cada path al DIR; no abre overlay. Para tests y
382/// staging.
383/// - **overlay** (default): abre un overlay sobre los targets por defecto y aplica las
384/// mutaciones sobre las rutas reales (que el kernel redirige al upper del overlay).
385fn run_apply(
386 store_path: &str,
387 file: &str,
388 prefix: Option<&std::path::Path>,
389 skip_source_patch: bool,
390 base_ref: Option<&std::path::Path>,
391 state_root: Option<&std::path::Path>,
392) -> anyhow::Result<()> {
393 let swm = load_swm(file)?;
394 swm.verify_schema()?;
395
396 if let Some(local) = load_local_base(base_ref)? {
397 match swm.verify_base(&local) {
398 hammer_core::BaseCompat::Ok => {}
399 other => {
400 print_base_compat(&other);
401 anyhow::bail!("base local no compatible con la base del .swm");
402 }
403 }
404 }
405
406 let mut overlay_id: Option<hammer_overlay::OverlayId> = None;
407 if prefix.is_none() {
408 let root = state_root
409 .map(|p| p.to_path_buf())
410 .unwrap_or_else(|| PathBuf::from(hammer_overlay::DEFAULT_STATE_ROOT));
411 std::fs::create_dir_all(&root)?;
412 let id = hammer_overlay::try_overlay(&[], &root)?;
413 eprintln!("overlay {id} abierto");
414 overlay_id = Some(id);
415 }
416
417 let mut counts = (0usize, 0usize, 0usize, 0usize); // src_patch, cfg, init, drop
418 for (i, m) in swm.mutations.iter().enumerate() {
419 match m {
420 hammer_core::Mutation::SourcePatch { target_bin, .. } => {
421 counts.0 += 1;
422 if skip_source_patch {
423 eprintln!(" [skip] source_patch #{} → {target_bin}", i + 1);
424 continue;
425 }
426 let store = hammer_core::Store::open(store_path)?;
427 let cfg = hammer_build::BuildConfig::from_env_or_defaults(store.root());
428 let hash = hammer_build::build_source_patch(m, &cfg, &store, None)?;
429 let artifact = store.find_by_hash(hash.as_str())?;
430 let into = hammer_core::apply::rebase_path("/", prefix);
431 let report = hammer_build::run_hydrate(
432 &artifact,
433 &into,
434 hammer_core::LinkMode::Static,
435 )?;
436 eprintln!(
437 " source_patch #{}: {} → hidratados {} archivo(s) en {}",
438 i + 1,
439 hash,
440 report.files.len(),
441 into.display()
442 );
443 // Sanity: el target_bin declarado debe existir tras la hidratación.
444 let abs = hammer_core::apply::rebase_path(target_bin, prefix);
445 if !abs.exists() {
446 anyhow::bail!(
447 "source_patch declara target_bin={target_bin} pero no quedó en {}",
448 abs.display()
449 );
450 }
451 }
452 hammer_core::Mutation::ConfigEdit { file: f, inline_diff } => {
453 counts.1 += 1;
454 let target = hammer_core::apply::rebase_path(f, prefix);
455 hammer_core::apply::apply_config_edit(&target, inline_diff)?;
456 eprintln!(" config_edit #{} → {}", i + 1, target.display());
457 }
458 hammer_core::Mutation::InitRule { action, service, .. } => {
459 counts.2 += 1;
460 eprintln!(
461 " [pendiente fase 5] init_rule #{}: {action} {service} — ignorando",
462 i + 1
463 );
464 }
465 hammer_core::Mutation::FileDrop { path, content_hash, content_b64, content_url } => {
466 counts.3 += 1;
467 let target = hammer_core::apply::rebase_path(path, prefix);
468 match (content_b64, content_url) {
469 (Some(b64), None) => {
470 hammer_core::apply::apply_file_drop(&target, b64, content_hash)?;
471 eprintln!(" file_drop #{} → {}", i + 1, target.display());
472 }
473 (None, Some(url)) => {
474 anyhow::bail!(
475 "file_drop #{} usa content_url={url}: fetch remoto pendiente",
476 i + 1
477 );
478 }
479 _ => unreachable!("verify_schema lo descarta"),
480 }
481 }
482 }
483 }
484 eprintln!(
485 "apply OK — source_patch={} config_edit={} init_rule={} file_drop={}",
486 counts.0, counts.1, counts.2, counts.3
487 );
488 if let Some(id) = &overlay_id {
489 println!("{id}");
490 eprintln!("overlay listo: usa `hammer commit {id}` para promocionar al FHS real.");
491 }
492 Ok(())
493}
494
495/// Exporta el diario actual como `.swm`. Estrategia honesta para Fase 4:
496///
497/// - Cada evento se traduce a un `file_drop` con `content_b64` (lo que hay en disco *ahora*)
498/// y `content_hash`. Esto garantiza reproducción byte-a-byte sin depender de tener el
499/// recipe original. La pérdida es que perdemos provenance — el receptor obtiene el binario
500/// pero no la receta que lo produjo. Para `source_patch` real necesitaremos un mapa
501/// artefacto→receta que la Fase 4 no construye todavía.
502/// - Mutaciones opacas (`Source::External`) llevan un `note` indicando "origen externo"
503/// para que el receptor sepa que no hay garantía de provenance.
504/// - Mutaciones con `op = Delete` se omiten: un `.swm` describe el estado final por
505/// adición/edición, no por borrado. Es un compromiso consciente (un futuro `FileRemove`
506/// resolvería esto).
507fn run_export(
508 base_ref_path: Option<&std::path::Path>,
509 journal_dir: &std::path::Path,
510 since: Option<&str>,
511) -> anyhow::Result<()> {
512 use base64::{engine::general_purpose::STANDARD, Engine as _};
513 use std::collections::BTreeMap;
514
515 let j = hammer_journal::Journal::open(journal_dir)?;
516 let events = j.read_all()?;
517 let filtered: Vec<_> = match since {
518 Some(t) => events.into_iter().filter(|e| e.ts.as_str() > t).collect(),
519 None => events,
520 };
521
522 let base = match load_local_base(base_ref_path)? {
523 Some(local) => hammer_core::Base {
524 distro_version: local.distro_version,
525 pins: local.pins,
526 },
527 None => {
528 eprintln!("warning: sin --base-ref; emitiendo base con distro_version='unknown'");
529 hammer_core::Base {
530 distro_version: "unknown".into(),
531 pins: BTreeMap::new(),
532 }
533 }
534 };
535
536 // Dedup por path: dentro de un export queremos un único estado final por archivo.
537 // Procesamos en orden y nos quedamos con la última ocurrencia.
538 let mut last_by_path: std::collections::BTreeMap<PathBuf, &hammer_journal::MutationEvent> =
539 std::collections::BTreeMap::new();
540 for ev in &filtered {
541 if matches!(ev.op, hammer_journal::MutationOp::Delete) {
542 last_by_path.remove(&ev.path); // borrado anula cualquier estado previo
543 continue;
544 }
545 last_by_path.insert(ev.path.clone(), ev);
546 }
547
548 let mut mutations = Vec::new();
549 let mut warnings = 0usize;
550 for (path, ev) in &last_by_path {
551 let bytes = match std::fs::read(path) {
552 Ok(b) => b,
553 Err(e) => {
554 warnings += 1;
555 eprintln!(
556 "warning: no pude leer {} para exportar (ev ts={}): {e}",
557 path.display(),
558 ev.ts
559 );
560 continue;
561 }
562 };
563 let h = hammer_core::ArtifactHash::of_inputs(&[bytes.as_slice()]);
564 let b64 = STANDARD.encode(&bytes);
565 let note = match &ev.by.source {
566 hammer_journal::Source::External => Some(format!(
567 "origen externo (pid={:?}, uid={:?}) — sin provenance",
568 ev.by.pid, ev.by.uid
569 )),
570 hammer_journal::Source::HammerHydrate { artifact } => {
571 Some(format!("hydrate {artifact}"))
572 }
573 hammer_journal::Source::HammerCommit { overlay, artifact } => match artifact {
574 Some(a) => Some(format!("commit {overlay} ({a})")),
575 None => Some(format!("commit {overlay}")),
576 },
577 };
578 // El note vive en el journal, no en el schema del file_drop — lo omitimos en el
579 // YAML emitido por ahora; está en stderr (warnings) si era external.
580 let _ = note;
581 mutations.push(hammer_core::Mutation::FileDrop {
582 path: path.display().to_string(),
583 content_hash: h.as_str().to_string(),
584 content_b64: Some(b64),
585 content_url: None,
586 });
587 }
588
589 let swm = hammer_core::Swm {
590 swm_version: 1,
591 base,
592 mutations,
593 signature: None,
594 };
595 let yaml = swm.to_yaml()?;
596 print!("{yaml}");
597 eprintln!(
598 "export: {} mutación(es), {} warning(s)",
599 swm.mutations.len(),
600 warnings
601 );
602 Ok(())
603}
604
605/// Envía una línea al FIFO de control humano. No abrimos un proceso intermedio (sería
606/// `sh -c "echo ... > FIFO"` con riesgo de quoting); usamos un `OpenOptions::write` y un
607/// `write_all`. El FIFO bloquea si no hay reader — eso es la garantía de "ack" implícito.
608fn run_ctl(line: &str, fifo: &std::path::Path) -> anyhow::Result<()> {
609 use std::io::Write;
610 use std::os::unix::fs::FileTypeExt;
611 let meta = std::fs::symlink_metadata(fifo).map_err(|e| {
612 anyhow::anyhow!(
613 "no encuentro el FIFO {} ({e}); ¿está hammerd arrancado?",
614 fifo.display()
615 )
616 })?;
617 if !meta.file_type().is_fifo() {
618 anyhow::bail!("{} existe pero no es un FIFO", fifo.display());
619 }
620 let mut f = std::fs::OpenOptions::new().write(true).open(fifo)?;
621 f.write_all(line.as_bytes())?;
622 if !line.ends_with('\n') {
623 f.write_all(b"\n")?;
624 }
625 Ok(())
626}