tawasuyu

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

TOML 177Rust 55Shell 28Markdown 271211 Definiciones

⎇ 09d50f9e60c9f523e99aff3d6c8dcf2e5b3cd1a5 crates/hammer-cli/src/main.rs Rust · 596 líneas · 23.6 KB Historia Quién tocó qué crudo

Fase 4 — formato .swm: verify, apply (config_edit/file_drop/source_patch) y export 09d50f9e6 · Sergio ·

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 init (proxy a /run/init.control).
137    Ctl { line: String },
138}
139
140fn print_event(ev: &hammer_journal::MutationEvent, format: &str) {
141    match format {
142        "json" => {
143            // Re-serializa. No imprimimos la línea original (no la tenemos a mano), pero el
144            // formato es estable porque MutationEvent es la fuente de verdad del schema.
145            match serde_json::to_string(ev) {
146                Ok(s) => println!("{s}"),
147                Err(e) => eprintln!("journal: error serializando: {e}"),
148            }
149        }
150        _ => {
151            let actor = match &ev.by.source {
152                hammer_journal::Source::HammerHydrate { artifact } => format!("hydrate {artifact}"),
153                hammer_journal::Source::HammerCommit { overlay, artifact } => match artifact {
154                    Some(a) => format!("commit {overlay} ({a})"),
155                    None => format!("commit {overlay}"),
156                },
157                hammer_journal::Source::External => {
158                    let pid = ev.by.pid.map(|p| format!("pid={p}")).unwrap_or_default();
159                    let uid = ev.by.uid.map(|u| format!("uid={u}")).unwrap_or_default();
160                    format!("external {pid} {uid}").trim().to_string()
161                }
162            };
163            println!(
164                "{ts} {op:<7} {path}  ←  {actor}",
165                ts = ev.ts,
166                op = ev.op.as_str(),
167                path = ev.path.display(),
168            );
169        }
170    }
171}
172
173fn main() -> anyhow::Result<()> {
174    tracing_subscriber::fmt()
175        .with_writer(std::io::stderr)
176        .with_env_filter(
177            tracing_subscriber::EnvFilter::try_from_default_env()
178                .unwrap_or_else(|_| "info".into()),
179        )
180        .init();
181
182    let cli = Cli::parse();
183
184    // Sólo las cmds que de verdad tocan artefactos abren (y posiblemente crean) el store.
185    // Las cmds de overlay/diario/apply pueden correr en sistemas sin store local todavía.
186    match cli.cmd {
187        Cmd::Build { recipe } => {
188            let store = hammer_core::Store::open(&cli.store)?;
189            let recipe = hammer_core::Recipe::load_from_path(&recipe)?;
190            let cfg = hammer_build::BuildConfig::from_env_or_defaults(store.root());
191            let hash = hammer_build::build(&recipe, &cfg, &store)?;
192            println!("{hash}");
193        }
194        Cmd::Hydrate { hash, into, link } => {
195            let store = hammer_core::Store::open(&cli.store)?;
196            let mode = match link.as_str() {
197                "static" => hammer_core::LinkMode::Static,
198                "dynamic" => hammer_core::LinkMode::Dynamic,
199                other => anyhow::bail!("--link debe ser 'static' o 'dynamic', no '{other}'"),
200            };
201            let artifact_dir = store.find_by_hash(&hash)?;
202            let report = hammer_build::run_hydrate(
203                &artifact_dir,
204                std::path::Path::new(&into),
205                mode,
206            )?;
207            println!(
208                "hydrated {} archivo(s) ({} dir(s), {} symlink(s)) → {}",
209                report.files.len(),
210                report.dirs_created,
211                report.symlinks,
212                into
213            );
214            for f in &report.files {
215                println!("  {}", f.dst.display());
216            }
217        }
218        Cmd::Try { targets, state_root } => {
219            let root = state_root
220                .unwrap_or_else(|| PathBuf::from(hammer_overlay::DEFAULT_STATE_ROOT));
221            std::fs::create_dir_all(&root)?;
222            let id = hammer_overlay::try_overlay(&targets, &root)?;
223            println!("{id}");
224        }
225        Cmd::Commit { id, state_root, journal, no_journal } => {
226            let root = state_root
227                .unwrap_or_else(|| PathBuf::from(hammer_overlay::DEFAULT_STATE_ROOT));
228            let id = hammer_overlay::OverlayId(id);
229            let report = if no_journal {
230                hammer_overlay::commit(&id, &root)?
231            } else {
232                let j = hammer_journal::Journal::open(&journal)?;
233                hammer_overlay::commit_with_journal(&id, &root, &j)?
234            };
235            println!(
236                "commit OK — {} archivo(s) promocionados, {} eliminado(s){}",
237                report.copied.len(),
238                report.removed.len(),
239                if no_journal { "" } else { " (anotado en el diario)" }
240            );
241        }
242        Cmd::Discard { id, state_root } => {
243            let root = state_root
244                .unwrap_or_else(|| PathBuf::from(hammer_overlay::DEFAULT_STATE_ROOT));
245            hammer_overlay::discard(&hammer_overlay::OverlayId(id), &root)?;
246            println!("discard OK");
247        }
248        Cmd::Status { state_root } => {
249            let root = state_root
250                .unwrap_or_else(|| PathBuf::from(hammer_overlay::DEFAULT_STATE_ROOT));
251            let overlays = hammer_overlay::status(&root)?;
252            if overlays.is_empty() {
253                println!("(sin overlays activos)");
254            } else {
255                for o in overlays {
256                    println!(
257                        "{}  created={}  mounts={}",
258                        o.id,
259                        o.created_at,
260                        o.mounts.len()
261                    );
262                    for m in &o.mounts {
263                        println!("  └─ {}  upper={}", m.target.display(), m.upper.display());
264                    }
265                }
266            }
267        }
268        Cmd::Journal { dir, tail, follow, format } => {
269            let j = hammer_journal::Journal::open(&dir)?;
270            let initial = j.tail(tail)?;
271            for ev in &initial {
272                print_event(ev, &format);
273            }
274            if follow {
275                use std::io::Write;
276                let _ = std::io::stdout().flush();
277                let mut cursor = j.end_offset()?;
278                loop {
279                    std::thread::sleep(std::time::Duration::from_millis(500));
280                    let (news, new_cursor) = j.read_from_offset(cursor)?;
281                    cursor = new_cursor;
282                    for ev in &news {
283                        print_event(ev, &format);
284                    }
285                    let _ = std::io::stdout().flush();
286                }
287            }
288        }
289        Cmd::Apply { file, prefix, skip_source_patch, base_ref, state_root } => {
290            run_apply(
291                &cli.store,
292                &file,
293                prefix.as_deref(),
294                skip_source_patch,
295                base_ref.as_deref(),
296                state_root.as_deref(),
297            )?;
298        }
299        Cmd::SwmVerify { file, base_ref } => {
300            run_swm_verify(&file, base_ref.as_deref())?;
301        }
302        Cmd::Export { base_ref, journal, since } => {
303            run_export(base_ref.as_deref(), &journal, since.as_deref())?;
304        }
305        Cmd::Ctl { line } => {
306            println!("[fase 5 pendiente] ctl {line:?} — ver docs/07-agent-bus.md");
307        }
308    }
309    Ok(())
310}
311
312/// Lee y deserializa un `.swm` desde disco; falla con mensaje útil si no parsea.
313fn load_swm(file: &str) -> anyhow::Result<hammer_core::Swm> {
314    let yaml = std::fs::read_to_string(file)
315        .map_err(|e| anyhow::anyhow!("no pude leer {file}: {e}"))?;
316    hammer_core::Swm::from_yaml(&yaml)
317        .map_err(|e| anyhow::anyhow!("no pude parsear {file}: {e}"))
318}
319
320/// Lee una `BaseRef` local desde JSON; usada por `apply` y `swm verify` para verificar la
321/// compatibilidad de pins. Si la ruta no existe, devuelve `Ok(None)` para que el caller
322/// pueda decidir entre fallar o seguir con un warning.
323fn load_local_base(path: Option<&std::path::Path>) -> anyhow::Result<Option<hammer_core::BaseRef>> {
324    let Some(p) = path else { return Ok(None) };
325    if !p.is_file() {
326        return Err(anyhow::anyhow!("base-ref {} no existe", p.display()));
327    }
328    let bytes = std::fs::read(p)?;
329    let base: hammer_core::BaseRef =
330        serde_json::from_slice(&bytes).map_err(|e| anyhow::anyhow!("base-ref inválido: {e}"))?;
331    Ok(Some(base))
332}
333
334fn print_base_compat(c: &hammer_core::BaseCompat) {
335    use hammer_core::BaseCompat::*;
336    match c {
337        Ok => eprintln!("verify_base: OK"),
338        DistroMismatch { local, swm } => eprintln!(
339            "verify_base: distro_version no coincide — local={local} swm={swm}"
340        ),
341        PinMismatch(diffs) => {
342            eprintln!("verify_base: {} pin(s) divergente(s):", diffs.len());
343            for d in diffs {
344                let local = d.local.as_deref().unwrap_or("(ausente)");
345                eprintln!("  {}: local={local} swm={}", d.name, d.swm);
346            }
347        }
348    }
349}
350
351fn run_swm_verify(file: &str, base_ref: Option<&std::path::Path>) -> anyhow::Result<()> {
352    let swm = load_swm(file)?;
353    swm.verify_schema()
354        .map_err(|e| anyhow::anyhow!("schema inválido: {e}"))?;
355    println!("schema: OK  ({} mutación(es))", swm.mutations.len());
356    // Verificación de hash de los file_drop inline (no toca disco).
357    for (i, m) in swm.mutations.iter().enumerate() {
358        if let hammer_core::Mutation::FileDrop { content_b64: Some(b64), content_hash, path, .. } = m {
359            hammer_core::apply::verify_content_hash(b64, content_hash).map_err(|e| {
360                anyhow::anyhow!("file_drop #{} en {path}: {e}", i + 1)
361            })?;
362        }
363    }
364    println!("file_drop inline: OK");
365    if let Some(local) = load_local_base(base_ref)? {
366        print_base_compat(&swm.verify_base(&local));
367    } else {
368        eprintln!("verify_base: omitido (sin --base-ref)");
369    }
370    Ok(())
371}
372
373/// Aplica un `.swm`. Dos modos:
374/// - **prefix** (`--prefix DIR`): re-rootea cada path al DIR; no abre overlay. Para tests y
375///   staging.
376/// - **overlay** (default): abre un overlay sobre los targets por defecto y aplica las
377///   mutaciones sobre las rutas reales (que el kernel redirige al upper del overlay).
378fn run_apply(
379    store_path: &str,
380    file: &str,
381    prefix: Option<&std::path::Path>,
382    skip_source_patch: bool,
383    base_ref: Option<&std::path::Path>,
384    state_root: Option<&std::path::Path>,
385) -> anyhow::Result<()> {
386    let swm = load_swm(file)?;
387    swm.verify_schema()?;
388
389    if let Some(local) = load_local_base(base_ref)? {
390        match swm.verify_base(&local) {
391            hammer_core::BaseCompat::Ok => {}
392            other => {
393                print_base_compat(&other);
394                anyhow::bail!("base local no compatible con la base del .swm");
395            }
396        }
397    }
398
399    let mut overlay_id: Option<hammer_overlay::OverlayId> = None;
400    if prefix.is_none() {
401        let root = state_root
402            .map(|p| p.to_path_buf())
403            .unwrap_or_else(|| PathBuf::from(hammer_overlay::DEFAULT_STATE_ROOT));
404        std::fs::create_dir_all(&root)?;
405        let id = hammer_overlay::try_overlay(&[], &root)?;
406        eprintln!("overlay {id} abierto");
407        overlay_id = Some(id);
408    }
409
410    let mut counts = (0usize, 0usize, 0usize, 0usize); // src_patch, cfg, init, drop
411    for (i, m) in swm.mutations.iter().enumerate() {
412        match m {
413            hammer_core::Mutation::SourcePatch { target_bin, .. } => {
414                counts.0 += 1;
415                if skip_source_patch {
416                    eprintln!("  [skip] source_patch #{} → {target_bin}", i + 1);
417                    continue;
418                }
419                let store = hammer_core::Store::open(store_path)?;
420                let cfg = hammer_build::BuildConfig::from_env_or_defaults(store.root());
421                let hash = hammer_build::build_source_patch(m, &cfg, &store, None)?;
422                let artifact = store.find_by_hash(hash.as_str())?;
423                let into = hammer_core::apply::rebase_path("/", prefix);
424                let report = hammer_build::run_hydrate(
425                    &artifact,
426                    &into,
427                    hammer_core::LinkMode::Static,
428                )?;
429                eprintln!(
430                    "  source_patch #{}: {} → hidratados {} archivo(s) en {}",
431                    i + 1,
432                    hash,
433                    report.files.len(),
434                    into.display()
435                );
436                // Sanity: el target_bin declarado debe existir tras la hidratación.
437                let abs = hammer_core::apply::rebase_path(target_bin, prefix);
438                if !abs.exists() {
439                    anyhow::bail!(
440                        "source_patch declara target_bin={target_bin} pero no quedó en {}",
441                        abs.display()
442                    );
443                }
444            }
445            hammer_core::Mutation::ConfigEdit { file: f, inline_diff } => {
446                counts.1 += 1;
447                let target = hammer_core::apply::rebase_path(f, prefix);
448                hammer_core::apply::apply_config_edit(&target, inline_diff)?;
449                eprintln!("  config_edit #{} → {}", i + 1, target.display());
450            }
451            hammer_core::Mutation::InitRule { action, service, .. } => {
452                counts.2 += 1;
453                eprintln!(
454                    "  [pendiente fase 5] init_rule #{}: {action} {service} — ignorando",
455                    i + 1
456                );
457            }
458            hammer_core::Mutation::FileDrop { path, content_hash, content_b64, content_url } => {
459                counts.3 += 1;
460                let target = hammer_core::apply::rebase_path(path, prefix);
461                match (content_b64, content_url) {
462                    (Some(b64), None) => {
463                        hammer_core::apply::apply_file_drop(&target, b64, content_hash)?;
464                        eprintln!("  file_drop #{} → {}", i + 1, target.display());
465                    }
466                    (None, Some(url)) => {
467                        anyhow::bail!(
468                            "file_drop #{} usa content_url={url}: fetch remoto pendiente",
469                            i + 1
470                        );
471                    }
472                    _ => unreachable!("verify_schema lo descarta"),
473                }
474            }
475        }
476    }
477    eprintln!(
478        "apply OK — source_patch={} config_edit={} init_rule={} file_drop={}",
479        counts.0, counts.1, counts.2, counts.3
480    );
481    if let Some(id) = &overlay_id {
482        println!("{id}");
483        eprintln!("overlay listo: usa `hammer commit {id}` para promocionar al FHS real.");
484    }
485    Ok(())
486}
487
488/// Exporta el diario actual como `.swm`. Estrategia honesta para Fase 4:
489///
490/// - Cada evento se traduce a un `file_drop` con `content_b64` (lo que hay en disco *ahora*)
491///   y `content_hash`. Esto garantiza reproducción byte-a-byte sin depender de tener el
492///   recipe original. La pérdida es que perdemos provenance — el receptor obtiene el binario
493///   pero no la receta que lo produjo. Para `source_patch` real necesitaremos un mapa
494///   artefacto→receta que la Fase 4 no construye todavía.
495/// - Mutaciones opacas (`Source::External`) llevan un `note` indicando "origen externo"
496///   para que el receptor sepa que no hay garantía de provenance.
497/// - Mutaciones con `op = Delete` se omiten: un `.swm` describe el estado final por
498///   adición/edición, no por borrado. Es un compromiso consciente (un futuro `FileRemove`
499///   resolvería esto).
500fn run_export(
501    base_ref_path: Option<&std::path::Path>,
502    journal_dir: &std::path::Path,
503    since: Option<&str>,
504) -> anyhow::Result<()> {
505    use base64::{engine::general_purpose::STANDARD, Engine as _};
506    use std::collections::BTreeMap;
507
508    let j = hammer_journal::Journal::open(journal_dir)?;
509    let events = j.read_all()?;
510    let filtered: Vec<_> = match since {
511        Some(t) => events.into_iter().filter(|e| e.ts.as_str() > t).collect(),
512        None => events,
513    };
514
515    let base = match load_local_base(base_ref_path)? {
516        Some(local) => hammer_core::Base {
517            distro_version: local.distro_version,
518            pins: local.pins,
519        },
520        None => {
521            eprintln!("warning: sin --base-ref; emitiendo base con distro_version='unknown'");
522            hammer_core::Base {
523                distro_version: "unknown".into(),
524                pins: BTreeMap::new(),
525            }
526        }
527    };
528
529    // Dedup por path: dentro de un export queremos un único estado final por archivo.
530    // Procesamos en orden y nos quedamos con la última ocurrencia.
531    let mut last_by_path: std::collections::BTreeMap<PathBuf, &hammer_journal::MutationEvent> =
532        std::collections::BTreeMap::new();
533    for ev in &filtered {
534        if matches!(ev.op, hammer_journal::MutationOp::Delete) {
535            last_by_path.remove(&ev.path); // borrado anula cualquier estado previo
536            continue;
537        }
538        last_by_path.insert(ev.path.clone(), ev);
539    }
540
541    let mut mutations = Vec::new();
542    let mut warnings = 0usize;
543    for (path, ev) in &last_by_path {
544        let bytes = match std::fs::read(path) {
545            Ok(b) => b,
546            Err(e) => {
547                warnings += 1;
548                eprintln!(
549                    "warning: no pude leer {} para exportar (ev ts={}): {e}",
550                    path.display(),
551                    ev.ts
552                );
553                continue;
554            }
555        };
556        let h = hammer_core::ArtifactHash::of_inputs(&[bytes.as_slice()]);
557        let b64 = STANDARD.encode(&bytes);
558        let note = match &ev.by.source {
559            hammer_journal::Source::External => Some(format!(
560                "origen externo (pid={:?}, uid={:?}) — sin provenance",
561                ev.by.pid, ev.by.uid
562            )),
563            hammer_journal::Source::HammerHydrate { artifact } => {
564                Some(format!("hydrate {artifact}"))
565            }
566            hammer_journal::Source::HammerCommit { overlay, artifact } => match artifact {
567                Some(a) => Some(format!("commit {overlay} ({a})")),
568                None => Some(format!("commit {overlay}")),
569            },
570        };
571        // El note vive en el journal, no en el schema del file_drop — lo omitimos en el
572        // YAML emitido por ahora; está en stderr (warnings) si era external.
573        let _ = note;
574        mutations.push(hammer_core::Mutation::FileDrop {
575            path: path.display().to_string(),
576            content_hash: h.as_str().to_string(),
577            content_b64: Some(b64),
578            content_url: None,
579        });
580    }
581
582    let swm = hammer_core::Swm {
583        swm_version: 1,
584        base,
585        mutations,
586        signature: None,
587    };
588    let yaml = swm.to_yaml()?;
589    print!("{yaml}");
590    eprintln!(
591        "export: {} mutación(es), {} warning(s)",
592        swm.mutations.len(),
593        warnings
594    );
595    Ok(())
596}