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/v1` 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            .mounts_with_optional_config()
198            .find(|(name, _)| name == mem_name)
199            .and_then(|(_, c)| c)
200            .and_then(|c| c.schema.as_ref())
201            .map(|s| s.to_string());
202
203        let mut entities: Vec<&memstead_base::Entity> = engine
204            .store()
205            .all_entities()
206            .filter(|e| !e.stub && e.mem == *mem_name)
207            .collect();
208        entities.sort_by(|a, b| a.id.as_ref().cmp(b.id.as_ref()));
209
210        let envelopes: Vec<serde_json::Value> = entities
211            .iter()
212            .map(|entity| {
213                let body = memstead_base::render::render_entity_markdown(entity, None);
214                let tokens = memstead_base::chunking::estimate_tokens(&body);
215                let outgoing = engine.store().outgoing(&entity.id);
216                // Export is a canonical-form surface — computed
217                // signals are a serving projection and stay out.
218                memstead_base::render::build_entity_envelope(
219                    entity,
220                    tokens,
221                    None,
222                    None,
223                    None,
224                    engine.mem_origin_class(entity.id.mem()),
225                    outgoing,
226                    None,
227                    None,
228                    None,
229                )
230            })
231            .collect();
232
233        let mut group = serde_json::Map::new();
234        if let Some(s) = schema_pin {
235            group.insert("schema".to_string(), json!(s));
236        }
237        group.insert(
238            "read_only".to_string(),
239            json!(!engine.mem_router().is_writable(mem_name)),
240        );
241        group.insert("entity_count".to_string(), json!(envelopes.len()));
242        group.insert("entities".to_string(), serde_json::Value::Array(envelopes));
243        mems.insert(mem_name.clone(), serde_json::Value::Object(group));
244    }
245
246    print_json(&json!({
247        "format": JSON_EXPORT_FORMAT,
248        "mems": mems,
249    }))
250}
251
252#[cfg(feature = "mem-repo")]
253fn run_markdown(
254    ctx: &CliContext,
255    engine: &memstead_base::Engine,
256    mem_filter: Option<&str>,
257) -> anyhow::Result<()> {
258    // The engine returns a
259    // typed `MARKDOWN_EXPORT_UNSUPPORTED_BACKEND` when `--mem`
260    // targets a mem whose backend doesn't support markdown
261    // regeneration. The workspace-wide path returns counts plus a
262    // structured `skipped_mounts` list.
263    let result = engine
264        .export_markdown(mem_filter, None)
265        .map_err(CliError::from_engine_op)?;
266
267    if ctx.json {
268        let mut body = json!({
269            "written": result.written,
270            "unchanged": result.unchanged,
271        });
272        if !result.skipped_mounts.is_empty() {
273            body["skipped_mounts"] = serde_json::to_value(&result.skipped_mounts)
274                .unwrap_or_else(|_| serde_json::Value::Array(Vec::new()));
275        }
276        if !result.refused_entities.is_empty() {
277            body["refused_entities"] = serde_json::to_value(&result.refused_entities)
278                .unwrap_or_else(|_| serde_json::Value::Array(Vec::new()));
279        }
280        print_json(&body)?;
281    } else {
282        let mut block = format!(
283            "# Export — markdown\n\n- Written: {}\n- Unchanged: {}",
284            result.written, result.unchanged,
285        );
286        if !result.skipped_mounts.is_empty() {
287            block.push_str("\n\n## Skipped mounts\n");
288            for m in &result.skipped_mounts {
289                block.push_str(&format!(
290                    "\n- `{}` — backend `{}` ({}); use `--format mem` for archive export",
291                    m.mem, m.active_backend, m.reason,
292                ));
293            }
294        }
295        // Never silent: an entity the export declined is one the operator has
296        // to repair through the engine, and an export that reported only
297        // counts would read as complete over content it did not write.
298        if !result.refused_entities.is_empty() {
299            block.push_str("\n\n## Refused entities\n");
300            for r in &result.refused_entities {
301                block.push_str(&format!("\n- `{}` [{}] — {}", r.id, r.reason, r.detail));
302            }
303        }
304        print_markdown(&block);
305    }
306    Ok(())
307}
308
309#[cfg(feature = "mem-repo")]
310fn run_mem(ctx: &CliContext, engine: &memstead_base::Engine, args: Args) -> anyhow::Result<()> {
311    let mem_name = resolve_mem_name(engine, args.mem_name)?;
312    // Deliberately the config-keyed query: a mem-archive export cannot be
313    // built without the config it packages, so "no config" is a genuine
314    // refusal here rather than a mount to enumerate (04/05, criterion 8 —
315    // the criterion is that no consumer SILENTLY skips, and this one refuses
316    // by name).
317    let config = engine
318        .mem_configs_named()
319        .find(|(name, _)| *name == mem_name)
320        .map(|(_, c)| c)
321        .ok_or_else(|| {
322            CliError::new(
323                ExitKind::NotFound,
324                "UNKNOWN_MEM",
325                format!("mem config not found for '{mem_name}'"),
326            )
327        })?;
328
329    let output = match args.output {
330        Some(p) => p,
331        None => default_output_path(&mem_name, config)?,
332    };
333
334    let mut result = engine
335        .export_mem(&mem_name, &output)
336        .map_err(CliError::from_engine_op)?;
337
338    // `--self-contained`: drop the cross-mem rows the archive cannot
339    // resolve, re-pack, strictly validate, and write the result over the
340    // just-written file. The dropped edges replace the dangling warnings
341    // in the report: they are the same edges, now gone instead of
342    // refused later.
343    let dropped = if args.self_contained {
344        let self_contained = make_self_contained_on_disk(&output)?;
345        result.size_bytes = self_contained.bytes.len() as u64;
346        result.dangling_cross_mem_edges.clear();
347        Some(self_contained.dropped)
348    } else {
349        None
350    };
351
352    // Surface each cross-mem edge
353    // whose target won't travel inside the single-mem archive — these
354    // are exactly what `install` will refuse, so showing them at export
355    // time lets the operator act before sharing.
356    let dangling = &result.dangling_cross_mem_edges;
357
358    if ctx.json {
359        let mut warnings: Vec<_> = dangling
360            .iter()
361            .map(|e| {
362                json!({
363                    "code": "DANGLING_CROSS_MEM_EDGE_IN_EXPORT",
364                    "entity": e.entity_path,
365                    "target_id": e.target_id,
366                    "target_mem": e.target_mem,
367                })
368            })
369            .collect();
370        if let Some(dropped) = &dropped {
371            warnings.extend(dropped.iter().map(|e| {
372                json!({
373                    "code": "CROSS_MEM_EDGE_DROPPED",
374                    "entity": e.entity_path,
375                    "target_id": e.target_id,
376                    "target_mem": e.target_mem,
377                })
378            }));
379        }
380        warnings.extend(result.unterminated_fence_entities.iter().map(|id| {
381            json!({
382                "code": "UNTERMINATED_FENCE_IN_EXPORT",
383                "entity": id,
384            })
385        }));
386        print_json(&json!({
387            "archive_path": result.archive_path,
388            "name": result.name,
389            "version": result.version,
390            "entity_count": result.entity_count,
391            "size_bytes": result.size_bytes,
392            "self_contained": args.self_contained,
393            "warnings": warnings,
394        }))?;
395    } else {
396        let mut block = format!(
397            "# Exported `{}` v{}\n\n- Archive: `{}`\n- Entities: {}\n- Size: {} bytes",
398            result.name,
399            result.version,
400            result.archive_path,
401            result.entity_count,
402            result.size_bytes,
403        );
404        if args.self_contained {
405            block.push_str("\n- Self-contained: yes");
406        }
407        // `install` will refuse the archive for each of these, so the operator
408        // learns it here rather than after sharing.
409        if !result.unterminated_fence_entities.is_empty() {
410            block.push_str(
411                "\n\n## Entities `install` will refuse\n\nEach ends a section inside an \
412                 unterminated code fence, which absorbed the sections after it. Repair through \
413                 the engine (replace the absorbing section) and re-export.\n",
414            );
415            for id in &result.unterminated_fence_entities {
416                block.push_str(&format!("\n- `{id}` [UNTERMINATED_FENCE_IN_EXPORT]"));
417            }
418        }
419        if !dangling.is_empty() {
420            block.push_str("\n\n## Warnings\n");
421            for e in dangling {
422                block.push_str(&format!(
423                    "\n- **DANGLING_CROSS_MEM_EDGE_IN_EXPORT**: `{}` → `{}` (mem `{}`) — \
424                     target lives outside this archive; `memstead install` will reject it unless \
425                     mem `{}` is also present. Re-export with `--self-contained` to drop such \
426                     rows (each reported; body wiki-link prose survives).",
427                    e.entity_path, e.target_id, e.target_mem, e.target_mem,
428                ));
429            }
430        }
431        if let Some(dropped) = &dropped
432            && !dropped.is_empty()
433        {
434            block.push_str("\n\n## Dropped cross-mem edges\n");
435            for e in dropped {
436                block.push_str(&format!(
437                    "\n- **CROSS_MEM_EDGE_DROPPED**: `{}` → `{}` (mem `{}`): the relationship \
438                     row does not travel; a body wiki-link to the same target still does.",
439                    e.entity_path, e.target_id, e.target_mem,
440                ));
441            }
442        }
443        print_markdown(&block);
444    }
445    Ok(())
446}
447
448/// Apply [`memstead_base::validator::make_archive_self_contained`] to
449/// the archive at `path`, writing the self-contained bytes back in place.
450fn make_self_contained_on_disk(
451    path: &std::path::Path,
452) -> anyhow::Result<memstead_base::validator::SelfContainedArchive> {
453    let bytes = std::fs::read(path).map_err(|e| {
454        CliError::new(
455            ExitKind::Generic,
456            crate::INTERNAL_CODE,
457            format!("read {}: {e}", path.display()),
458        )
459    })?;
460    let out = memstead_base::validator::make_archive_self_contained(&bytes).map_err(|e| {
461        CliError::new(
462            ExitKind::Generic,
463            "ARCHIVE_VALIDATION_FAILED",
464            format!("self-contained re-pack of {}: {e}", path.display()),
465        )
466    })?;
467    std::fs::write(path, &out.bytes).map_err(|e| {
468        CliError::new(
469            ExitKind::Generic,
470            crate::INTERNAL_CODE,
471            format!("write {}: {e}", path.display()),
472        )
473    })?;
474    Ok(out)
475}
476
477#[cfg(feature = "mem-repo")]
478fn resolve_mem_name(
479    engine: &memstead_base::Engine,
480    explicit: Option<String>,
481) -> anyhow::Result<String> {
482    if let Some(name) = explicit {
483        return Ok(name);
484    }
485    // Every mount (04/05, criterion 8): a broken mem is still a writable mem
486    // for the purpose of "is the target unambiguous", and omitting it turns an
487    // ambiguous workspace into a silently-resolved one.
488    let writable: Vec<String> = engine
489        .mounts_with_optional_config()
490        .filter(|(name, _)| engine.mem_router().is_writable(name))
491        .map(|(name, _)| name.to_string())
492        .collect();
493
494    match writable.len() {
495        0 => Err(CliError::new(
496            ExitKind::Generic,
497            "NO_WRITABLE_MEM",
498            "no writable mem loaded — nothing to export",
499        )
500        .into()),
501        1 => Ok(writable.into_iter().next().unwrap()),
502        _ => Err(CliError::new(
503            ExitKind::Validation,
504            "AMBIGUOUS_MEM",
505            format!(
506                "multiple writable mems loaded ({}); pass --mem <name>",
507                writable.join(", ")
508            ),
509        )
510        .with_details(json!({ "mems": writable }))
511        .into()),
512    }
513}
514
515/// Filesystem-mem `memstead export --format mem` builds the `.mem`
516/// archive bytes via [`memstead_base::Engine::export_mem_to_bytes`]
517/// (the same primitive the mem-repo path and `memstead publish --mem`
518/// use) and writes them to `--output` (defaulting to `<name>.mem` in
519/// cwd). `--mem` is accepted for shape parity but only the workspace's
520/// pinned mem matches.
521fn run_mem_filesystem(
522    ctx: &CliContext,
523    engine: &memstead_base::Engine,
524    args: Args,
525) -> anyhow::Result<()> {
526    let workspace_mem = engine
527        .mem_names()
528        .into_iter()
529        .next()
530        .map(String::from)
531        .unwrap_or_default();
532    if let Some(name) = args.mem_name.as_deref()
533        && name != workspace_mem
534    {
535        return Err(CliError::new(
536                ExitKind::NotFound,
537                "UNKNOWN_MEM",
538                format!(
539                    "filesystem-mem is single-mem: workspace mem is `{workspace_mem}`, --mem `{name}` does not match"
540                ),
541            )
542            .into());
543    }
544
545    // Export through the ENGINE, which reads whatever layout it
546    // booted: the mount roster locates the mem's folder and its
547    // `.memstead/config.json` inside it. The legacy assemble path
548    // resolved the config against the WORKSPACE root instead — in the
549    // legacy single-mem layout the two coincide, but in the current
550    // (`workspace.toml` + `state/mounts.json`) layout they do not, so
551    // `export --format mem` failed on every workspace `quickstart`
552    // produces while the rest of the CLI worked (sealed-gate finding
553    // F6). One exporter for every backend also keeps the typed
554    // refusals backend-symmetric (MEM_CONFIG_INCOMPLETE on a missing
555    // version, F1) without a special-cased mapping.
556    let bytes = engine
557        .export_mem_to_bytes(&workspace_mem)
558        .map_err(CliError::from_engine_op)?;
559
560    let output = match args.output {
561        Some(p) => p,
562        None => {
563            // Filesystem-mem config doesn't carry `version` today —
564            // archive identity is `<mem_name>.mem` until the
565            // assemble path threads a version through. Operator can
566            // override with `-o`.
567            PathBuf::from(format!(
568                "{workspace_mem}.{}",
569                memstead_schema::ARCHIVE_EXTENSION
570            ))
571        }
572    };
573
574    std::fs::write(&output, &bytes).map_err(|e| {
575        CliError::new(
576            ExitKind::Generic,
577            crate::INTERNAL_CODE,
578            format!("write {}: {e}", output.display()),
579        )
580    })?;
581    let dropped = if args.self_contained {
582        Some(make_self_contained_on_disk(&output)?.dropped)
583    } else {
584        None
585    };
586    let size_bytes = std::fs::metadata(&output)
587        .map(|m| m.len() as usize)
588        .unwrap_or(bytes.len());
589    // Count only the exported mem's entities — the store also holds
590    // mounted sibling mems (the multi-mount setup), which do not travel
591    // in this archive.
592    let entity_count = engine
593        .store()
594        .all_entities()
595        .filter(|e| !e.stub && e.id.mem() == workspace_mem)
596        .count();
597
598    if ctx.json {
599        let warnings: Vec<_> = dropped
600            .iter()
601            .flatten()
602            .map(|e| {
603                json!({
604                    "code": "CROSS_MEM_EDGE_DROPPED",
605                    "entity": e.entity_path,
606                    "target_id": e.target_id,
607                    "target_mem": e.target_mem,
608                })
609            })
610            .collect();
611        print_json(&json!({
612            "archive_path": output.to_string_lossy(),
613            "name": workspace_mem,
614            "entity_count": entity_count,
615            "size_bytes": size_bytes,
616            "self_contained": args.self_contained,
617            "warnings": warnings,
618        }))?;
619    } else {
620        let mut block = format!(
621            "# Exported `{workspace_mem}`\n\n- Archive: `{}`\n- Entities: {}\n- Size: {} bytes",
622            output.display(),
623            entity_count,
624            size_bytes,
625        );
626        if args.self_contained {
627            block.push_str("\n- Self-contained: yes");
628            let n = dropped.as_ref().map(|d| d.len()).unwrap_or(0);
629            if n > 0 {
630                block.push_str(&format!("\n- Cross-mem edges dropped: {n}"));
631            }
632        }
633        print_markdown(&block);
634    }
635    Ok(())
636}
637
638#[cfg(feature = "mem-repo")]
639fn default_output_path(
640    mem_name: &str,
641    config: &memstead_schema::MemConfig,
642) -> anyhow::Result<PathBuf> {
643    let version = config.version.as_ref().ok_or_else(|| {
644        // F1: typed envelope replaces the pre-fix INTERNAL-collapse
645        // path (config lives at
646        // `__MEMSTEAD:mems/<name>/config.json` for the mem-repo
647        // backend). The recovery hint
648        // names the engine-owned setter that mutates the right
649        // surface for whichever backend serves the mem.
650        CliError::from_engine_op(memstead_base::EngineError::MemConfigIncomplete {
651            mem: mem_name.to_string(),
652            missing_fields: vec!["version".to_string()],
653        })
654    })?;
655    // The mem name is supplied by the caller (engine mem state)
656    // rather than pulled from the now-optional in-config `name` field.
657    let filename = format!(
658        "{mem_name}-{version}.{}",
659        memstead_schema::ARCHIVE_EXTENSION
660    );
661    Ok(PathBuf::from(filename))
662}
663
664/// `--format llms-txt` — the whole mem as one Markdown document an agent can
665/// read in a single pass. Backend-uniform via [`CliEngine::base`] and
666/// observably read-only, like `--format json`.
667///
668/// The document shape is the engine's, shared with the served
669/// `/llms-full.txt` endpoint. This function supplies only what a *deployment*
670/// would otherwise supply and a CLI cannot: the link base. It deliberately
671/// supplies no authority and no wider-project block — a file exported from
672/// someone's own workspace has no deployment vouching for it, and a header
673/// claiming otherwise would put a false provenance line atop the one document
674/// written to be read whole.
675fn run_llms_txt(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
676    let engine_holder = ctx.cli_engine()?;
677    let engine = engine_holder.base();
678    let mem = resolve_single_mem(engine, args.mem_name.as_deref())?;
679
680    let ctx_opts = memstead_base::engine::export_llms_txt::LlmsTxtContext {
681        authority: None,
682        href_prefix: args
683            .base_url
684            .clone()
685            .map(|u| u.trim_end_matches('/').to_string())
686            .unwrap_or_default(),
687        wider_project: Vec::new(),
688    };
689    let doc = engine
690        .render_llms_txt(&mem, &ctx_opts)
691        .map_err(CliError::from_engine_op)?;
692
693    match &args.output {
694        Some(path) => {
695            std::fs::write(path, &doc).map_err(|e| {
696                CliError::new(
697                    ExitKind::Generic,
698                    "IO_ERROR",
699                    format!("write {}: {e}", path.display()),
700                )
701            })?;
702            if ctx.json {
703                print_json(&serde_json::json!({
704                    "mem": mem,
705                    "written": path.display().to_string(),
706                    "bytes": doc.len(),
707                }))?;
708            } else {
709                println!("Wrote {} ({} bytes)", path.display(), doc.len());
710            }
711        }
712        // No `-o` prints the document itself — it is text meant to be read or
713        // piped, so stdout is the natural destination rather than a file the
714        // caller then has to find.
715        None => print!("{doc}"),
716    }
717    Ok(())
718}
719
720/// Resolve the one mem an export targets: an explicit `--mem` wins (read-only
721/// mounts allowed); otherwise the sole writable mem, refusing when there is
722/// none or several rather than picking one.
723fn resolve_single_mem(
724    engine: &memstead_base::Engine,
725    requested: Option<&str>,
726) -> Result<String, CliError> {
727    if let Some(m) = requested {
728        return Ok(m.to_string());
729    }
730    let writables: Vec<String> = engine
731        .writable_mem_names()
732        .iter()
733        .map(|s| s.to_string())
734        .collect();
735    match writables.as_slice() {
736        [one] => Ok(one.clone()),
737        [] => Err(CliError::new(
738            ExitKind::Validation,
739            "INVALID_INPUT",
740            "no writable mem loaded — pass --mem <name>",
741        )),
742        _ => Err(CliError::new(
743            ExitKind::Validation,
744            "INVALID_INPUT",
745            format!(
746                "multiple writable mems loaded ({}) — pass --mem <name>",
747                writables.join(", ")
748            ),
749        )),
750    }
751}
752
753/// `--format html` — one self-contained HTML file per mem (the read
754/// surface for non-operators). Backend-uniform via [`CliEngine::base`]
755/// and observably read-only. The export date is stamped once (UTC);
756/// `--today` on `memstead due` has no analogue here because the date
757/// only labels the export, it never filters.
758fn run_html(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
759    let engine_holder = ctx.cli_engine()?;
760    let engine = engine_holder.base();
761    // Resolve the target mem like `--format mem`: explicit name wins
762    // (read-only mounts allowed); otherwise the sole writable mem.
763    let mem = match &args.mem_name {
764        Some(m) => m.clone(),
765        None => {
766            let writables: Vec<String> = engine
767                .writable_mem_names()
768                .iter()
769                .map(|s| s.to_string())
770                .collect();
771            match writables.as_slice() {
772                [one] => one.clone(),
773                [] => {
774                    return Err(CliError::new(
775                        ExitKind::Validation,
776                        "INVALID_INPUT",
777                        "no writable mem loaded — pass --mem <name>",
778                    )
779                    .into());
780                }
781                _ => {
782                    return Err(CliError::new(
783                        ExitKind::Validation,
784                        "INVALID_INPUT",
785                        format!(
786                            "multiple writable mems loaded ({}) — pass --mem <name>",
787                            writables.join(", ")
788                        ),
789                    )
790                    .into());
791                }
792            }
793        }
794    };
795    let now = time::OffsetDateTime::now_utc();
796    let export_date = format!(
797        "{:04}-{:02}-{:02}",
798        now.year(),
799        u8::from(now.month()),
800        now.day()
801    );
802    let html = engine
803        .render_html_export(&mem, &export_date)
804        .map_err(CliError::from_engine_op)?;
805    let out_path = args
806        .output
807        .clone()
808        .unwrap_or_else(|| PathBuf::from(format!("{mem}.html")));
809    std::fs::write(&out_path, &html).map_err(|e| {
810        CliError::new(
811            ExitKind::Generic,
812            "IO_ERROR",
813            format!("write {}: {e}", out_path.display()),
814        )
815    })?;
816    if ctx.json {
817        print_json(&serde_json::json!({
818            "format": "html",
819            "mem": mem,
820            "path": out_path,
821            "bytes": html.len(),
822            "exported": export_date,
823        }))?;
824    } else {
825        print_markdown(&format!(
826            "# 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",
827            out_path.display(),
828            html.len()
829        ));
830    }
831    Ok(())
832}
833
834#[cfg(test)]
835mod tests {
836    use super::*;
837    use clap::Parser;
838
839    /// Mem selection is `--mem`, converged onto the convention every
840    /// other subcommand uses; the former `--mem-name` outlier is gone.
841    #[test]
842    fn export_mem_selection_flag_is_mem_not_mem_name() {
843        let parsed = Args::try_parse_from(["export", "--mem", "specs", "--format", "mem"]).unwrap();
844        assert_eq!(parsed.mem_name.as_deref(), Some("specs"));
845        assert!(
846            Args::try_parse_from(["export", "--mem-name", "specs"]).is_err(),
847            "the retired --mem-name flag must not parse"
848        );
849    }
850}