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