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).
33    #[arg(long, value_enum, default_value_t = Format::Markdown)]
34    pub format: Format,
35
36    /// Output path for `--format mem` (default `./<name>-<version>.mem`)
37    /// and `--format html` (default `./<mem>.html`). Ignored for
38    /// `--format markdown`; refused for `--format json` (that document
39    /// goes to stdout).
40    #[arg(long, short = 'o', value_name = "PATH")]
41    pub output: Option<PathBuf>,
42
43    /// Which mem to export (by name). For `--format markdown`, omitting
44    /// this argument runs a workspace-wide export and reports any
45    /// declined mounts under `skipped_mounts`. For `--format mem`,
46    /// required when more than one write mem is loaded; defaults to
47    /// the first writable mem otherwise. For `--format json`, omitting
48    /// it exports every writable mem; naming a read-only mount exports
49    /// that mount (read-mems are excluded from the workspace-wide
50    /// default — they are someone else's published content).
51    #[arg(long = "mem", value_name = "NAME")]
52    pub mem_name: Option<String>,
53}
54
55#[derive(ValueEnum, Clone, Copy, Debug)]
56pub enum Format {
57    /// Regenerate markdown files in place.
58    Markdown,
59    /// Write a `.mem` zip archive to `--output`.
60    Mem,
61    /// Print the full entity set as one JSON document on stdout.
62    Json,
63    /// Write one self-contained HTML file — the read surface for
64    /// non-operators: no server, no scripts, zero network requests.
65    Html,
66}
67
68pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
69    if matches!(args.format, Format::Json) {
70        return run_json(ctx, args);
71    }
72    if matches!(args.format, Format::Html) {
73        return run_html(ctx, args);
74    }
75    match ctx.cli_engine()? {
76        #[cfg(feature = "mem-repo")]
77        CliEngine::MemRepo(engine) => match args.format {
78            Format::Markdown => run_markdown(ctx, &engine, args.mem_name.as_deref()),
79            Format::Mem => run_mem(ctx, &engine, args),
80            Format::Json => unreachable!("dispatched to run_json above"),
81            Format::Html => unreachable!("dispatched to run_html above"),
82        },
83        CliEngine::Filesystem(engine) => match args.format {
84            // `--format markdown` regenerates files in place. The
85            // filesystem engine's writer would do the same, but
86            // there's no `export_markdown` accessor today; surface
87            // the gap as a clear validation error rather than a
88            // silent no-op.
89            Format::Markdown => Err(CliError::new(
90                ExitKind::Validation,
91                "INVALID_INPUT",
92                "--format markdown is not yet supported on filesystem-mem `memstead export` — entities are already on disk in their canonical form",
93            )
94            .into()),
95            Format::Mem => run_mem_filesystem(ctx, &engine, args),
96            Format::Json => unreachable!("dispatched to run_json above"),
97            Format::Html => unreachable!("dispatched to run_html above"),
98        },
99    }
100}
101
102/// Version marker on the `--format json` document, following the
103/// `workspace-dump/v0` convention: consumers assert the marker before
104/// parsing so a future shape change fails loudly instead of silently.
105const JSON_EXPORT_FORMAT: &str = "memstead-export/v1";
106
107/// `--format json` — the bulk read. Backend-uniform (both engine
108/// flavours serve it via [`CliEngine::base`]) and observably read-only:
109/// pure store iteration, no engine mutation path is touched. Each
110/// entity rides as the same structured envelope `memstead entity --json`
111/// emits (plus mem-level grouping), so a consumer parses one entity
112/// shape across both surfaces. Entities are sorted by id within each
113/// mem for deterministic output; stubs are excluded (they are
114/// unresolved references, not content).
115fn run_json(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
116    // `-o` only means something for archive export. Refusing beats
117    // silently ignoring: an operator who passed `-o dump.json` would
118    // otherwise wait on a file that never appears.
119    if args.output.is_some() {
120        return Err(CliError::new(
121            ExitKind::Validation,
122            "INVALID_INPUT",
123            "--output applies only to --format mem — the JSON document goes to stdout; redirect it instead",
124        )
125        .into());
126    }
127
128    let cli_engine = ctx.cli_engine()?;
129    let engine = cli_engine.base();
130
131    let all_names: Vec<String> = engine.mem_names().into_iter().map(String::from).collect();
132    // Named mem: any loaded mount qualifies, read-only included — an
133    // explicit name is the opt-in. Workspace-wide default: writable
134    // mems only; read-only mounts are someone else's published content.
135    let selected: Vec<String> = match &args.mem_name {
136        Some(name) => {
137            if !all_names.iter().any(|n| n == name) {
138                return Err(CliError::new(
139                    ExitKind::NotFound,
140                    "UNKNOWN_MEM",
141                    format!(
142                        "unknown mem '{name}' — loaded mems: {}",
143                        all_names.join(", ")
144                    ),
145                )
146                .with_details(json!({ "mem": name, "loaded": all_names }))
147                .into());
148            }
149            vec![name.clone()]
150        }
151        None => all_names
152            .iter()
153            .filter(|n| engine.mem_router().is_writable(n))
154            .cloned()
155            .collect(),
156    };
157
158    let mut mems = serde_json::Map::new();
159    for mem_name in &selected {
160        // The authoritative schema pin lives in the mem's own config;
161        // carried once at the group level rather than per entity.
162        let schema_pin = engine
163            .mem_configs_named()
164            .find(|(name, _)| name == mem_name)
165            .and_then(|(_, c)| c.schema.as_ref())
166            .map(|s| s.to_string());
167
168        let mut entities: Vec<&memstead_base::Entity> = engine
169            .store()
170            .all_entities()
171            .filter(|e| !e.stub && e.mem == *mem_name)
172            .collect();
173        entities.sort_by(|a, b| a.id.as_ref().cmp(b.id.as_ref()));
174
175        let envelopes: Vec<serde_json::Value> = entities
176            .iter()
177            .map(|entity| {
178                let body = memstead_base::render::render_entity_markdown(entity, None);
179                let tokens = memstead_base::chunking::estimate_tokens(&body);
180                let outgoing = engine.store().outgoing(&entity.id);
181                memstead_base::render::build_entity_envelope(
182                    entity, tokens, None, None, None, outgoing,
183                )
184            })
185            .collect();
186
187        let mut group = serde_json::Map::new();
188        if let Some(s) = schema_pin {
189            group.insert("schema".to_string(), json!(s));
190        }
191        group.insert(
192            "read_only".to_string(),
193            json!(!engine.mem_router().is_writable(mem_name)),
194        );
195        group.insert("entity_count".to_string(), json!(envelopes.len()));
196        group.insert("entities".to_string(), serde_json::Value::Array(envelopes));
197        mems.insert(mem_name.clone(), serde_json::Value::Object(group));
198    }
199
200    print_json(&json!({
201        "format": JSON_EXPORT_FORMAT,
202        "mems": mems,
203    }))
204}
205
206#[cfg(feature = "mem-repo")]
207fn run_markdown(
208    ctx: &CliContext,
209    engine: &memstead_base::Engine,
210    mem_filter: Option<&str>,
211) -> anyhow::Result<()> {
212    // The engine returns a
213    // typed `MARKDOWN_EXPORT_UNSUPPORTED_BACKEND` when `--mem`
214    // targets a mem whose backend doesn't support markdown
215    // regeneration. The workspace-wide path returns counts plus a
216    // structured `skipped_mounts` list.
217    let result = engine
218        .export_markdown(mem_filter, None)
219        .map_err(CliError::from_engine_op)?;
220
221    if ctx.json {
222        let mut body = json!({
223            "written": result.written,
224            "unchanged": result.unchanged,
225        });
226        if !result.skipped_mounts.is_empty() {
227            body["skipped_mounts"] = serde_json::to_value(&result.skipped_mounts)
228                .unwrap_or_else(|_| serde_json::Value::Array(Vec::new()));
229        }
230        print_json(&body)?;
231    } else {
232        let mut block = format!(
233            "# Export — markdown\n\n- Written: {}\n- Unchanged: {}",
234            result.written, result.unchanged,
235        );
236        if !result.skipped_mounts.is_empty() {
237            block.push_str("\n\n## Skipped mounts\n");
238            for m in &result.skipped_mounts {
239                block.push_str(&format!(
240                    "\n- `{}` — backend `{}` ({}); use `--format mem` for archive export",
241                    m.mem, m.active_backend, m.reason,
242                ));
243            }
244        }
245        print_markdown(&block);
246    }
247    Ok(())
248}
249
250#[cfg(feature = "mem-repo")]
251fn run_mem(ctx: &CliContext, engine: &memstead_base::Engine, args: Args) -> anyhow::Result<()> {
252    let mem_name = resolve_mem_name(engine, args.mem_name)?;
253    let config = engine
254        .mem_configs_named()
255        .find(|(name, _)| *name == mem_name)
256        .map(|(_, c)| c)
257        .ok_or_else(|| {
258            CliError::new(
259                ExitKind::NotFound,
260                "UNKNOWN_MEM",
261                format!("mem config not found for '{mem_name}'"),
262            )
263        })?;
264
265    let output = match args.output {
266        Some(p) => p,
267        None => default_output_path(&mem_name, config)?,
268    };
269
270    let result = engine
271        .export_mem(&mem_name, &output)
272        .map_err(CliError::from_engine_op)?;
273
274    // Surface each cross-mem edge
275    // whose target won't travel inside the single-mem archive — these
276    // are exactly what `install` will refuse, so showing them at export
277    // time lets the operator act before sharing.
278    let dangling = &result.dangling_cross_mem_edges;
279
280    if ctx.json {
281        let warnings: Vec<_> = dangling
282            .iter()
283            .map(|e| {
284                json!({
285                    "code": "DANGLING_CROSS_MEM_EDGE_IN_EXPORT",
286                    "entity": e.entity_path,
287                    "target_id": e.target_id,
288                    "target_mem": e.target_mem,
289                })
290            })
291            .collect();
292        print_json(&json!({
293            "archive_path": result.archive_path,
294            "name": result.name,
295            "version": result.version,
296            "entity_count": result.entity_count,
297            "size_bytes": result.size_bytes,
298            "warnings": warnings,
299        }))?;
300    } else {
301        let mut block = format!(
302            "# Exported `{}` v{}\n\n- Archive: `{}`\n- Entities: {}\n- Size: {} bytes",
303            result.name,
304            result.version,
305            result.archive_path,
306            result.entity_count,
307            result.size_bytes,
308        );
309        if !dangling.is_empty() {
310            block.push_str("\n\n## Warnings\n");
311            for e in dangling {
312                block.push_str(&format!(
313                    "\n- **DANGLING_CROSS_MEM_EDGE_IN_EXPORT**: `{}` → `{}` (mem `{}`) — \
314                     target lives outside this archive; `memstead install` will reject it unless \
315                     mem `{}` is also present.",
316                    e.entity_path, e.target_id, e.target_mem, e.target_mem,
317                ));
318            }
319        }
320        print_markdown(&block);
321    }
322    Ok(())
323}
324
325#[cfg(feature = "mem-repo")]
326fn resolve_mem_name(
327    engine: &memstead_base::Engine,
328    explicit: Option<String>,
329) -> anyhow::Result<String> {
330    if let Some(name) = explicit {
331        return Ok(name);
332    }
333    let writable: Vec<String> = engine
334        .mem_configs_named()
335        .filter(|(name, _)| engine.mem_router().is_writable(name))
336        .map(|(name, _)| name.to_string())
337        .collect();
338
339    match writable.len() {
340        0 => Err(CliError::new(
341            ExitKind::Generic,
342            "NO_WRITABLE_MEM",
343            "no writable mem loaded — nothing to export",
344        )
345        .into()),
346        1 => Ok(writable.into_iter().next().unwrap()),
347        _ => Err(CliError::new(
348            ExitKind::Validation,
349            "AMBIGUOUS_MEM",
350            format!(
351                "multiple writable mems loaded ({}); pass --mem <name>",
352                writable.join(", ")
353            ),
354        )
355        .with_details(json!({ "mems": writable }))
356        .into()),
357    }
358}
359
360/// Filesystem-mem `memstead export --format mem` builds the `.mem`
361/// archive bytes via [`memstead_base::filesystem::publish::assemble_archive`]
362/// (the same path `memstead publish` uses on a filesystem-mem workspace)
363/// and writes them to `--output` (defaulting to `<name>-<version>.mem`
364/// in cwd). `--mem` is accepted for shape parity but only the
365/// workspace's pinned mem matches.
366fn run_mem_filesystem(
367    ctx: &CliContext,
368    engine: &memstead_base::Engine,
369    args: Args,
370) -> anyhow::Result<()> {
371    let workspace_mem = engine
372        .mem_names()
373        .into_iter()
374        .next()
375        .map(String::from)
376        .unwrap_or_default();
377    if let Some(name) = args.mem_name.as_deref()
378        && name != workspace_mem
379    {
380        return Err(CliError::new(
381                ExitKind::NotFound,
382                "UNKNOWN_MEM",
383                format!(
384                    "filesystem-mem is single-mem: workspace mem is `{workspace_mem}`, --mem `{name}` does not match"
385                ),
386            )
387            .into());
388    }
389
390    // assemble_archive is engine-agnostic now — pass the discovered
391    // workspace root directly.
392    let workspace_root =
393        crate::setup::find_filesystem_workspace_root(&std::env::current_dir().map_err(|e| {
394            CliError::new(
395                ExitKind::Generic,
396                crate::INTERNAL_CODE,
397                format!("current_dir: {e}"),
398            )
399        })?)
400        .ok_or_else(|| {
401            CliError::new(
402                ExitKind::NotFound,
403                "WORKSPACE_NOT_INITIALISED",
404                "no filesystem-mem workspace found from cwd",
405            )
406        })?;
407    let bytes =
408        memstead_base::filesystem::publish::assemble_archive(&workspace_root).map_err(|e| {
409            // F1: backend-symmetric typed envelope for the missing-
410            // version case — the mem-repo path surfaces the same
411            // MEM_CONFIG_INCOMPLETE via Engine::export_mem.
412            if matches!(
413                &e,
414                memstead_base::filesystem::publish::AssembleError::Config(
415                    memstead_schema::PublishConversionError::MissingVersion
416                )
417            ) {
418                CliError::from_engine_op(memstead_base::EngineError::MemConfigIncomplete {
419                    mem: workspace_mem.clone(),
420                    missing_fields: vec!["version".to_string()],
421                })
422            } else {
423                CliError::new(ExitKind::Generic, "ARCHIVE_ASSEMBLY_FAILED", e.to_string())
424            }
425        })?;
426
427    let output = match args.output {
428        Some(p) => p,
429        None => {
430            // Filesystem-mem config doesn't carry `version` today —
431            // archive identity is `<mem_name>.mem` until the
432            // assemble path threads a version through. Operator can
433            // override with `-o`.
434            PathBuf::from(format!(
435                "{workspace_mem}.{}",
436                memstead_schema::ARCHIVE_EXTENSION
437            ))
438        }
439    };
440
441    let size_bytes = bytes.len();
442    std::fs::write(&output, &bytes).map_err(|e| {
443        CliError::new(
444            ExitKind::Generic,
445            crate::INTERNAL_CODE,
446            format!("write {}: {e}", output.display()),
447        )
448    })?;
449    // Count only the exported mem's entities — the store also holds
450    // mounted sibling mems (the multi-mount setup), which do not travel
451    // in this archive.
452    let entity_count = engine
453        .store()
454        .all_entities()
455        .filter(|e| !e.stub && e.id.mem() == workspace_mem)
456        .count();
457
458    if ctx.json {
459        print_json(&json!({
460            "archive_path": output.to_string_lossy(),
461            "name": workspace_mem,
462            "entity_count": entity_count,
463            "size_bytes": size_bytes,
464        }))?;
465    } else {
466        print_markdown(&format!(
467            "# Exported `{workspace_mem}`\n\n- Archive: `{}`\n- Entities: {}\n- Size: {} bytes",
468            output.display(),
469            entity_count,
470            size_bytes,
471        ));
472    }
473    Ok(())
474}
475
476#[cfg(feature = "mem-repo")]
477fn default_output_path(
478    mem_name: &str,
479    config: &memstead_schema::MemConfig,
480) -> anyhow::Result<PathBuf> {
481    let version = config.version.as_ref().ok_or_else(|| {
482        // F1: typed envelope replaces the pre-fix INTERNAL-collapse
483        // path (config lives at
484        // `__MEMSTEAD:mems/<name>/config.json` for the mem-repo
485        // backend). The recovery hint
486        // names the engine-owned setter that mutates the right
487        // surface for whichever backend serves the mem.
488        CliError::from_engine_op(memstead_base::EngineError::MemConfigIncomplete {
489            mem: mem_name.to_string(),
490            missing_fields: vec!["version".to_string()],
491        })
492    })?;
493    // The mem name is supplied by the caller (engine mem state)
494    // rather than pulled from the now-optional in-config `name` field.
495    let filename = format!(
496        "{mem_name}-{version}.{}",
497        memstead_schema::ARCHIVE_EXTENSION
498    );
499    Ok(PathBuf::from(filename))
500}
501
502/// `--format html` — one self-contained HTML file per mem (the read
503/// surface for non-operators). Backend-uniform via [`CliEngine::base`]
504/// and observably read-only. The export date is stamped once (UTC);
505/// `--today` on `memstead due` has no analogue here because the date
506/// only labels the export, it never filters.
507fn run_html(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
508    let engine_holder = ctx.cli_engine()?;
509    let engine = engine_holder.base();
510    // Resolve the target mem like `--format mem`: explicit name wins
511    // (read-only mounts allowed); otherwise the sole writable mem.
512    let mem = match &args.mem_name {
513        Some(m) => m.clone(),
514        None => {
515            let writables: Vec<String> = engine
516                .writable_mem_names()
517                .iter()
518                .map(|s| s.to_string())
519                .collect();
520            match writables.as_slice() {
521                [one] => one.clone(),
522                [] => {
523                    return Err(CliError::new(
524                        ExitKind::Validation,
525                        "INVALID_INPUT",
526                        "no writable mem loaded — pass --mem <name>",
527                    )
528                    .into());
529                }
530                _ => {
531                    return Err(CliError::new(
532                        ExitKind::Validation,
533                        "INVALID_INPUT",
534                        format!(
535                            "multiple writable mems loaded ({}) — pass --mem <name>",
536                            writables.join(", ")
537                        ),
538                    )
539                    .into());
540                }
541            }
542        }
543    };
544    let now = time::OffsetDateTime::now_utc();
545    let export_date = format!(
546        "{:04}-{:02}-{:02}",
547        now.year(),
548        u8::from(now.month()),
549        now.day()
550    );
551    let html = engine
552        .render_html_export(&mem, &export_date)
553        .map_err(CliError::from_engine_op)?;
554    let out_path = args
555        .output
556        .clone()
557        .unwrap_or_else(|| PathBuf::from(format!("{mem}.html")));
558    std::fs::write(&out_path, &html).map_err(|e| {
559        CliError::new(
560            ExitKind::Generic,
561            "IO_ERROR",
562            format!("write {}: {e}", out_path.display()),
563        )
564    })?;
565    if ctx.json {
566        print_json(&serde_json::json!({
567            "format": "html",
568            "mem": mem,
569            "path": out_path,
570            "bytes": html.len(),
571            "exported": export_date,
572        }))?;
573    } else {
574        print_markdown(&format!(
575            "# 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",
576            out_path.display(),
577            html.len()
578        ));
579    }
580    Ok(())
581}
582
583#[cfg(test)]
584mod tests {
585    use super::*;
586    use clap::Parser;
587
588    /// Mem selection is `--mem`, converged onto the convention every
589    /// other subcommand uses; the former `--mem-name` outlier is gone.
590    #[test]
591    fn export_mem_selection_flag_is_mem_not_mem_name() {
592        let parsed = Args::try_parse_from(["export", "--mem", "specs", "--format", "mem"]).unwrap();
593        assert_eq!(parsed.mem_name.as_deref(), Some("specs"));
594        assert!(
595            Args::try_parse_from(["export", "--mem-name", "specs"]).is_err(),
596            "the retired --mem-name flag must not parse"
597        );
598    }
599}