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…
⎇ 09d50f9e60c9f523e99aff3d6c8dcf2e5b3cd1a5
crates/hammer-cli/src/main.rs
Historia
Quién tocó qué
crudo
Fase 4 — formato .swm: verify, apply (config_edit/file_drop/source_patch) y export
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}