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