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,
183                    tokens,
184                    None,
185                    None,
186                    None,
187                    engine.mem_origin_class(entity.id.mem()),
188                    outgoing,
189                    None,
190                )
191            })
192            .collect();
193
194        let mut group = serde_json::Map::new();
195        if let Some(s) = schema_pin {
196            group.insert("schema".to_string(), json!(s));
197        }
198        group.insert(
199            "read_only".to_string(),
200            json!(!engine.mem_router().is_writable(mem_name)),
201        );
202        group.insert("entity_count".to_string(), json!(envelopes.len()));
203        group.insert("entities".to_string(), serde_json::Value::Array(envelopes));
204        mems.insert(mem_name.clone(), serde_json::Value::Object(group));
205    }
206
207    print_json(&json!({
208        "format": JSON_EXPORT_FORMAT,
209        "mems": mems,
210    }))
211}
212
213#[cfg(feature = "mem-repo")]
214fn run_markdown(
215    ctx: &CliContext,
216    engine: &memstead_base::Engine,
217    mem_filter: Option<&str>,
218) -> anyhow::Result<()> {
219    // The engine returns a
220    // typed `MARKDOWN_EXPORT_UNSUPPORTED_BACKEND` when `--mem`
221    // targets a mem whose backend doesn't support markdown
222    // regeneration. The workspace-wide path returns counts plus a
223    // structured `skipped_mounts` list.
224    let result = engine
225        .export_markdown(mem_filter, None)
226        .map_err(CliError::from_engine_op)?;
227
228    if ctx.json {
229        let mut body = json!({
230            "written": result.written,
231            "unchanged": result.unchanged,
232        });
233        if !result.skipped_mounts.is_empty() {
234            body["skipped_mounts"] = serde_json::to_value(&result.skipped_mounts)
235                .unwrap_or_else(|_| serde_json::Value::Array(Vec::new()));
236        }
237        print_json(&body)?;
238    } else {
239        let mut block = format!(
240            "# Export — markdown\n\n- Written: {}\n- Unchanged: {}",
241            result.written, result.unchanged,
242        );
243        if !result.skipped_mounts.is_empty() {
244            block.push_str("\n\n## Skipped mounts\n");
245            for m in &result.skipped_mounts {
246                block.push_str(&format!(
247                    "\n- `{}` — backend `{}` ({}); use `--format mem` for archive export",
248                    m.mem, m.active_backend, m.reason,
249                ));
250            }
251        }
252        print_markdown(&block);
253    }
254    Ok(())
255}
256
257#[cfg(feature = "mem-repo")]
258fn run_mem(ctx: &CliContext, engine: &memstead_base::Engine, args: Args) -> anyhow::Result<()> {
259    let mem_name = resolve_mem_name(engine, args.mem_name)?;
260    let config = engine
261        .mem_configs_named()
262        .find(|(name, _)| *name == mem_name)
263        .map(|(_, c)| c)
264        .ok_or_else(|| {
265            CliError::new(
266                ExitKind::NotFound,
267                "UNKNOWN_MEM",
268                format!("mem config not found for '{mem_name}'"),
269            )
270        })?;
271
272    let output = match args.output {
273        Some(p) => p,
274        None => default_output_path(&mem_name, config)?,
275    };
276
277    let result = engine
278        .export_mem(&mem_name, &output)
279        .map_err(CliError::from_engine_op)?;
280
281    // Surface each cross-mem edge
282    // whose target won't travel inside the single-mem archive — these
283    // are exactly what `install` will refuse, so showing them at export
284    // time lets the operator act before sharing.
285    let dangling = &result.dangling_cross_mem_edges;
286
287    if ctx.json {
288        let warnings: Vec<_> = dangling
289            .iter()
290            .map(|e| {
291                json!({
292                    "code": "DANGLING_CROSS_MEM_EDGE_IN_EXPORT",
293                    "entity": e.entity_path,
294                    "target_id": e.target_id,
295                    "target_mem": e.target_mem,
296                })
297            })
298            .collect();
299        print_json(&json!({
300            "archive_path": result.archive_path,
301            "name": result.name,
302            "version": result.version,
303            "entity_count": result.entity_count,
304            "size_bytes": result.size_bytes,
305            "warnings": warnings,
306        }))?;
307    } else {
308        let mut block = format!(
309            "# Exported `{}` v{}\n\n- Archive: `{}`\n- Entities: {}\n- Size: {} bytes",
310            result.name,
311            result.version,
312            result.archive_path,
313            result.entity_count,
314            result.size_bytes,
315        );
316        if !dangling.is_empty() {
317            block.push_str("\n\n## Warnings\n");
318            for e in dangling {
319                block.push_str(&format!(
320                    "\n- **DANGLING_CROSS_MEM_EDGE_IN_EXPORT**: `{}` → `{}` (mem `{}`) — \
321                     target lives outside this archive; `memstead install` will reject it unless \
322                     mem `{}` is also present.",
323                    e.entity_path, e.target_id, e.target_mem, e.target_mem,
324                ));
325            }
326        }
327        print_markdown(&block);
328    }
329    Ok(())
330}
331
332#[cfg(feature = "mem-repo")]
333fn resolve_mem_name(
334    engine: &memstead_base::Engine,
335    explicit: Option<String>,
336) -> anyhow::Result<String> {
337    if let Some(name) = explicit {
338        return Ok(name);
339    }
340    let writable: Vec<String> = engine
341        .mem_configs_named()
342        .filter(|(name, _)| engine.mem_router().is_writable(name))
343        .map(|(name, _)| name.to_string())
344        .collect();
345
346    match writable.len() {
347        0 => Err(CliError::new(
348            ExitKind::Generic,
349            "NO_WRITABLE_MEM",
350            "no writable mem loaded — nothing to export",
351        )
352        .into()),
353        1 => Ok(writable.into_iter().next().unwrap()),
354        _ => Err(CliError::new(
355            ExitKind::Validation,
356            "AMBIGUOUS_MEM",
357            format!(
358                "multiple writable mems loaded ({}); pass --mem <name>",
359                writable.join(", ")
360            ),
361        )
362        .with_details(json!({ "mems": writable }))
363        .into()),
364    }
365}
366
367/// Filesystem-mem `memstead export --format mem` builds the `.mem`
368/// archive bytes via [`memstead_base::filesystem::publish::assemble_archive`]
369/// (the same path `memstead publish` uses on a filesystem-mem workspace)
370/// and writes them to `--output` (defaulting to `<name>-<version>.mem`
371/// in cwd). `--mem` is accepted for shape parity but only the
372/// workspace's pinned mem matches.
373fn run_mem_filesystem(
374    ctx: &CliContext,
375    engine: &memstead_base::Engine,
376    args: Args,
377) -> anyhow::Result<()> {
378    let workspace_mem = engine
379        .mem_names()
380        .into_iter()
381        .next()
382        .map(String::from)
383        .unwrap_or_default();
384    if let Some(name) = args.mem_name.as_deref()
385        && name != workspace_mem
386    {
387        return Err(CliError::new(
388                ExitKind::NotFound,
389                "UNKNOWN_MEM",
390                format!(
391                    "filesystem-mem is single-mem: workspace mem is `{workspace_mem}`, --mem `{name}` does not match"
392                ),
393            )
394            .into());
395    }
396
397    // assemble_archive is engine-agnostic now — pass the discovered
398    // workspace root directly.
399    let workspace_root =
400        crate::setup::find_filesystem_workspace_root(&std::env::current_dir().map_err(|e| {
401            CliError::new(
402                ExitKind::Generic,
403                crate::INTERNAL_CODE,
404                format!("current_dir: {e}"),
405            )
406        })?)
407        .ok_or_else(|| {
408            CliError::new(
409                ExitKind::NotFound,
410                "WORKSPACE_NOT_INITIALISED",
411                "no filesystem-mem workspace found from cwd",
412            )
413        })?;
414    let bytes =
415        memstead_base::filesystem::publish::assemble_archive(&workspace_root).map_err(|e| {
416            // F1: backend-symmetric typed envelope for the missing-
417            // version case — the mem-repo path surfaces the same
418            // MEM_CONFIG_INCOMPLETE via Engine::export_mem.
419            if matches!(
420                &e,
421                memstead_base::filesystem::publish::AssembleError::Config(
422                    memstead_schema::PublishConversionError::MissingVersion
423                )
424            ) {
425                CliError::from_engine_op(memstead_base::EngineError::MemConfigIncomplete {
426                    mem: workspace_mem.clone(),
427                    missing_fields: vec!["version".to_string()],
428                })
429            } else {
430                CliError::new(ExitKind::Generic, "ARCHIVE_ASSEMBLY_FAILED", e.to_string())
431            }
432        })?;
433
434    let output = match args.output {
435        Some(p) => p,
436        None => {
437            // Filesystem-mem config doesn't carry `version` today —
438            // archive identity is `<mem_name>.mem` until the
439            // assemble path threads a version through. Operator can
440            // override with `-o`.
441            PathBuf::from(format!(
442                "{workspace_mem}.{}",
443                memstead_schema::ARCHIVE_EXTENSION
444            ))
445        }
446    };
447
448    let size_bytes = bytes.len();
449    std::fs::write(&output, &bytes).map_err(|e| {
450        CliError::new(
451            ExitKind::Generic,
452            crate::INTERNAL_CODE,
453            format!("write {}: {e}", output.display()),
454        )
455    })?;
456    // Count only the exported mem's entities — the store also holds
457    // mounted sibling mems (the multi-mount setup), which do not travel
458    // in this archive.
459    let entity_count = engine
460        .store()
461        .all_entities()
462        .filter(|e| !e.stub && e.id.mem() == workspace_mem)
463        .count();
464
465    if ctx.json {
466        print_json(&json!({
467            "archive_path": output.to_string_lossy(),
468            "name": workspace_mem,
469            "entity_count": entity_count,
470            "size_bytes": size_bytes,
471        }))?;
472    } else {
473        print_markdown(&format!(
474            "# Exported `{workspace_mem}`\n\n- Archive: `{}`\n- Entities: {}\n- Size: {} bytes",
475            output.display(),
476            entity_count,
477            size_bytes,
478        ));
479    }
480    Ok(())
481}
482
483#[cfg(feature = "mem-repo")]
484fn default_output_path(
485    mem_name: &str,
486    config: &memstead_schema::MemConfig,
487) -> anyhow::Result<PathBuf> {
488    let version = config.version.as_ref().ok_or_else(|| {
489        // F1: typed envelope replaces the pre-fix INTERNAL-collapse
490        // path (config lives at
491        // `__MEMSTEAD:mems/<name>/config.json` for the mem-repo
492        // backend). The recovery hint
493        // names the engine-owned setter that mutates the right
494        // surface for whichever backend serves the mem.
495        CliError::from_engine_op(memstead_base::EngineError::MemConfigIncomplete {
496            mem: mem_name.to_string(),
497            missing_fields: vec!["version".to_string()],
498        })
499    })?;
500    // The mem name is supplied by the caller (engine mem state)
501    // rather than pulled from the now-optional in-config `name` field.
502    let filename = format!(
503        "{mem_name}-{version}.{}",
504        memstead_schema::ARCHIVE_EXTENSION
505    );
506    Ok(PathBuf::from(filename))
507}
508
509/// `--format html` — one self-contained HTML file per mem (the read
510/// surface for non-operators). Backend-uniform via [`CliEngine::base`]
511/// and observably read-only. The export date is stamped once (UTC);
512/// `--today` on `memstead due` has no analogue here because the date
513/// only labels the export, it never filters.
514fn run_html(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
515    let engine_holder = ctx.cli_engine()?;
516    let engine = engine_holder.base();
517    // Resolve the target mem like `--format mem`: explicit name wins
518    // (read-only mounts allowed); otherwise the sole writable mem.
519    let mem = match &args.mem_name {
520        Some(m) => m.clone(),
521        None => {
522            let writables: Vec<String> = engine
523                .writable_mem_names()
524                .iter()
525                .map(|s| s.to_string())
526                .collect();
527            match writables.as_slice() {
528                [one] => one.clone(),
529                [] => {
530                    return Err(CliError::new(
531                        ExitKind::Validation,
532                        "INVALID_INPUT",
533                        "no writable mem loaded — pass --mem <name>",
534                    )
535                    .into());
536                }
537                _ => {
538                    return Err(CliError::new(
539                        ExitKind::Validation,
540                        "INVALID_INPUT",
541                        format!(
542                            "multiple writable mems loaded ({}) — pass --mem <name>",
543                            writables.join(", ")
544                        ),
545                    )
546                    .into());
547                }
548            }
549        }
550    };
551    let now = time::OffsetDateTime::now_utc();
552    let export_date = format!(
553        "{:04}-{:02}-{:02}",
554        now.year(),
555        u8::from(now.month()),
556        now.day()
557    );
558    let html = engine
559        .render_html_export(&mem, &export_date)
560        .map_err(CliError::from_engine_op)?;
561    let out_path = args
562        .output
563        .clone()
564        .unwrap_or_else(|| PathBuf::from(format!("{mem}.html")));
565    std::fs::write(&out_path, &html).map_err(|e| {
566        CliError::new(
567            ExitKind::Generic,
568            "IO_ERROR",
569            format!("write {}: {e}", out_path.display()),
570        )
571    })?;
572    if ctx.json {
573        print_json(&serde_json::json!({
574            "format": "html",
575            "mem": mem,
576            "path": out_path,
577            "bytes": html.len(),
578            "exported": export_date,
579        }))?;
580    } else {
581        print_markdown(&format!(
582            "# 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",
583            out_path.display(),
584            html.len()
585        ));
586    }
587    Ok(())
588}
589
590#[cfg(test)]
591mod tests {
592    use super::*;
593    use clap::Parser;
594
595    /// Mem selection is `--mem`, converged onto the convention every
596    /// other subcommand uses; the former `--mem-name` outlier is gone.
597    #[test]
598    fn export_mem_selection_flag_is_mem_not_mem_name() {
599        let parsed = Args::try_parse_from(["export", "--mem", "specs", "--format", "mem"]).unwrap();
600        assert_eq!(parsed.mem_name.as_deref(), Some("specs"));
601        assert!(
602            Args::try_parse_from(["export", "--mem-name", "specs"]).is_err(),
603            "the retired --mem-name flag must not parse"
604        );
605    }
606}