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.",
426                    e.entity_path, e.target_id, e.target_mem, e.target_mem,
427                ));
428            }
429        }
430        if let Some(dropped) = &dropped
431            && !dropped.is_empty()
432        {
433            block.push_str("\n\n## Dropped cross-mem edges\n");
434            for e in dropped {
435                block.push_str(&format!(
436                    "\n- **CROSS_MEM_EDGE_DROPPED**: `{}` → `{}` (mem `{}`): the relationship \
437                     row does not travel; a body wiki-link to the same target still does.",
438                    e.entity_path, e.target_id, e.target_mem,
439                ));
440            }
441        }
442        print_markdown(&block);
443    }
444    Ok(())
445}
446
447/// Apply [`memstead_base::validator::make_archive_self_contained`] to
448/// the archive at `path`, writing the self-contained bytes back in place.
449fn make_self_contained_on_disk(
450    path: &std::path::Path,
451) -> anyhow::Result<memstead_base::validator::SelfContainedArchive> {
452    let bytes = std::fs::read(path).map_err(|e| {
453        CliError::new(
454            ExitKind::Generic,
455            crate::INTERNAL_CODE,
456            format!("read {}: {e}", path.display()),
457        )
458    })?;
459    let out = memstead_base::validator::make_archive_self_contained(&bytes).map_err(|e| {
460        CliError::new(
461            ExitKind::Generic,
462            "ARCHIVE_VALIDATION_FAILED",
463            format!("self-contained re-pack of {}: {e}", path.display()),
464        )
465    })?;
466    std::fs::write(path, &out.bytes).map_err(|e| {
467        CliError::new(
468            ExitKind::Generic,
469            crate::INTERNAL_CODE,
470            format!("write {}: {e}", path.display()),
471        )
472    })?;
473    Ok(out)
474}
475
476#[cfg(feature = "mem-repo")]
477fn resolve_mem_name(
478    engine: &memstead_base::Engine,
479    explicit: Option<String>,
480) -> anyhow::Result<String> {
481    if let Some(name) = explicit {
482        return Ok(name);
483    }
484    // Every mount (04/05, criterion 8): a broken mem is still a writable mem
485    // for the purpose of "is the target unambiguous", and omitting it turns an
486    // ambiguous workspace into a silently-resolved one.
487    let writable: Vec<String> = engine
488        .mounts_with_optional_config()
489        .filter(|(name, _)| engine.mem_router().is_writable(name))
490        .map(|(name, _)| name.to_string())
491        .collect();
492
493    match writable.len() {
494        0 => Err(CliError::new(
495            ExitKind::Generic,
496            "NO_WRITABLE_MEM",
497            "no writable mem loaded — nothing to export",
498        )
499        .into()),
500        1 => Ok(writable.into_iter().next().unwrap()),
501        _ => Err(CliError::new(
502            ExitKind::Validation,
503            "AMBIGUOUS_MEM",
504            format!(
505                "multiple writable mems loaded ({}); pass --mem <name>",
506                writable.join(", ")
507            ),
508        )
509        .with_details(json!({ "mems": writable }))
510        .into()),
511    }
512}
513
514/// Filesystem-mem `memstead export --format mem` builds the `.mem`
515/// archive bytes via [`memstead_base::Engine::export_mem_to_bytes`]
516/// (the same primitive the mem-repo path and `memstead publish --mem`
517/// use) and writes them to `--output` (defaulting to `<name>.mem` in
518/// cwd). `--mem` is accepted for shape parity but only the workspace's
519/// pinned mem matches.
520fn run_mem_filesystem(
521    ctx: &CliContext,
522    engine: &memstead_base::Engine,
523    args: Args,
524) -> anyhow::Result<()> {
525    let workspace_mem = engine
526        .mem_names()
527        .into_iter()
528        .next()
529        .map(String::from)
530        .unwrap_or_default();
531    if let Some(name) = args.mem_name.as_deref()
532        && name != workspace_mem
533    {
534        return Err(CliError::new(
535                ExitKind::NotFound,
536                "UNKNOWN_MEM",
537                format!(
538                    "filesystem-mem is single-mem: workspace mem is `{workspace_mem}`, --mem `{name}` does not match"
539                ),
540            )
541            .into());
542    }
543
544    // Export through the ENGINE, which reads whatever layout it
545    // booted: the mount roster locates the mem's folder and its
546    // `.memstead/config.json` inside it. The legacy assemble path
547    // resolved the config against the WORKSPACE root instead — in the
548    // legacy single-mem layout the two coincide, but in the current
549    // (`workspace.toml` + `state/mounts.json`) layout they do not, so
550    // `export --format mem` failed on every workspace `quickstart`
551    // produces while the rest of the CLI worked (sealed-gate finding
552    // F6). One exporter for every backend also keeps the typed
553    // refusals backend-symmetric (MEM_CONFIG_INCOMPLETE on a missing
554    // version, F1) without a special-cased mapping.
555    let bytes = engine
556        .export_mem_to_bytes(&workspace_mem)
557        .map_err(CliError::from_engine_op)?;
558
559    let output = match args.output {
560        Some(p) => p,
561        None => {
562            // Filesystem-mem config doesn't carry `version` today —
563            // archive identity is `<mem_name>.mem` until the
564            // assemble path threads a version through. Operator can
565            // override with `-o`.
566            PathBuf::from(format!(
567                "{workspace_mem}.{}",
568                memstead_schema::ARCHIVE_EXTENSION
569            ))
570        }
571    };
572
573    std::fs::write(&output, &bytes).map_err(|e| {
574        CliError::new(
575            ExitKind::Generic,
576            crate::INTERNAL_CODE,
577            format!("write {}: {e}", output.display()),
578        )
579    })?;
580    let dropped = if args.self_contained {
581        Some(make_self_contained_on_disk(&output)?.dropped)
582    } else {
583        None
584    };
585    let size_bytes = std::fs::metadata(&output)
586        .map(|m| m.len() as usize)
587        .unwrap_or(bytes.len());
588    // Count only the exported mem's entities — the store also holds
589    // mounted sibling mems (the multi-mount setup), which do not travel
590    // in this archive.
591    let entity_count = engine
592        .store()
593        .all_entities()
594        .filter(|e| !e.stub && e.id.mem() == workspace_mem)
595        .count();
596
597    if ctx.json {
598        let warnings: Vec<_> = dropped
599            .iter()
600            .flatten()
601            .map(|e| {
602                json!({
603                    "code": "CROSS_MEM_EDGE_DROPPED",
604                    "entity": e.entity_path,
605                    "target_id": e.target_id,
606                    "target_mem": e.target_mem,
607                })
608            })
609            .collect();
610        print_json(&json!({
611            "archive_path": output.to_string_lossy(),
612            "name": workspace_mem,
613            "entity_count": entity_count,
614            "size_bytes": size_bytes,
615            "self_contained": args.self_contained,
616            "warnings": warnings,
617        }))?;
618    } else {
619        let mut block = format!(
620            "# Exported `{workspace_mem}`\n\n- Archive: `{}`\n- Entities: {}\n- Size: {} bytes",
621            output.display(),
622            entity_count,
623            size_bytes,
624        );
625        if args.self_contained {
626            block.push_str("\n- Self-contained: yes");
627            let n = dropped.as_ref().map(|d| d.len()).unwrap_or(0);
628            if n > 0 {
629                block.push_str(&format!("\n- Cross-mem edges dropped: {n}"));
630            }
631        }
632        print_markdown(&block);
633    }
634    Ok(())
635}
636
637#[cfg(feature = "mem-repo")]
638fn default_output_path(
639    mem_name: &str,
640    config: &memstead_schema::MemConfig,
641) -> anyhow::Result<PathBuf> {
642    let version = config.version.as_ref().ok_or_else(|| {
643        // F1: typed envelope replaces the pre-fix INTERNAL-collapse
644        // path (config lives at
645        // `__MEMSTEAD:mems/<name>/config.json` for the mem-repo
646        // backend). The recovery hint
647        // names the engine-owned setter that mutates the right
648        // surface for whichever backend serves the mem.
649        CliError::from_engine_op(memstead_base::EngineError::MemConfigIncomplete {
650            mem: mem_name.to_string(),
651            missing_fields: vec!["version".to_string()],
652        })
653    })?;
654    // The mem name is supplied by the caller (engine mem state)
655    // rather than pulled from the now-optional in-config `name` field.
656    let filename = format!(
657        "{mem_name}-{version}.{}",
658        memstead_schema::ARCHIVE_EXTENSION
659    );
660    Ok(PathBuf::from(filename))
661}
662
663/// `--format llms-txt` — the whole mem as one Markdown document an agent can
664/// read in a single pass. Backend-uniform via [`CliEngine::base`] and
665/// observably read-only, like `--format json`.
666///
667/// The document shape is the engine's, shared with the served
668/// `/llms-full.txt` endpoint. This function supplies only what a *deployment*
669/// would otherwise supply and a CLI cannot: the link base. It deliberately
670/// supplies no authority and no wider-project block — a file exported from
671/// someone's own workspace has no deployment vouching for it, and a header
672/// claiming otherwise would put a false provenance line atop the one document
673/// written to be read whole.
674fn run_llms_txt(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
675    let engine_holder = ctx.cli_engine()?;
676    let engine = engine_holder.base();
677    let mem = resolve_single_mem(engine, args.mem_name.as_deref())?;
678
679    let ctx_opts = memstead_base::engine::export_llms_txt::LlmsTxtContext {
680        authority: None,
681        href_prefix: args
682            .base_url
683            .clone()
684            .map(|u| u.trim_end_matches('/').to_string())
685            .unwrap_or_default(),
686        wider_project: Vec::new(),
687    };
688    let doc = engine
689        .render_llms_txt(&mem, &ctx_opts)
690        .map_err(CliError::from_engine_op)?;
691
692    match &args.output {
693        Some(path) => {
694            std::fs::write(path, &doc).map_err(|e| {
695                CliError::new(
696                    ExitKind::Generic,
697                    "IO_ERROR",
698                    format!("write {}: {e}", path.display()),
699                )
700            })?;
701            if ctx.json {
702                print_json(&serde_json::json!({
703                    "mem": mem,
704                    "written": path.display().to_string(),
705                    "bytes": doc.len(),
706                }))?;
707            } else {
708                println!("Wrote {} ({} bytes)", path.display(), doc.len());
709            }
710        }
711        // No `-o` prints the document itself — it is text meant to be read or
712        // piped, so stdout is the natural destination rather than a file the
713        // caller then has to find.
714        None => print!("{doc}"),
715    }
716    Ok(())
717}
718
719/// Resolve the one mem an export targets: an explicit `--mem` wins (read-only
720/// mounts allowed); otherwise the sole writable mem, refusing when there is
721/// none or several rather than picking one.
722fn resolve_single_mem(
723    engine: &memstead_base::Engine,
724    requested: Option<&str>,
725) -> Result<String, CliError> {
726    if let Some(m) = requested {
727        return Ok(m.to_string());
728    }
729    let writables: Vec<String> = engine
730        .writable_mem_names()
731        .iter()
732        .map(|s| s.to_string())
733        .collect();
734    match writables.as_slice() {
735        [one] => Ok(one.clone()),
736        [] => Err(CliError::new(
737            ExitKind::Validation,
738            "INVALID_INPUT",
739            "no writable mem loaded — pass --mem <name>",
740        )),
741        _ => 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    }
750}
751
752/// `--format html` — one self-contained HTML file per mem (the read
753/// surface for non-operators). Backend-uniform via [`CliEngine::base`]
754/// and observably read-only. The export date is stamped once (UTC);
755/// `--today` on `memstead due` has no analogue here because the date
756/// only labels the export, it never filters.
757fn run_html(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
758    let engine_holder = ctx.cli_engine()?;
759    let engine = engine_holder.base();
760    // Resolve the target mem like `--format mem`: explicit name wins
761    // (read-only mounts allowed); otherwise the sole writable mem.
762    let mem = match &args.mem_name {
763        Some(m) => m.clone(),
764        None => {
765            let writables: Vec<String> = engine
766                .writable_mem_names()
767                .iter()
768                .map(|s| s.to_string())
769                .collect();
770            match writables.as_slice() {
771                [one] => one.clone(),
772                [] => {
773                    return Err(CliError::new(
774                        ExitKind::Validation,
775                        "INVALID_INPUT",
776                        "no writable mem loaded — pass --mem <name>",
777                    )
778                    .into());
779                }
780                _ => {
781                    return Err(CliError::new(
782                        ExitKind::Validation,
783                        "INVALID_INPUT",
784                        format!(
785                            "multiple writable mems loaded ({}) — pass --mem <name>",
786                            writables.join(", ")
787                        ),
788                    )
789                    .into());
790                }
791            }
792        }
793    };
794    let now = time::OffsetDateTime::now_utc();
795    let export_date = format!(
796        "{:04}-{:02}-{:02}",
797        now.year(),
798        u8::from(now.month()),
799        now.day()
800    );
801    let html = engine
802        .render_html_export(&mem, &export_date)
803        .map_err(CliError::from_engine_op)?;
804    let out_path = args
805        .output
806        .clone()
807        .unwrap_or_else(|| PathBuf::from(format!("{mem}.html")));
808    std::fs::write(&out_path, &html).map_err(|e| {
809        CliError::new(
810            ExitKind::Generic,
811            "IO_ERROR",
812            format!("write {}: {e}", out_path.display()),
813        )
814    })?;
815    if ctx.json {
816        print_json(&serde_json::json!({
817            "format": "html",
818            "mem": mem,
819            "path": out_path,
820            "bytes": html.len(),
821            "exported": export_date,
822        }))?;
823    } else {
824        print_markdown(&format!(
825            "# 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",
826            out_path.display(),
827            html.len()
828        ));
829    }
830    Ok(())
831}
832
833#[cfg(test)]
834mod tests {
835    use super::*;
836    use clap::Parser;
837
838    /// Mem selection is `--mem`, converged onto the convention every
839    /// other subcommand uses; the former `--mem-name` outlier is gone.
840    #[test]
841    fn export_mem_selection_flag_is_mem_not_mem_name() {
842        let parsed = Args::try_parse_from(["export", "--mem", "specs", "--format", "mem"]).unwrap();
843        assert_eq!(parsed.mem_name.as_deref(), Some("specs"));
844        assert!(
845            Args::try_parse_from(["export", "--mem-name", "specs"]).is_err(),
846            "the retired --mem-name flag must not parse"
847        );
848    }
849}