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