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. A `mem`
37    /// archive's authoring provenance (`.memstead/provenance.json`) has
38    /// every private-pattern span redacted to `[redacted:<class>]` —
39    /// the leak scan's classes, one vocabulary — never stripped; the
40    /// report counts redactions per class. Entity bodies are not
41    /// rewritten.
42    #[arg(long, value_enum, default_value_t = Format::Markdown)]
43    pub format: Format,
44
45    /// Output path for `--format mem` (default `./<name>-<version>.mem`)
46    /// and `--format html` (default `./<mem>.html`). Optional for
47    /// `--format llms-txt`, which prints to stdout when omitted.
48    /// Ignored for `--format markdown`; refused for `--format json`
49    /// (that document goes to stdout).
50    #[arg(long, short = 'o', value_name = "PATH")]
51    pub output: Option<PathBuf>,
52
53    /// Which mem to export (by name). For `--format markdown`, omitting
54    /// this argument runs a workspace-wide export and reports any
55    /// declined mounts under `skipped_mounts`. For `--format mem`,
56    /// required when more than one write mem is loaded; defaults to
57    /// the first writable mem otherwise. For `--format json`, omitting
58    /// it exports every writable mem; naming a read-only mount exports
59    /// that mount (read-mems are excluded from the workspace-wide
60    /// default — they are someone else's published content).
61    #[arg(long = "mem", value_name = "NAME")]
62    pub mem_name: Option<String>,
63
64    /// Export only the chain reachable from this entity (`mem--slug`)
65    /// instead of the whole mem — for `--format json`, `html` and
66    /// `llms-txt`. Requires `--via`. The root itself is always included;
67    /// each rendered entity keeps its metadata, sections, relationships
68    /// and (json) its anchors with live state; stubs in the chain are
69    /// marked; references to entities outside the chain render as
70    /// unresolved markers, never as broken links. Without `--root` the
71    /// export is the whole mem, byte-identical to before.
72    #[arg(long, value_name = "ID")]
73    pub root: Option<String>,
74
75    /// Rel-types the chain follows, comma-separated or repeatable
76    /// (`--via SUPPORTS,DERIVES_FROM`). Validated against the mem's
77    /// schema vocabulary: an unknown name refuses `INVALID_REL_TYPE`
78    /// naming the declared rel-types. Only with `--root`.
79    #[arg(long, value_name = "REL", value_delimiter = ',')]
80    pub via: Vec<String>,
81
82    /// Direction applied at EVERY hop of the chain: `out` follows edges
83    /// pointing away from the root (what the root rests on), `in`
84    /// follows edges pointing at it (what rests on the root), `both`
85    /// the undirected walk. A pure transitive closure in the chosen
86    /// direction, the same contract `memstead search` uses.
87    #[arg(long, value_enum, default_value_t = ChainDirection::Out)]
88    pub direction: ChainDirection,
89
90    /// Maximum hops from the root (default: unbounded). `--depth 1` is
91    /// the root and its direct neighbours along `--via`.
92    #[arg(long, value_name = "N")]
93    pub depth: Option<usize>,
94
95    /// For `--format mem`: make the archive self-contained by dropping
96    /// every `## Relationships` row whose target lives in another mem,
97    /// then re-pack and strictly validate it (the same pass `install`
98    /// runs). Without it, a mem that references its sibling mems exports
99    /// with `DANGLING_CROSS_MEM_EDGE_IN_EXPORT` warnings and `install`
100    /// refuses the archive; with it, every dropped edge is reported as
101    /// `CROSS_MEM_EDGE_DROPPED` instead. Section text, body wiki-links
102    /// included, is never touched: an alias row synthesised from a body
103    /// link loses nothing the body does not still say.
104    #[arg(long)]
105    pub self_contained: bool,
106
107    /// Absolute base URL for entity links in `--format llms-txt` (e.g.
108    /// `https://example.com`). With it, references render as absolute
109    /// links exactly as the served document does; without it they target
110    /// the document-relative `entity/<id>`. There is no third form.
111    /// Ignored by every other format.
112    #[arg(long = "base-url", value_name = "URL")]
113    pub base_url: Option<String>,
114
115    /// Opt extra per-entity content into the `--format json` document
116    /// (comma-separated). Keys: `anchors` — each entity envelope gains
117    /// an `anchors` array with its stored provenance anchors, so the
118    /// file-to-entity map a carving or sync pass starts from is one
119    /// export instead of one `memstead anchors <id>` per entity. An
120    /// unknown key refuses naming the allowed set; refused for every
121    /// other format.
122    #[arg(long, value_delimiter = ',', value_name = "KEY")]
123    pub include: Vec<String>,
124}
125
126/// `--direction` for a chain export — the wire words of the engine's
127/// `TraversalDirection`, one value each.
128#[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq, Eq)]
129pub enum ChainDirection {
130    Out,
131    In,
132    Both,
133}
134
135impl From<ChainDirection> for memstead_base::graph::query::TraversalDirection {
136    fn from(d: ChainDirection) -> Self {
137        match d {
138            ChainDirection::Out => Self::Out,
139            ChainDirection::In => Self::In,
140            ChainDirection::Both => Self::Both,
141        }
142    }
143}
144
145/// The chain scope a caller asked for, or `None` for the whole mem.
146fn chain_scope(args: &Args) -> Option<memstead_base::graph::chain::ChainScope> {
147    args.root
148        .as_deref()
149        .map(|root| memstead_base::graph::chain::ChainScope {
150            root: memstead_base::EntityId::canonical(root),
151            via: args.via.clone(),
152            direction: args.direction.into(),
153            depth: args.depth.unwrap_or(usize::MAX),
154        })
155}
156
157#[derive(ValueEnum, Clone, Copy, Debug)]
158pub enum Format {
159    /// Regenerate markdown files in place.
160    Markdown,
161    /// Write a `.mem` zip archive to `--output`.
162    Mem,
163    /// Print the full entity set as one JSON document on stdout.
164    Json,
165    /// Write one self-contained HTML file — the read surface for
166    /// non-operators: no server, no scripts, zero network requests.
167    Html,
168    /// Write the whole mem as one agent-readable Markdown document —
169    /// the `/llms-full.txt` shape, rendered by the same engine code the
170    /// served endpoint uses, so the two cannot drift.
171    LlmsTxt,
172}
173
174pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
175    // The chain flags travel together, refused typed (not as a clap usage
176    // error) so an agent reads the same envelope every other refusal has.
177    if args.root.is_some() && args.via.is_empty() {
178        return Err(CliError::new(
179            ExitKind::Validation,
180            "INVALID_INPUT",
181            "--root selects a chain and needs --via <REL[,REL]>: the rel-types the chain follows",
182        )
183        .with_details(json!({ "field": "via" }))
184        .into());
185    }
186    if args.root.is_none() && (!args.via.is_empty() || args.depth.is_some()) {
187        return Err(CliError::new(
188            ExitKind::Validation,
189            "INVALID_INPUT",
190            "--via and --depth describe a chain and need --root <ID>",
191        )
192        .with_details(json!({ "field": "root" }))
193        .into());
194    }
195    if args.root.is_some() && !matches!(args.format, Format::Json | Format::Html | Format::LlmsTxt)
196    {
197        return Err(CliError::new(
198            ExitKind::Validation,
199            "INVALID_INPUT",
200            "--root selects a chain within a rendered export (json, html, llms-txt); the \
201             markdown regeneration and the .mem archive always carry the whole mem",
202        )
203        .into());
204    }
205    if !args.include.is_empty() && !matches!(args.format, Format::Json) {
206        return Err(CliError::new(
207            ExitKind::Validation,
208            "INVALID_INPUT",
209            "--include applies only to --format json",
210        )
211        .into());
212    }
213    if matches!(args.format, Format::Json) {
214        return run_json(ctx, args);
215    }
216    if matches!(args.format, Format::Html) {
217        return run_html(ctx, args);
218    }
219    if matches!(args.format, Format::LlmsTxt) {
220        return run_llms_txt(ctx, args);
221    }
222    match ctx.cli_engine()? {
223        #[cfg(feature = "mem-repo")]
224        CliEngine::MemRepo(engine) => match args.format {
225            Format::Markdown => run_markdown(ctx, &engine, args.mem_name.as_deref()),
226            Format::Mem => run_mem(ctx, &engine, args),
227            Format::Json => unreachable!("dispatched to run_json above"),
228            Format::Html => unreachable!("dispatched to run_html above"),
229            Format::LlmsTxt => unreachable!("dispatched to run_llms_txt above"),
230        },
231        CliEngine::Filesystem(engine) => match args.format {
232            // `--format markdown` regenerates files in place. The
233            // filesystem engine's writer would do the same, but
234            // there's no `export_markdown` accessor today; surface
235            // the gap as a clear validation error rather than a
236            // silent no-op.
237            Format::Markdown => Err(CliError::new(
238                ExitKind::Validation,
239                "INVALID_INPUT",
240                "--format markdown is not yet supported on filesystem-mem `memstead export` — entities are already on disk in their canonical form",
241            )
242            .into()),
243            Format::Mem => run_mem_filesystem(ctx, &engine, args),
244            Format::Json => unreachable!("dispatched to run_json above"),
245            Format::Html => unreachable!("dispatched to run_html above"),
246            Format::LlmsTxt => unreachable!("dispatched to run_llms_txt above"),
247        },
248    }
249}
250
251/// Version marker on the `--format json` document, following the
252/// `workspace-dump/v1` convention: consumers assert the marker before
253/// parsing so a future shape change fails loudly instead of silently.
254const JSON_EXPORT_FORMAT: &str = "memstead-export/v1";
255
256/// `--format json` — the bulk read. Backend-uniform (both engine
257/// flavours serve it via [`CliEngine::base`]) and observably read-only:
258/// pure store iteration, no engine mutation path is touched. Each
259/// entity rides as the same structured envelope `memstead entity --json`
260/// emits (plus mem-level grouping), so a consumer parses one entity
261/// shape across both surfaces. Entities are sorted by id within each
262/// mem for deterministic output; stubs are excluded (they are
263/// unresolved references, not content).
264fn run_json(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
265    // `-o` only means something for archive export. Refusing beats
266    // silently ignoring: an operator who passed `-o dump.json` would
267    // otherwise wait on a file that never appears.
268    if args.output.is_some() {
269        return Err(CliError::new(
270            ExitKind::Validation,
271            "INVALID_INPUT",
272            "--output applies only to --format mem — the JSON document goes to stdout; redirect it instead",
273        )
274        .into());
275    }
276
277    // Include-key validation — one key today; an unknown key refuses
278    // naming the allowed set rather than silently exporting less than
279    // the caller asked for.
280    const JSON_INCLUDE_KEYS: &[&str] = &["anchors"];
281    for key in &args.include {
282        if !JSON_INCLUDE_KEYS.contains(&key.as_str()) {
283            return Err(CliError::new(
284                ExitKind::Validation,
285                "INVALID_INPUT",
286                format!(
287                    "unknown --include key {key:?} — allowed: {}",
288                    JSON_INCLUDE_KEYS.join(", ")
289                ),
290            )
291            .into());
292        }
293    }
294    let include_anchors = args.include.iter().any(|k| k == "anchors");
295
296    let cli_engine = ctx.cli_engine()?;
297    let engine = cli_engine.base();
298
299    // A chain is resolved once, against the root's mem (the mem is
300    // implied by the root when `--mem` is omitted).
301    let scope = chain_scope(&args);
302    let chain = match &scope {
303        Some(scope) => {
304            let mem = args
305                .mem_name
306                .clone()
307                .unwrap_or_else(|| scope.root.mem().to_string());
308            Some((
309                mem.clone(),
310                engine
311                    .chain_set(&mem, scope)
312                    .map_err(CliError::from_engine_op)?,
313            ))
314        }
315        None => None,
316    };
317
318    let all_names: Vec<String> = engine.mem_names().into_iter().map(String::from).collect();
319    // Named mem: any loaded mount qualifies, read-only included — an
320    // explicit name is the opt-in. Workspace-wide default: writable
321    // mems only; read-only mounts are someone else's published content.
322    let selected: Vec<String> = match chain.as_ref().map(|(m, _)| m).or(args.mem_name.as_ref()) {
323        Some(name) => {
324            if !all_names.iter().any(|n| n == name) {
325                return Err(CliError::new(
326                    ExitKind::NotFound,
327                    "UNKNOWN_MEM",
328                    format!(
329                        "unknown mem '{name}' — loaded mems: {}",
330                        all_names.join(", ")
331                    ),
332                )
333                .with_details(json!({ "mem": name, "loaded": all_names }))
334                .into());
335            }
336            vec![name.clone()]
337        }
338        None => all_names
339            .iter()
340            .filter(|n| engine.mem_router().is_writable(n))
341            .cloned()
342            .collect(),
343    };
344
345    let mut mems = serde_json::Map::new();
346    for mem_name in &selected {
347        // The authoritative schema pin lives in the mem's own config;
348        // carried once at the group level rather than per entity.
349        let schema_pin = engine
350            .mounts_with_optional_config()
351            .find(|(name, _)| name == mem_name)
352            .and_then(|(_, c)| c)
353            .and_then(|c| c.schema.as_ref())
354            .map(|s| s.to_string());
355
356        let mut entities: Vec<&memstead_base::Entity> = engine
357            .store()
358            .all_entities()
359            .filter(|e| !e.stub && e.mem == *mem_name)
360            .filter(|e| chain.as_ref().is_none_or(|(_, c)| c.contains(&e.id)))
361            .collect();
362        entities.sort_by(|a, b| a.id.as_ref().cmp(b.id.as_ref()));
363
364        let envelopes: Vec<serde_json::Value> = entities
365            .iter()
366            .map(|entity| {
367                let body = memstead_base::render::render_entity_markdown(entity, None);
368                let tokens = memstead_base::chunking::estimate_tokens(&body);
369                let outgoing = engine.store().outgoing(&entity.id);
370                // Export is a canonical-form surface — computed
371                // signals are a serving projection and stay out.
372                let mut envelope = memstead_base::render::build_entity_envelope(
373                    entity,
374                    tokens,
375                    None,
376                    None,
377                    None,
378                    engine.mem_origin_class(entity.id.mem()),
379                    outgoing,
380                    None,
381                    None,
382                    None,
383                );
384                // `--include anchors`: the stored provenance anchors ride
385                // each envelope, so the file-to-entity map a carving pass
386                // starts from is one export instead of one `memstead
387                // anchors <id>` per entity. Canonical stored form, no
388                // live resolution — this stays a pure read.
389                if include_anchors && let Some(obj) = envelope.as_object_mut() {
390                    let anchors = engine.entity_anchors(&entity.id);
391                    obj.insert(
392                        "anchors".to_string(),
393                        serde_json::to_value(&anchors).unwrap_or(serde_json::Value::Null),
394                    );
395                }
396                // A chain export is an auditor's read: every node carries
397                // its anchors WITH live state (artifact, grain, class,
398                // state), so one export answers what each link in the
399                // chain rests on and whether it still holds.
400                if chain.is_some()
401                    && !include_anchors
402                    && let Some(obj) = envelope.as_object_mut()
403                {
404                    let resolved = engine.entity_anchors_resolved(&entity.id);
405                    obj.insert(
406                        "anchors".to_string(),
407                        serde_json::to_value(&resolved).unwrap_or(serde_json::Value::Null),
408                    );
409                }
410                envelope
411            })
412            .collect();
413
414        let mut group = serde_json::Map::new();
415        if let Some(s) = schema_pin {
416            group.insert("schema".to_string(), json!(s));
417        }
418        group.insert(
419            "read_only".to_string(),
420            json!(!engine.mem_router().is_writable(mem_name)),
421        );
422        group.insert("entity_count".to_string(), json!(envelopes.len()));
423        group.insert("entities".to_string(), serde_json::Value::Array(envelopes));
424        // The chain itself: what was asked for, and the induced subgraph
425        // (nodes in this mem, edges with both ends in the chain) — the
426        // same node and edge set the ui-api topology endpoint returns for
427        // the same scope, so the two surfaces can be compared directly.
428        if let Some((_, chain_set)) = &chain {
429            let topology = engine
430                .mem_topology_scoped(mem_name, Some(chain_set))
431                .map_err(CliError::from_engine_op)?;
432            group.insert(
433                "chain".to_string(),
434                json!({
435                    "root": chain_set.scope.root.to_string(),
436                    "via": chain_set.scope.via,
437                    "direction": chain_set.scope.direction.as_wire(),
438                    "depth": (chain_set.scope.depth != usize::MAX).then_some(chain_set.scope.depth),
439                    "nodes": topology.nodes.iter().map(|n| &n.id).collect::<Vec<_>>(),
440                    "edges": topology.edges,
441                    "reached": chain_set.reached.iter().map(|r| json!({
442                        "id": r.id.to_string(),
443                        "via_edge": r.via_edge,
444                        "depth": r.depth,
445                        "direction": r.direction.as_wire(),
446                    })).collect::<Vec<_>>(),
447                }),
448            );
449        }
450        mems.insert(mem_name.clone(), serde_json::Value::Object(group));
451    }
452
453    print_json(&json!({
454        "format": JSON_EXPORT_FORMAT,
455        "mems": mems,
456    }))
457}
458
459#[cfg(feature = "mem-repo")]
460fn run_markdown(
461    ctx: &CliContext,
462    engine: &memstead_base::Engine,
463    mem_filter: Option<&str>,
464) -> anyhow::Result<()> {
465    // The engine returns a
466    // typed `MARKDOWN_EXPORT_UNSUPPORTED_BACKEND` when `--mem`
467    // targets a mem whose backend doesn't support markdown
468    // regeneration. The workspace-wide path returns counts plus a
469    // structured `skipped_mounts` list.
470    let result = engine
471        .export_markdown(mem_filter, None)
472        .map_err(CliError::from_engine_op)?;
473
474    if ctx.json {
475        let mut body = json!({
476            "written": result.written,
477            "unchanged": result.unchanged,
478        });
479        if !result.skipped_mounts.is_empty() {
480            body["skipped_mounts"] = serde_json::to_value(&result.skipped_mounts)
481                .unwrap_or_else(|_| serde_json::Value::Array(Vec::new()));
482        }
483        if !result.refused_entities.is_empty() {
484            body["refused_entities"] = serde_json::to_value(&result.refused_entities)
485                .unwrap_or_else(|_| serde_json::Value::Array(Vec::new()));
486        }
487        print_json(&body)?;
488    } else {
489        let mut block = format!(
490            "# Export — markdown\n\n- Written: {}\n- Unchanged: {}",
491            result.written, result.unchanged,
492        );
493        if !result.skipped_mounts.is_empty() {
494            block.push_str("\n\n## Skipped mounts\n");
495            for m in &result.skipped_mounts {
496                block.push_str(&format!(
497                    "\n- `{}` — backend `{}` ({}); use `--format mem` for archive export",
498                    m.mem, m.active_backend, m.reason,
499                ));
500            }
501        }
502        // Never silent: an entity the export declined is one the operator has
503        // to repair through the engine, and an export that reported only
504        // counts would read as complete over content it did not write.
505        if !result.refused_entities.is_empty() {
506            block.push_str("\n\n## Refused entities\n");
507            for r in &result.refused_entities {
508                block.push_str(&format!("\n- `{}` [{}] — {}", r.id, r.reason, r.detail));
509            }
510        }
511        print_markdown(&block);
512    }
513    Ok(())
514}
515
516#[cfg(feature = "mem-repo")]
517fn run_mem(ctx: &CliContext, engine: &memstead_base::Engine, args: Args) -> anyhow::Result<()> {
518    let mem_name = resolve_mem_name(engine, args.mem_name)?;
519    // Deliberately the config-keyed query: a mem-archive export cannot be
520    // built without the config it packages, so "no config" is a genuine
521    // refusal here rather than a mount to enumerate (04/05, criterion 8 —
522    // the criterion is that no consumer SILENTLY skips, and this one refuses
523    // by name).
524    let config = engine
525        .mem_configs_named()
526        .find(|(name, _)| *name == mem_name)
527        .map(|(_, c)| c)
528        .ok_or_else(|| {
529            CliError::new(
530                ExitKind::NotFound,
531                "UNKNOWN_MEM",
532                format!("mem config not found for '{mem_name}'"),
533            )
534        })?;
535
536    let output = match args.output {
537        Some(p) => p,
538        None => default_output_path(&mem_name, config)?,
539    };
540
541    let mut result = engine
542        .export_mem(&mem_name, &output)
543        .map_err(CliError::from_engine_op)?;
544
545    // `--self-contained`: drop the cross-mem rows the archive cannot
546    // resolve, re-pack, strictly validate, and write the result over the
547    // just-written file. The dropped edges replace the dangling warnings
548    // in the report: they are the same edges, now gone instead of
549    // refused later.
550    let dropped = if args.self_contained {
551        let self_contained = make_self_contained_on_disk(&output)?;
552        result.size_bytes = self_contained.bytes.len() as u64;
553        result.dangling_cross_mem_edges.clear();
554        Some(self_contained.dropped)
555    } else {
556        None
557    };
558
559    // Surface each cross-mem edge
560    // whose target won't travel inside the single-mem archive — these
561    // are exactly what `install` will refuse, so showing them at export
562    // time lets the operator act before sharing.
563    let dangling = &result.dangling_cross_mem_edges;
564
565    if ctx.json {
566        let mut warnings: Vec<_> = dangling
567            .iter()
568            .map(|e| {
569                json!({
570                    "code": "DANGLING_CROSS_MEM_EDGE_IN_EXPORT",
571                    "entity": e.entity_path,
572                    "target_id": e.target_id,
573                    "target_mem": e.target_mem,
574                })
575            })
576            .collect();
577        if let Some(dropped) = &dropped {
578            warnings.extend(dropped.iter().map(|e| {
579                json!({
580                    "code": "CROSS_MEM_EDGE_DROPPED",
581                    "entity": e.entity_path,
582                    "target_id": e.target_id,
583                    "target_mem": e.target_mem,
584                })
585            }));
586        }
587        warnings.extend(result.unterminated_fence_entities.iter().map(|id| {
588            json!({
589                "code": "UNTERMINATED_FENCE_IN_EXPORT",
590                "entity": id,
591            })
592        }));
593        print_json(&json!({
594            "archive_path": result.archive_path,
595            "name": result.name,
596            "version": result.version,
597            "entity_count": result.entity_count,
598            "size_bytes": result.size_bytes,
599            "self_contained": args.self_contained,
600            "redactions": result.redactions,
601            "warnings": warnings,
602        }))?;
603    } else {
604        let mut block = format!(
605            "# Exported `{}` v{}\n\n- Archive: `{}`\n- Entities: {}\n- Size: {} bytes",
606            result.name,
607            result.version,
608            result.archive_path,
609            result.entity_count,
610            result.size_bytes,
611        );
612        if args.self_contained {
613            block.push_str("\n- Self-contained: yes");
614        }
615        if !result.redactions.is_empty() {
616            let listed: Vec<String> = result
617                .redactions
618                .iter()
619                .map(|r| format!("{} {}", r.class, r.count))
620                .collect();
621            block.push_str(&format!(
622                "\n- Redacted in provenance: {} (each span reads `[redacted:<class>]`)",
623                listed.join(", ")
624            ));
625        }
626        // `install` will refuse the archive for each of these, so the operator
627        // learns it here rather than after sharing.
628        if !result.unterminated_fence_entities.is_empty() {
629            block.push_str(
630                "\n\n## Entities `install` will refuse\n\nEach ends a section inside an \
631                 unterminated code fence, which absorbed the sections after it. Repair through \
632                 the engine (replace the absorbing section) and re-export.\n",
633            );
634            for id in &result.unterminated_fence_entities {
635                block.push_str(&format!("\n- `{id}` [UNTERMINATED_FENCE_IN_EXPORT]"));
636            }
637        }
638        if !dangling.is_empty() {
639            block.push_str("\n\n## Warnings\n");
640            for e in dangling {
641                block.push_str(&format!(
642                    "\n- **DANGLING_CROSS_MEM_EDGE_IN_EXPORT**: `{}` → `{}` (mem `{}`) — \
643                     target lives outside this archive; `memstead install` will reject it unless \
644                     mem `{}` is also present. Re-export with `--self-contained` to drop such \
645                     rows (each reported; body wiki-link prose survives).",
646                    e.entity_path, e.target_id, e.target_mem, e.target_mem,
647                ));
648            }
649        }
650        if let Some(dropped) = &dropped
651            && !dropped.is_empty()
652        {
653            block.push_str("\n\n## Dropped cross-mem edges\n");
654            for e in dropped {
655                block.push_str(&format!(
656                    "\n- **CROSS_MEM_EDGE_DROPPED**: `{}` → `{}` (mem `{}`): the relationship \
657                     row does not travel; a body wiki-link to the same target still does.",
658                    e.entity_path, e.target_id, e.target_mem,
659                ));
660            }
661        }
662        print_markdown(&block);
663    }
664    Ok(())
665}
666
667/// Apply [`memstead_base::validator::make_archive_self_contained`] to
668/// the archive at `path`, writing the self-contained bytes back in place.
669fn make_self_contained_on_disk(
670    path: &std::path::Path,
671) -> anyhow::Result<memstead_base::validator::SelfContainedArchive> {
672    let bytes = std::fs::read(path).map_err(|e| {
673        CliError::new(
674            ExitKind::Generic,
675            crate::INTERNAL_CODE,
676            format!("read {}: {e}", path.display()),
677        )
678    })?;
679    let out = memstead_base::validator::make_archive_self_contained(&bytes).map_err(|e| {
680        CliError::new(
681            ExitKind::Generic,
682            "ARCHIVE_VALIDATION_FAILED",
683            format!("self-contained re-pack of {}: {e}", path.display()),
684        )
685    })?;
686    std::fs::write(path, &out.bytes).map_err(|e| {
687        CliError::new(
688            ExitKind::Generic,
689            crate::INTERNAL_CODE,
690            format!("write {}: {e}", path.display()),
691        )
692    })?;
693    Ok(out)
694}
695
696#[cfg(feature = "mem-repo")]
697fn resolve_mem_name(
698    engine: &memstead_base::Engine,
699    explicit: Option<String>,
700) -> anyhow::Result<String> {
701    if let Some(name) = explicit {
702        return Ok(name);
703    }
704    // Every mount (04/05, criterion 8): a broken mem is still a writable mem
705    // for the purpose of "is the target unambiguous", and omitting it turns an
706    // ambiguous workspace into a silently-resolved one.
707    let writable: Vec<String> = engine
708        .mounts_with_optional_config()
709        .filter(|(name, _)| engine.mem_router().is_writable(name))
710        .map(|(name, _)| name.to_string())
711        .collect();
712
713    match writable.len() {
714        0 => Err(CliError::new(
715            ExitKind::Generic,
716            "NO_WRITABLE_MEM",
717            "no writable mem loaded — nothing to export",
718        )
719        .into()),
720        1 => Ok(writable.into_iter().next().unwrap()),
721        _ => Err(CliError::new(
722            ExitKind::Validation,
723            "AMBIGUOUS_MEM",
724            format!(
725                "multiple writable mems loaded ({}); pass --mem <name>",
726                writable.join(", ")
727            ),
728        )
729        .with_details(json!({ "mems": writable }))
730        .into()),
731    }
732}
733
734/// Filesystem-mem `memstead export --format mem` builds the `.mem`
735/// archive bytes via [`memstead_base::Engine::export_mem_to_bytes`]
736/// (the same primitive the mem-repo path and `memstead publish --mem`
737/// use) and writes them to `--output` (defaulting to `<name>.mem` in
738/// cwd). `--mem` is accepted for shape parity but only the workspace's
739/// pinned mem matches.
740fn run_mem_filesystem(
741    ctx: &CliContext,
742    engine: &memstead_base::Engine,
743    args: Args,
744) -> anyhow::Result<()> {
745    let workspace_mem = engine
746        .mem_names()
747        .into_iter()
748        .next()
749        .map(String::from)
750        .unwrap_or_default();
751    if let Some(name) = args.mem_name.as_deref()
752        && name != workspace_mem
753    {
754        return Err(CliError::new(
755                ExitKind::NotFound,
756                "UNKNOWN_MEM",
757                format!(
758                    "filesystem-mem is single-mem: workspace mem is `{workspace_mem}`, --mem `{name}` does not match"
759                ),
760            )
761            .into());
762    }
763
764    // Export through the ENGINE, which reads whatever layout it
765    // booted: the mount roster locates the mem's folder and its
766    // `.memstead/config.json` inside it. The legacy assemble path
767    // resolved the config against the WORKSPACE root instead — in the
768    // legacy single-mem layout the two coincide, but in the current
769    // (`workspace.toml` + `state/mounts.json`) layout they do not, so
770    // `export --format mem` failed on every workspace `quickstart`
771    // produces while the rest of the CLI worked (sealed-gate finding
772    // F6). One exporter for every backend also keeps the typed
773    // refusals backend-symmetric (MEM_CONFIG_INCOMPLETE on a missing
774    // version, F1) without a special-cased mapping.
775    let report = engine
776        .export_mem_bytes_report(&workspace_mem)
777        .map_err(CliError::from_engine_op)?;
778    let bytes = report.bytes;
779
780    let output = match args.output {
781        Some(p) => p,
782        None => {
783            // Filesystem-mem config doesn't carry `version` today —
784            // archive identity is `<mem_name>.mem` until the
785            // assemble path threads a version through. Operator can
786            // override with `-o`.
787            PathBuf::from(format!(
788                "{workspace_mem}.{}",
789                memstead_schema::ARCHIVE_EXTENSION
790            ))
791        }
792    };
793
794    std::fs::write(&output, &bytes).map_err(|e| {
795        CliError::new(
796            ExitKind::Generic,
797            crate::INTERNAL_CODE,
798            format!("write {}: {e}", output.display()),
799        )
800    })?;
801    let dropped = if args.self_contained {
802        Some(make_self_contained_on_disk(&output)?.dropped)
803    } else {
804        None
805    };
806    let size_bytes = std::fs::metadata(&output)
807        .map(|m| m.len() as usize)
808        .unwrap_or(bytes.len());
809    // Count only the exported mem's entities — the store also holds
810    // mounted sibling mems (the multi-mount setup), which do not travel
811    // in this archive.
812    let entity_count = engine
813        .store()
814        .all_entities()
815        .filter(|e| !e.stub && e.id.mem() == workspace_mem)
816        .count();
817
818    if ctx.json {
819        let warnings: Vec<_> = dropped
820            .iter()
821            .flatten()
822            .map(|e| {
823                json!({
824                    "code": "CROSS_MEM_EDGE_DROPPED",
825                    "entity": e.entity_path,
826                    "target_id": e.target_id,
827                    "target_mem": e.target_mem,
828                })
829            })
830            .collect();
831        print_json(&json!({
832            "archive_path": output.to_string_lossy(),
833            "name": workspace_mem,
834            "entity_count": entity_count,
835            "size_bytes": size_bytes,
836            "self_contained": args.self_contained,
837            "redactions": report.redactions,
838            "warnings": warnings,
839        }))?;
840    } else {
841        let mut block = format!(
842            "# Exported `{workspace_mem}`\n\n- Archive: `{}`\n- Entities: {}\n- Size: {} bytes",
843            output.display(),
844            entity_count,
845            size_bytes,
846        );
847        if !report.redactions.is_empty() {
848            let listed: Vec<String> = report
849                .redactions
850                .iter()
851                .map(|r| format!("{} {}", r.class, r.count))
852                .collect();
853            block.push_str(&format!(
854                "\n- Redacted in provenance: {} (each span reads `[redacted:<class>]`)",
855                listed.join(", ")
856            ));
857        }
858        if args.self_contained {
859            block.push_str("\n- Self-contained: yes");
860            let n = dropped.as_ref().map(|d| d.len()).unwrap_or(0);
861            if n > 0 {
862                block.push_str(&format!("\n- Cross-mem edges dropped: {n}"));
863            }
864        }
865        print_markdown(&block);
866    }
867    Ok(())
868}
869
870#[cfg(feature = "mem-repo")]
871fn default_output_path(
872    mem_name: &str,
873    config: &memstead_schema::MemConfig,
874) -> anyhow::Result<PathBuf> {
875    let version = config.version.as_ref().ok_or_else(|| {
876        // F1: typed envelope replaces the pre-fix INTERNAL-collapse
877        // path (config lives at
878        // `__MEMSTEAD:mems/<name>/config.json` for the mem-repo
879        // backend). The recovery hint
880        // names the engine-owned setter that mutates the right
881        // surface for whichever backend serves the mem.
882        CliError::from_engine_op(memstead_base::EngineError::MemConfigIncomplete {
883            mem: mem_name.to_string(),
884            missing_fields: vec!["version".to_string()],
885        })
886    })?;
887    // The mem name is supplied by the caller (engine mem state)
888    // rather than pulled from the now-optional in-config `name` field.
889    let filename = format!(
890        "{mem_name}-{version}.{}",
891        memstead_schema::ARCHIVE_EXTENSION
892    );
893    Ok(PathBuf::from(filename))
894}
895
896/// `--format llms-txt` — the whole mem as one Markdown document an agent can
897/// read in a single pass. Backend-uniform via [`CliEngine::base`] and
898/// observably read-only, like `--format json`.
899///
900/// The document shape is the engine's, shared with the served
901/// `/llms-full.txt` endpoint. This function supplies only what a *deployment*
902/// would otherwise supply and a CLI cannot: the link base. It deliberately
903/// supplies no authority and no wider-project block — a file exported from
904/// someone's own workspace has no deployment vouching for it, and a header
905/// claiming otherwise would put a false provenance line atop the one document
906/// written to be read whole.
907fn run_llms_txt(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
908    let engine_holder = ctx.cli_engine()?;
909    let engine = engine_holder.base();
910    // A chain's root implies the mem when `--mem` is omitted.
911    let implied_mem = args
912        .root
913        .as_deref()
914        .map(|r| memstead_base::EntityId::canonical(r).mem().to_string());
915    let mem = resolve_single_mem(engine, args.mem_name.as_deref().or(implied_mem.as_deref()))?;
916
917    let ctx_opts = memstead_base::engine::export_llms_txt::LlmsTxtContext {
918        authority: None,
919        href_prefix: args
920            .base_url
921            .clone()
922            .map(|u| u.trim_end_matches('/').to_string())
923            .unwrap_or_default(),
924        wider_project: Vec::new(),
925    };
926    let chain = match chain_scope(&args) {
927        Some(scope) => Some(
928            engine
929                .chain_set(&mem, &scope)
930                .map_err(CliError::from_engine_op)?,
931        ),
932        None => None,
933    };
934    let doc = engine
935        .render_llms_txt_scoped(&mem, &ctx_opts, chain.as_ref())
936        .map_err(CliError::from_engine_op)?;
937
938    match &args.output {
939        Some(path) => {
940            std::fs::write(path, &doc).map_err(|e| {
941                CliError::new(
942                    ExitKind::Generic,
943                    "IO_ERROR",
944                    format!("write {}: {e}", path.display()),
945                )
946            })?;
947            if ctx.json {
948                print_json(&serde_json::json!({
949                    "mem": mem,
950                    "written": path.display().to_string(),
951                    "bytes": doc.len(),
952                }))?;
953            } else {
954                println!("Wrote {} ({} bytes)", path.display(), doc.len());
955            }
956        }
957        // No `-o` prints the document itself — it is text meant to be read or
958        // piped, so stdout is the natural destination rather than a file the
959        // caller then has to find.
960        None => print!("{doc}"),
961    }
962    Ok(())
963}
964
965/// Resolve the one mem an export targets: an explicit `--mem` wins (read-only
966/// mounts allowed); otherwise the sole writable mem, refusing when there is
967/// none or several rather than picking one.
968fn resolve_single_mem(
969    engine: &memstead_base::Engine,
970    requested: Option<&str>,
971) -> Result<String, CliError> {
972    if let Some(m) = requested {
973        return Ok(m.to_string());
974    }
975    let writables: Vec<String> = engine
976        .writable_mem_names()
977        .iter()
978        .map(|s| s.to_string())
979        .collect();
980    match writables.as_slice() {
981        [one] => Ok(one.clone()),
982        [] => Err(CliError::new(
983            ExitKind::Validation,
984            "INVALID_INPUT",
985            "no writable mem loaded — pass --mem <name>",
986        )),
987        _ => Err(CliError::new(
988            ExitKind::Validation,
989            "INVALID_INPUT",
990            format!(
991                "multiple writable mems loaded ({}) — pass --mem <name>",
992                writables.join(", ")
993            ),
994        )),
995    }
996}
997
998/// `--format html` — one self-contained HTML file per mem (the read
999/// surface for non-operators). Backend-uniform via [`CliEngine::base`]
1000/// and observably read-only. The export date is stamped once (UTC);
1001/// `--today` on `memstead due` has no analogue here because the date
1002/// only labels the export, it never filters.
1003fn run_html(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
1004    let engine_holder = ctx.cli_engine()?;
1005    let engine = engine_holder.base();
1006    // Resolve the target mem like `--format mem`: explicit name wins
1007    // (read-only mounts allowed); a chain's root implies its mem;
1008    // otherwise the sole writable mem.
1009    let implied_mem = args
1010        .root
1011        .as_deref()
1012        .map(|r| memstead_base::EntityId::canonical(r).mem().to_string());
1013    let mem = match args.mem_name.as_ref().or(implied_mem.as_ref()) {
1014        Some(m) => m.clone(),
1015        None => {
1016            let writables: Vec<String> = engine
1017                .writable_mem_names()
1018                .iter()
1019                .map(|s| s.to_string())
1020                .collect();
1021            match writables.as_slice() {
1022                [one] => one.clone(),
1023                [] => {
1024                    return Err(CliError::new(
1025                        ExitKind::Validation,
1026                        "INVALID_INPUT",
1027                        "no writable mem loaded — pass --mem <name>",
1028                    )
1029                    .into());
1030                }
1031                _ => {
1032                    return Err(CliError::new(
1033                        ExitKind::Validation,
1034                        "INVALID_INPUT",
1035                        format!(
1036                            "multiple writable mems loaded ({}) — pass --mem <name>",
1037                            writables.join(", ")
1038                        ),
1039                    )
1040                    .into());
1041                }
1042            }
1043        }
1044    };
1045    let now = time::OffsetDateTime::now_utc();
1046    let export_date = format!(
1047        "{:04}-{:02}-{:02}",
1048        now.year(),
1049        u8::from(now.month()),
1050        now.day()
1051    );
1052    let chain = match chain_scope(&args) {
1053        Some(scope) => Some(
1054            engine
1055                .chain_set(&mem, &scope)
1056                .map_err(CliError::from_engine_op)?,
1057        ),
1058        None => None,
1059    };
1060    let html = engine
1061        .render_html_export_scoped(&mem, &export_date, chain.as_ref())
1062        .map_err(CliError::from_engine_op)?;
1063    let out_path = args
1064        .output
1065        .clone()
1066        .unwrap_or_else(|| PathBuf::from(format!("{mem}.html")));
1067    std::fs::write(&out_path, &html).map_err(|e| {
1068        CliError::new(
1069            ExitKind::Generic,
1070            "IO_ERROR",
1071            format!("write {}: {e}", out_path.display()),
1072        )
1073    })?;
1074    if ctx.json {
1075        print_json(&serde_json::json!({
1076            "format": "html",
1077            "mem": mem,
1078            "path": out_path,
1079            "bytes": html.len(),
1080            "exported": export_date,
1081        }))?;
1082    } else {
1083        print_markdown(&format!(
1084            "# 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",
1085            out_path.display(),
1086            html.len()
1087        ));
1088    }
1089    Ok(())
1090}
1091
1092#[cfg(test)]
1093mod tests {
1094    use super::*;
1095    use clap::Parser;
1096
1097    /// Mem selection is `--mem`, converged onto the convention every
1098    /// other subcommand uses; the former `--mem-name` outlier is gone.
1099    #[test]
1100    fn export_mem_selection_flag_is_mem_not_mem_name() {
1101        let parsed = Args::try_parse_from(["export", "--mem", "specs", "--format", "mem"]).unwrap();
1102        assert_eq!(parsed.mem_name.as_deref(), Some("specs"));
1103        assert!(
1104            Args::try_parse_from(["export", "--mem-name", "specs"]).is_err(),
1105            "the retired --mem-name flag must not parse"
1106        );
1107    }
1108}