Skip to main content

memstead_cli/commands/
export.rs

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