Skip to main content

memstead_cli/commands/
export.rs

1use std::path::PathBuf;
2
3use clap::{Parser, ValueEnum};
4use serde_json::json;
5
6use crate::CliError;
7use crate::output::{ExitKind, print_json, print_markdown};
8use crate::setup::{CliContext, CliEngine};
9
10/// Export the write mem as markdown (in place), as a portable `.mem`
11/// archive, or as a structured JSON document on stdout.
12///
13/// `--format markdown` is supported only on folder-backed mems; use
14/// `--format mem` for archive export on git-branch backends. Targeting
15/// a mem on an incompatible backend returns
16/// `MARKDOWN_EXPORT_UNSUPPORTED_BACKEND`; workspace-wide markdown export
17/// in a mixed-backend workspace completes the folder mounts and lists
18/// the declined mounts under `skipped_mounts`.
19///
20/// `--format json` is the bulk read: one engine boot emits the complete
21/// entity set — per entity the same structured envelope `memstead entity
22/// --json` produces — grouped per mem, backend-uniform, observably
23/// read-only. External projections and check scripts consume this
24/// instead of per-entity CLI calls (which pay the engine boot per
25/// entity) or raw git against the mem-repo.
26#[derive(Parser, Debug)]
27pub struct Args {
28    /// Output format. `markdown` regenerates the mem directory in place
29    /// (folder-backed mems only); `mem` writes a portable `.mem` zip
30    /// suitable for sharing (every backend); `json` prints every
31    /// non-stub entity of the selected mem(s) as one structured JSON
32    /// document on stdout (every backend, read-only); `html` writes one
33    /// self-contained page; `llms-txt` prints the whole mem as one
34    /// agent-readable Markdown document (every backend, read-only) —
35    /// the same shape a Memstead deployment serves at `/llms-full.txt`,
36    /// rendered by the same engine code so the two cannot drift.
37    #[arg(long, value_enum, default_value_t = Format::Markdown)]
38    pub format: Format,
39
40    /// Output path for `--format mem` (default `./<name>-<version>.mem`)
41    /// and `--format html` (default `./<mem>.html`). Optional for
42    /// `--format llms-txt`, which prints to stdout when omitted.
43    /// Ignored for `--format markdown`; refused for `--format json`
44    /// (that document goes to stdout).
45    #[arg(long, short = 'o', value_name = "PATH")]
46    pub output: Option<PathBuf>,
47
48    /// Which mem to export (by name). For `--format markdown`, omitting
49    /// this argument runs a workspace-wide export and reports any
50    /// declined mounts under `skipped_mounts`. For `--format mem`,
51    /// required when more than one write mem is loaded; defaults to
52    /// the first writable mem otherwise. For `--format json`, omitting
53    /// it exports every writable mem; naming a read-only mount exports
54    /// that mount (read-mems are excluded from the workspace-wide
55    /// default — they are someone else's published content).
56    #[arg(long = "mem", value_name = "NAME")]
57    pub mem_name: Option<String>,
58
59    /// Absolute base URL for entity links in `--format llms-txt` (e.g.
60    /// `https://example.com`). With it, references render as absolute
61    /// links exactly as the served document does; without it they target
62    /// the document-relative `entity/<id>`. There is no third form.
63    /// Ignored by every other format.
64    #[arg(long = "base-url", value_name = "URL")]
65    pub base_url: Option<String>,
66}
67
68#[derive(ValueEnum, Clone, Copy, Debug)]
69pub enum Format {
70    /// Regenerate markdown files in place.
71    Markdown,
72    /// Write a `.mem` zip archive to `--output`.
73    Mem,
74    /// Print the full entity set as one JSON document on stdout.
75    Json,
76    /// Write one self-contained HTML file — the read surface for
77    /// non-operators: no server, no scripts, zero network requests.
78    Html,
79    /// Write the whole mem as one agent-readable Markdown document —
80    /// the `/llms-full.txt` shape, rendered by the same engine code the
81    /// served endpoint uses, so the two cannot drift.
82    LlmsTxt,
83}
84
85pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
86    if matches!(args.format, Format::Json) {
87        return run_json(ctx, args);
88    }
89    if matches!(args.format, Format::Html) {
90        return run_html(ctx, args);
91    }
92    if matches!(args.format, Format::LlmsTxt) {
93        return run_llms_txt(ctx, args);
94    }
95    match ctx.cli_engine()? {
96        #[cfg(feature = "mem-repo")]
97        CliEngine::MemRepo(engine) => match args.format {
98            Format::Markdown => run_markdown(ctx, &engine, args.mem_name.as_deref()),
99            Format::Mem => run_mem(ctx, &engine, args),
100            Format::Json => unreachable!("dispatched to run_json above"),
101            Format::Html => unreachable!("dispatched to run_html above"),
102            Format::LlmsTxt => unreachable!("dispatched to run_llms_txt above"),
103        },
104        CliEngine::Filesystem(engine) => match args.format {
105            // `--format markdown` regenerates files in place. The
106            // filesystem engine's writer would do the same, but
107            // there's no `export_markdown` accessor today; surface
108            // the gap as a clear validation error rather than a
109            // silent no-op.
110            Format::Markdown => Err(CliError::new(
111                ExitKind::Validation,
112                "INVALID_INPUT",
113                "--format markdown is not yet supported on filesystem-mem `memstead export` — entities are already on disk in their canonical form",
114            )
115            .into()),
116            Format::Mem => run_mem_filesystem(ctx, &engine, args),
117            Format::Json => unreachable!("dispatched to run_json above"),
118            Format::Html => unreachable!("dispatched to run_html above"),
119            Format::LlmsTxt => unreachable!("dispatched to run_llms_txt above"),
120        },
121    }
122}
123
124/// Version marker on the `--format json` document, following the
125/// `workspace-dump/v0` convention: consumers assert the marker before
126/// parsing so a future shape change fails loudly instead of silently.
127const JSON_EXPORT_FORMAT: &str = "memstead-export/v1";
128
129/// `--format json` — the bulk read. Backend-uniform (both engine
130/// flavours serve it via [`CliEngine::base`]) and observably read-only:
131/// pure store iteration, no engine mutation path is touched. Each
132/// entity rides as the same structured envelope `memstead entity --json`
133/// emits (plus mem-level grouping), so a consumer parses one entity
134/// shape across both surfaces. Entities are sorted by id within each
135/// mem for deterministic output; stubs are excluded (they are
136/// unresolved references, not content).
137fn run_json(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
138    // `-o` only means something for archive export. Refusing beats
139    // silently ignoring: an operator who passed `-o dump.json` would
140    // otherwise wait on a file that never appears.
141    if args.output.is_some() {
142        return Err(CliError::new(
143            ExitKind::Validation,
144            "INVALID_INPUT",
145            "--output applies only to --format mem — the JSON document goes to stdout; redirect it instead",
146        )
147        .into());
148    }
149
150    let cli_engine = ctx.cli_engine()?;
151    let engine = cli_engine.base();
152
153    let all_names: Vec<String> = engine.mem_names().into_iter().map(String::from).collect();
154    // Named mem: any loaded mount qualifies, read-only included — an
155    // explicit name is the opt-in. Workspace-wide default: writable
156    // mems only; read-only mounts are someone else's published content.
157    let selected: Vec<String> = match &args.mem_name {
158        Some(name) => {
159            if !all_names.iter().any(|n| n == name) {
160                return Err(CliError::new(
161                    ExitKind::NotFound,
162                    "UNKNOWN_MEM",
163                    format!(
164                        "unknown mem '{name}' — loaded mems: {}",
165                        all_names.join(", ")
166                    ),
167                )
168                .with_details(json!({ "mem": name, "loaded": all_names }))
169                .into());
170            }
171            vec![name.clone()]
172        }
173        None => all_names
174            .iter()
175            .filter(|n| engine.mem_router().is_writable(n))
176            .cloned()
177            .collect(),
178    };
179
180    let mut mems = serde_json::Map::new();
181    for mem_name in &selected {
182        // The authoritative schema pin lives in the mem's own config;
183        // carried once at the group level rather than per entity.
184        let schema_pin = engine
185            .mem_configs_named()
186            .find(|(name, _)| name == mem_name)
187            .and_then(|(_, c)| c.schema.as_ref())
188            .map(|s| s.to_string());
189
190        let mut entities: Vec<&memstead_base::Entity> = engine
191            .store()
192            .all_entities()
193            .filter(|e| !e.stub && e.mem == *mem_name)
194            .collect();
195        entities.sort_by(|a, b| a.id.as_ref().cmp(b.id.as_ref()));
196
197        let envelopes: Vec<serde_json::Value> = entities
198            .iter()
199            .map(|entity| {
200                let body = memstead_base::render::render_entity_markdown(entity, None);
201                let tokens = memstead_base::chunking::estimate_tokens(&body);
202                let outgoing = engine.store().outgoing(&entity.id);
203                // Export is a canonical-form surface — computed
204                // signals are a serving projection and stay out.
205                memstead_base::render::build_entity_envelope(
206                    entity,
207                    tokens,
208                    None,
209                    None,
210                    None,
211                    engine.mem_origin_class(entity.id.mem()),
212                    outgoing,
213                    None,
214                    None,
215                    None,
216                )
217            })
218            .collect();
219
220        let mut group = serde_json::Map::new();
221        if let Some(s) = schema_pin {
222            group.insert("schema".to_string(), json!(s));
223        }
224        group.insert(
225            "read_only".to_string(),
226            json!(!engine.mem_router().is_writable(mem_name)),
227        );
228        group.insert("entity_count".to_string(), json!(envelopes.len()));
229        group.insert("entities".to_string(), serde_json::Value::Array(envelopes));
230        mems.insert(mem_name.clone(), serde_json::Value::Object(group));
231    }
232
233    print_json(&json!({
234        "format": JSON_EXPORT_FORMAT,
235        "mems": mems,
236    }))
237}
238
239#[cfg(feature = "mem-repo")]
240fn run_markdown(
241    ctx: &CliContext,
242    engine: &memstead_base::Engine,
243    mem_filter: Option<&str>,
244) -> anyhow::Result<()> {
245    // The engine returns a
246    // typed `MARKDOWN_EXPORT_UNSUPPORTED_BACKEND` when `--mem`
247    // targets a mem whose backend doesn't support markdown
248    // regeneration. The workspace-wide path returns counts plus a
249    // structured `skipped_mounts` list.
250    let result = engine
251        .export_markdown(mem_filter, None)
252        .map_err(CliError::from_engine_op)?;
253
254    if ctx.json {
255        let mut body = json!({
256            "written": result.written,
257            "unchanged": result.unchanged,
258        });
259        if !result.skipped_mounts.is_empty() {
260            body["skipped_mounts"] = serde_json::to_value(&result.skipped_mounts)
261                .unwrap_or_else(|_| serde_json::Value::Array(Vec::new()));
262        }
263        print_json(&body)?;
264    } else {
265        let mut block = format!(
266            "# Export — markdown\n\n- Written: {}\n- Unchanged: {}",
267            result.written, result.unchanged,
268        );
269        if !result.skipped_mounts.is_empty() {
270            block.push_str("\n\n## Skipped mounts\n");
271            for m in &result.skipped_mounts {
272                block.push_str(&format!(
273                    "\n- `{}` — backend `{}` ({}); use `--format mem` for archive export",
274                    m.mem, m.active_backend, m.reason,
275                ));
276            }
277        }
278        print_markdown(&block);
279    }
280    Ok(())
281}
282
283#[cfg(feature = "mem-repo")]
284fn run_mem(ctx: &CliContext, engine: &memstead_base::Engine, args: Args) -> anyhow::Result<()> {
285    let mem_name = resolve_mem_name(engine, args.mem_name)?;
286    let config = engine
287        .mem_configs_named()
288        .find(|(name, _)| *name == mem_name)
289        .map(|(_, c)| c)
290        .ok_or_else(|| {
291            CliError::new(
292                ExitKind::NotFound,
293                "UNKNOWN_MEM",
294                format!("mem config not found for '{mem_name}'"),
295            )
296        })?;
297
298    let output = match args.output {
299        Some(p) => p,
300        None => default_output_path(&mem_name, config)?,
301    };
302
303    let result = engine
304        .export_mem(&mem_name, &output)
305        .map_err(CliError::from_engine_op)?;
306
307    // Surface each cross-mem edge
308    // whose target won't travel inside the single-mem archive — these
309    // are exactly what `install` will refuse, so showing them at export
310    // time lets the operator act before sharing.
311    let dangling = &result.dangling_cross_mem_edges;
312
313    if ctx.json {
314        let warnings: Vec<_> = dangling
315            .iter()
316            .map(|e| {
317                json!({
318                    "code": "DANGLING_CROSS_MEM_EDGE_IN_EXPORT",
319                    "entity": e.entity_path,
320                    "target_id": e.target_id,
321                    "target_mem": e.target_mem,
322                })
323            })
324            .collect();
325        print_json(&json!({
326            "archive_path": result.archive_path,
327            "name": result.name,
328            "version": result.version,
329            "entity_count": result.entity_count,
330            "size_bytes": result.size_bytes,
331            "warnings": warnings,
332        }))?;
333    } else {
334        let mut block = format!(
335            "# Exported `{}` v{}\n\n- Archive: `{}`\n- Entities: {}\n- Size: {} bytes",
336            result.name,
337            result.version,
338            result.archive_path,
339            result.entity_count,
340            result.size_bytes,
341        );
342        if !dangling.is_empty() {
343            block.push_str("\n\n## Warnings\n");
344            for e in dangling {
345                block.push_str(&format!(
346                    "\n- **DANGLING_CROSS_MEM_EDGE_IN_EXPORT**: `{}` → `{}` (mem `{}`) — \
347                     target lives outside this archive; `memstead install` will reject it unless \
348                     mem `{}` is also present.",
349                    e.entity_path, e.target_id, e.target_mem, e.target_mem,
350                ));
351            }
352        }
353        print_markdown(&block);
354    }
355    Ok(())
356}
357
358#[cfg(feature = "mem-repo")]
359fn resolve_mem_name(
360    engine: &memstead_base::Engine,
361    explicit: Option<String>,
362) -> anyhow::Result<String> {
363    if let Some(name) = explicit {
364        return Ok(name);
365    }
366    let writable: Vec<String> = engine
367        .mem_configs_named()
368        .filter(|(name, _)| engine.mem_router().is_writable(name))
369        .map(|(name, _)| name.to_string())
370        .collect();
371
372    match writable.len() {
373        0 => Err(CliError::new(
374            ExitKind::Generic,
375            "NO_WRITABLE_MEM",
376            "no writable mem loaded — nothing to export",
377        )
378        .into()),
379        1 => Ok(writable.into_iter().next().unwrap()),
380        _ => Err(CliError::new(
381            ExitKind::Validation,
382            "AMBIGUOUS_MEM",
383            format!(
384                "multiple writable mems loaded ({}); pass --mem <name>",
385                writable.join(", ")
386            ),
387        )
388        .with_details(json!({ "mems": writable }))
389        .into()),
390    }
391}
392
393/// Filesystem-mem `memstead export --format mem` builds the `.mem`
394/// archive bytes via [`memstead_base::filesystem::publish::assemble_archive`]
395/// (the same path `memstead publish` uses on a filesystem-mem workspace)
396/// and writes them to `--output` (defaulting to `<name>-<version>.mem`
397/// in cwd). `--mem` is accepted for shape parity but only the
398/// workspace's pinned mem matches.
399fn run_mem_filesystem(
400    ctx: &CliContext,
401    engine: &memstead_base::Engine,
402    args: Args,
403) -> anyhow::Result<()> {
404    let workspace_mem = engine
405        .mem_names()
406        .into_iter()
407        .next()
408        .map(String::from)
409        .unwrap_or_default();
410    if let Some(name) = args.mem_name.as_deref()
411        && name != workspace_mem
412    {
413        return Err(CliError::new(
414                ExitKind::NotFound,
415                "UNKNOWN_MEM",
416                format!(
417                    "filesystem-mem is single-mem: workspace mem is `{workspace_mem}`, --mem `{name}` does not match"
418                ),
419            )
420            .into());
421    }
422
423    // assemble_archive is engine-agnostic now — pass the discovered
424    // workspace root directly.
425    let workspace_root =
426        crate::setup::find_filesystem_workspace_root(&std::env::current_dir().map_err(|e| {
427            CliError::new(
428                ExitKind::Generic,
429                crate::INTERNAL_CODE,
430                format!("current_dir: {e}"),
431            )
432        })?)
433        .ok_or_else(|| {
434            CliError::new(
435                ExitKind::NotFound,
436                "WORKSPACE_NOT_INITIALISED",
437                "no filesystem-mem workspace found from cwd",
438            )
439        })?;
440    let bytes =
441        memstead_base::filesystem::publish::assemble_archive(&workspace_root).map_err(|e| {
442            // F1: backend-symmetric typed envelope for the missing-
443            // version case — the mem-repo path surfaces the same
444            // MEM_CONFIG_INCOMPLETE via Engine::export_mem.
445            if matches!(
446                &e,
447                memstead_base::filesystem::publish::AssembleError::Config(
448                    memstead_schema::PublishConversionError::MissingVersion
449                )
450            ) {
451                CliError::from_engine_op(memstead_base::EngineError::MemConfigIncomplete {
452                    mem: workspace_mem.clone(),
453                    missing_fields: vec!["version".to_string()],
454                })
455            } else {
456                CliError::new(ExitKind::Generic, "ARCHIVE_ASSEMBLY_FAILED", e.to_string())
457            }
458        })?;
459
460    let output = match args.output {
461        Some(p) => p,
462        None => {
463            // Filesystem-mem config doesn't carry `version` today —
464            // archive identity is `<mem_name>.mem` until the
465            // assemble path threads a version through. Operator can
466            // override with `-o`.
467            PathBuf::from(format!(
468                "{workspace_mem}.{}",
469                memstead_schema::ARCHIVE_EXTENSION
470            ))
471        }
472    };
473
474    let size_bytes = bytes.len();
475    std::fs::write(&output, &bytes).map_err(|e| {
476        CliError::new(
477            ExitKind::Generic,
478            crate::INTERNAL_CODE,
479            format!("write {}: {e}", output.display()),
480        )
481    })?;
482    // Count only the exported mem's entities — the store also holds
483    // mounted sibling mems (the multi-mount setup), which do not travel
484    // in this archive.
485    let entity_count = engine
486        .store()
487        .all_entities()
488        .filter(|e| !e.stub && e.id.mem() == workspace_mem)
489        .count();
490
491    if ctx.json {
492        print_json(&json!({
493            "archive_path": output.to_string_lossy(),
494            "name": workspace_mem,
495            "entity_count": entity_count,
496            "size_bytes": size_bytes,
497        }))?;
498    } else {
499        print_markdown(&format!(
500            "# Exported `{workspace_mem}`\n\n- Archive: `{}`\n- Entities: {}\n- Size: {} bytes",
501            output.display(),
502            entity_count,
503            size_bytes,
504        ));
505    }
506    Ok(())
507}
508
509#[cfg(feature = "mem-repo")]
510fn default_output_path(
511    mem_name: &str,
512    config: &memstead_schema::MemConfig,
513) -> anyhow::Result<PathBuf> {
514    let version = config.version.as_ref().ok_or_else(|| {
515        // F1: typed envelope replaces the pre-fix INTERNAL-collapse
516        // path (config lives at
517        // `__MEMSTEAD:mems/<name>/config.json` for the mem-repo
518        // backend). The recovery hint
519        // names the engine-owned setter that mutates the right
520        // surface for whichever backend serves the mem.
521        CliError::from_engine_op(memstead_base::EngineError::MemConfigIncomplete {
522            mem: mem_name.to_string(),
523            missing_fields: vec!["version".to_string()],
524        })
525    })?;
526    // The mem name is supplied by the caller (engine mem state)
527    // rather than pulled from the now-optional in-config `name` field.
528    let filename = format!(
529        "{mem_name}-{version}.{}",
530        memstead_schema::ARCHIVE_EXTENSION
531    );
532    Ok(PathBuf::from(filename))
533}
534
535/// `--format llms-txt` — the whole mem as one Markdown document an agent can
536/// read in a single pass. Backend-uniform via [`CliEngine::base`] and
537/// observably read-only, like `--format json`.
538///
539/// The document shape is the engine's, shared with the served
540/// `/llms-full.txt` endpoint. This function supplies only what a *deployment*
541/// would otherwise supply and a CLI cannot: the link base. It deliberately
542/// supplies no authority and no wider-project block — a file exported from
543/// someone's own workspace has no deployment vouching for it, and a header
544/// claiming otherwise would put a false provenance line atop the one document
545/// written to be read whole.
546fn run_llms_txt(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
547    let engine_holder = ctx.cli_engine()?;
548    let engine = engine_holder.base();
549    let mem = resolve_single_mem(engine, args.mem_name.as_deref())?;
550
551    let ctx_opts = memstead_base::engine::export_llms_txt::LlmsTxtContext {
552        authority: None,
553        href_prefix: args
554            .base_url
555            .clone()
556            .map(|u| u.trim_end_matches('/').to_string())
557            .unwrap_or_default(),
558        wider_project: Vec::new(),
559    };
560    let doc = engine
561        .render_llms_txt(&mem, &ctx_opts)
562        .map_err(CliError::from_engine_op)?;
563
564    match &args.output {
565        Some(path) => {
566            std::fs::write(path, &doc).map_err(|e| {
567                CliError::new(
568                    ExitKind::Generic,
569                    "IO_ERROR",
570                    format!("write {}: {e}", path.display()),
571                )
572            })?;
573            if ctx.json {
574                print_json(&serde_json::json!({
575                    "mem": mem,
576                    "written": path.display().to_string(),
577                    "bytes": doc.len(),
578                }))?;
579            } else {
580                println!("Wrote {} ({} bytes)", path.display(), doc.len());
581            }
582        }
583        // No `-o` prints the document itself — it is text meant to be read or
584        // piped, so stdout is the natural destination rather than a file the
585        // caller then has to find.
586        None => print!("{doc}"),
587    }
588    Ok(())
589}
590
591/// Resolve the one mem an export targets: an explicit `--mem` wins (read-only
592/// mounts allowed); otherwise the sole writable mem, refusing when there is
593/// none or several rather than picking one.
594fn resolve_single_mem(
595    engine: &memstead_base::Engine,
596    requested: Option<&str>,
597) -> Result<String, CliError> {
598    if let Some(m) = requested {
599        return Ok(m.to_string());
600    }
601    let writables: Vec<String> = engine
602        .writable_mem_names()
603        .iter()
604        .map(|s| s.to_string())
605        .collect();
606    match writables.as_slice() {
607        [one] => Ok(one.clone()),
608        [] => Err(CliError::new(
609            ExitKind::Validation,
610            "INVALID_INPUT",
611            "no writable mem loaded — pass --mem <name>",
612        )),
613        _ => Err(CliError::new(
614            ExitKind::Validation,
615            "INVALID_INPUT",
616            format!(
617                "multiple writable mems loaded ({}) — pass --mem <name>",
618                writables.join(", ")
619            ),
620        )),
621    }
622}
623
624/// `--format html` — one self-contained HTML file per mem (the read
625/// surface for non-operators). Backend-uniform via [`CliEngine::base`]
626/// and observably read-only. The export date is stamped once (UTC);
627/// `--today` on `memstead due` has no analogue here because the date
628/// only labels the export, it never filters.
629fn run_html(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
630    let engine_holder = ctx.cli_engine()?;
631    let engine = engine_holder.base();
632    // Resolve the target mem like `--format mem`: explicit name wins
633    // (read-only mounts allowed); otherwise the sole writable mem.
634    let mem = match &args.mem_name {
635        Some(m) => m.clone(),
636        None => {
637            let writables: Vec<String> = engine
638                .writable_mem_names()
639                .iter()
640                .map(|s| s.to_string())
641                .collect();
642            match writables.as_slice() {
643                [one] => one.clone(),
644                [] => {
645                    return Err(CliError::new(
646                        ExitKind::Validation,
647                        "INVALID_INPUT",
648                        "no writable mem loaded — pass --mem <name>",
649                    )
650                    .into());
651                }
652                _ => {
653                    return Err(CliError::new(
654                        ExitKind::Validation,
655                        "INVALID_INPUT",
656                        format!(
657                            "multiple writable mems loaded ({}) — pass --mem <name>",
658                            writables.join(", ")
659                        ),
660                    )
661                    .into());
662                }
663            }
664        }
665    };
666    let now = time::OffsetDateTime::now_utc();
667    let export_date = format!(
668        "{:04}-{:02}-{:02}",
669        now.year(),
670        u8::from(now.month()),
671        now.day()
672    );
673    let html = engine
674        .render_html_export(&mem, &export_date)
675        .map_err(CliError::from_engine_op)?;
676    let out_path = args
677        .output
678        .clone()
679        .unwrap_or_else(|| PathBuf::from(format!("{mem}.html")));
680    std::fs::write(&out_path, &html).map_err(|e| {
681        CliError::new(
682            ExitKind::Generic,
683            "IO_ERROR",
684            format!("write {}: {e}", out_path.display()),
685        )
686    })?;
687    if ctx.json {
688        print_json(&serde_json::json!({
689            "format": "html",
690            "mem": mem,
691            "path": out_path,
692            "bytes": html.len(),
693            "exported": export_date,
694        }))?;
695    } else {
696        print_markdown(&format!(
697            "# HTML export\n\n- Mem: `{mem}`\n- File: `{}`\n- Size: {} bytes\n- Exported: {export_date}\n\nSelf-contained — open it from anywhere, no server needed.\n",
698            out_path.display(),
699            html.len()
700        ));
701    }
702    Ok(())
703}
704
705#[cfg(test)]
706mod tests {
707    use super::*;
708    use clap::Parser;
709
710    /// Mem selection is `--mem`, converged onto the convention every
711    /// other subcommand uses; the former `--mem-name` outlier is gone.
712    #[test]
713    fn export_mem_selection_flag_is_mem_not_mem_name() {
714        let parsed = Args::try_parse_from(["export", "--mem", "specs", "--format", "mem"]).unwrap();
715        assert_eq!(parsed.mem_name.as_deref(), Some("specs"));
716        assert!(
717            Args::try_parse_from(["export", "--mem-name", "specs"]).is_err(),
718            "the retired --mem-name flag must not parse"
719        );
720    }
721}