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) or as a portable `.mem` archive.
11///
12/// `--format markdown` is supported only on folder-backed mems; use
13/// `--format mem` for archive export on git-branch backends. Targeting
14/// a mem on an incompatible backend returns
15/// `MARKDOWN_EXPORT_UNSUPPORTED_BACKEND`; workspace-wide markdown export
16/// in a mixed-backend workspace completes the folder mounts and lists
17/// the declined mounts under `skipped_mounts`.
18#[derive(Parser, Debug)]
19pub struct Args {
20    /// Output format. `markdown` regenerates the mem directory in place
21    /// (folder-backed mems only); `mem` writes a portable `.mem` zip
22    /// suitable for sharing (every backend).
23    #[arg(long, value_enum, default_value_t = Format::Markdown)]
24    pub format: Format,
25
26    /// Output path for `--format mem`. Defaults to `./<name>-<version>.mem`
27    /// in the current directory, matching the "external vs cache filename"
28    /// convention for portable mem archives. Ignored for `--format markdown`.
29    #[arg(long, short = 'o', value_name = "PATH")]
30    pub output: Option<PathBuf>,
31
32    /// Which mem to export (by name). For `--format markdown`, omitting
33    /// this argument runs a workspace-wide export and reports any
34    /// declined mounts under `skipped_mounts`. For `--format mem`,
35    /// required when more than one write mem is loaded; defaults to
36    /// the first writable mem otherwise.
37    #[arg(long = "mem", value_name = "NAME")]
38    pub mem_name: Option<String>,
39}
40
41#[derive(ValueEnum, Clone, Copy, Debug)]
42pub enum Format {
43    /// Regenerate markdown files in place.
44    Markdown,
45    /// Write a `.mem` zip archive to `--output`.
46    Mem,
47}
48
49pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
50    match ctx.cli_engine()? {
51        #[cfg(feature = "mem-repo")]
52        CliEngine::MemRepo(engine) => match args.format {
53            Format::Markdown => run_markdown(ctx, &engine, args.mem_name.as_deref()),
54            Format::Mem => run_mem(ctx, &engine, args),
55        },
56        CliEngine::Filesystem(engine) => match args.format {
57            // `--format markdown` regenerates files in place. The
58            // filesystem engine's writer would do the same, but
59            // there's no `export_markdown` accessor today; surface
60            // the gap as a clear validation error rather than a
61            // silent no-op.
62            Format::Markdown => Err(CliError::new(
63                ExitKind::Validation,
64                "INVALID_INPUT",
65                "--format markdown is not yet supported on filesystem-mem `memstead export` — entities are already on disk in their canonical form",
66            )
67            .into()),
68            Format::Mem => run_mem_filesystem(ctx, &engine, args),
69        },
70    }
71}
72
73#[cfg(feature = "mem-repo")]
74fn run_markdown(
75    ctx: &CliContext,
76    engine: &memstead_base::Engine,
77    mem_filter: Option<&str>,
78) -> anyhow::Result<()> {
79    // The engine returns a
80    // typed `MARKDOWN_EXPORT_UNSUPPORTED_BACKEND` when `--mem`
81    // targets a mem whose backend doesn't support markdown
82    // regeneration. The workspace-wide path returns counts plus a
83    // structured `skipped_mounts` list.
84    let result = engine
85        .export_markdown(mem_filter, None)
86        .map_err(CliError::from_engine_op)?;
87
88    if ctx.json {
89        let mut body = json!({
90            "written": result.written,
91            "unchanged": result.unchanged,
92        });
93        if !result.skipped_mounts.is_empty() {
94            body["skipped_mounts"] = serde_json::to_value(&result.skipped_mounts)
95                .unwrap_or_else(|_| serde_json::Value::Array(Vec::new()));
96        }
97        print_json(&body)?;
98    } else {
99        let mut block = format!(
100            "# Export — markdown\n\n- Written: {}\n- Unchanged: {}",
101            result.written, result.unchanged,
102        );
103        if !result.skipped_mounts.is_empty() {
104            block.push_str("\n\n## Skipped mounts\n");
105            for m in &result.skipped_mounts {
106                block.push_str(&format!(
107                    "\n- `{}` — backend `{}` ({}); use `--format mem` for archive export",
108                    m.mem, m.active_backend, m.reason,
109                ));
110            }
111        }
112        print_markdown(&block);
113    }
114    Ok(())
115}
116
117#[cfg(feature = "mem-repo")]
118fn run_mem(ctx: &CliContext, engine: &memstead_base::Engine, args: Args) -> anyhow::Result<()> {
119    let mem_name = resolve_mem_name(engine, args.mem_name)?;
120    let config = engine
121        .mem_configs_named()
122        .find(|(name, _)| *name == mem_name)
123        .map(|(_, c)| c)
124        .ok_or_else(|| {
125            CliError::new(
126                ExitKind::NotFound,
127                "UNKNOWN_MEM",
128                format!("mem config not found for '{mem_name}'"),
129            )
130        })?;
131
132    let output = match args.output {
133        Some(p) => p,
134        None => default_output_path(&mem_name, config)?,
135    };
136
137    let result = engine
138        .export_mem(&mem_name, &output)
139        .map_err(CliError::from_engine_op)?;
140
141    // Surface each cross-mem edge
142    // whose target won't travel inside the single-mem archive — these
143    // are exactly what `install` will refuse, so showing them at export
144    // time lets the operator act before sharing.
145    let dangling = &result.dangling_cross_mem_edges;
146
147    if ctx.json {
148        let warnings: Vec<_> = dangling
149            .iter()
150            .map(|e| {
151                json!({
152                    "code": "DANGLING_CROSS_MEM_EDGE_IN_EXPORT",
153                    "entity": e.entity_path,
154                    "target_id": e.target_id,
155                    "target_mem": e.target_mem,
156                })
157            })
158            .collect();
159        print_json(&json!({
160            "archive_path": result.archive_path,
161            "name": result.name,
162            "version": result.version,
163            "entity_count": result.entity_count,
164            "size_bytes": result.size_bytes,
165            "warnings": warnings,
166        }))?;
167    } else {
168        let mut block = format!(
169            "# Exported `{}` v{}\n\n- Archive: `{}`\n- Entities: {}\n- Size: {} bytes",
170            result.name,
171            result.version,
172            result.archive_path,
173            result.entity_count,
174            result.size_bytes,
175        );
176        if !dangling.is_empty() {
177            block.push_str("\n\n## Warnings\n");
178            for e in dangling {
179                block.push_str(&format!(
180                    "\n- **DANGLING_CROSS_MEM_EDGE_IN_EXPORT**: `{}` → `{}` (mem `{}`) — \
181                     target lives outside this archive; `memstead install` will reject it unless \
182                     mem `{}` is also present.",
183                    e.entity_path, e.target_id, e.target_mem, e.target_mem,
184                ));
185            }
186        }
187        print_markdown(&block);
188    }
189    Ok(())
190}
191
192#[cfg(feature = "mem-repo")]
193fn resolve_mem_name(
194    engine: &memstead_base::Engine,
195    explicit: Option<String>,
196) -> anyhow::Result<String> {
197    if let Some(name) = explicit {
198        return Ok(name);
199    }
200    let writable: Vec<String> = engine
201        .mem_configs_named()
202        .filter(|(name, _)| engine.mem_router().is_writable(name))
203        .map(|(name, _)| name.to_string())
204        .collect();
205
206    match writable.len() {
207        0 => Err(CliError::new(
208            ExitKind::Generic,
209            "NO_WRITABLE_MEM",
210            "no writable mem loaded — nothing to export",
211        )
212        .into()),
213        1 => Ok(writable.into_iter().next().unwrap()),
214        _ => Err(CliError::new(
215            ExitKind::Validation,
216            "AMBIGUOUS_MEM",
217            format!(
218                "multiple writable mems loaded ({}); pass --mem <name>",
219                writable.join(", ")
220            ),
221        )
222        .with_details(json!({ "mems": writable }))
223        .into()),
224    }
225}
226
227/// Filesystem-mem `memstead export --format mem` builds the `.mem`
228/// archive bytes via [`memstead_base::filesystem::publish::assemble_archive`]
229/// (the same path `memstead publish` uses on a filesystem-mem workspace)
230/// and writes them to `--output` (defaulting to `<name>-<version>.mem`
231/// in cwd). `--mem` is accepted for shape parity but only the
232/// workspace's pinned mem matches.
233fn run_mem_filesystem(
234    ctx: &CliContext,
235    engine: &memstead_base::Engine,
236    args: Args,
237) -> anyhow::Result<()> {
238    let workspace_mem = engine
239        .mem_names()
240        .into_iter()
241        .next()
242        .map(String::from)
243        .unwrap_or_default();
244    if let Some(name) = args.mem_name.as_deref()
245        && name != workspace_mem
246    {
247        return Err(CliError::new(
248                ExitKind::NotFound,
249                "UNKNOWN_MEM",
250                format!(
251                    "filesystem-mem is single-mem: workspace mem is `{workspace_mem}`, --mem `{name}` does not match"
252                ),
253            )
254            .into());
255    }
256
257    // assemble_archive is engine-agnostic now — pass the discovered
258    // workspace root directly.
259    let workspace_root =
260        crate::setup::find_filesystem_workspace_root(&std::env::current_dir().map_err(|e| {
261            CliError::new(
262                ExitKind::Generic,
263                crate::INTERNAL_CODE,
264                format!("current_dir: {e}"),
265            )
266        })?)
267        .ok_or_else(|| {
268            CliError::new(
269                ExitKind::NotFound,
270                "WORKSPACE_NOT_INITIALISED",
271                "no filesystem-mem workspace found from cwd",
272            )
273        })?;
274    let bytes =
275        memstead_base::filesystem::publish::assemble_archive(&workspace_root).map_err(|e| {
276            // F1: backend-symmetric typed envelope for the missing-
277            // version case — the mem-repo path surfaces the same
278            // MEM_CONFIG_INCOMPLETE via Engine::export_mem.
279            if matches!(
280                &e,
281                memstead_base::filesystem::publish::AssembleError::Config(
282                    memstead_schema::PublishConversionError::MissingVersion
283                )
284            ) {
285                CliError::from_engine_op(memstead_base::EngineError::MemConfigIncomplete {
286                    mem: workspace_mem.clone(),
287                    missing_fields: vec!["version".to_string()],
288                })
289            } else {
290                CliError::new(ExitKind::Generic, "ARCHIVE_ASSEMBLY_FAILED", e.to_string())
291            }
292        })?;
293
294    let output = match args.output {
295        Some(p) => p,
296        None => {
297            // Filesystem-mem config doesn't carry `version` today —
298            // archive identity is `<mem_name>.mem` until the
299            // assemble path threads a version through. Operator can
300            // override with `-o`.
301            PathBuf::from(format!(
302                "{workspace_mem}.{}",
303                memstead_schema::ARCHIVE_EXTENSION
304            ))
305        }
306    };
307
308    let size_bytes = bytes.len();
309    std::fs::write(&output, &bytes).map_err(|e| {
310        CliError::new(
311            ExitKind::Generic,
312            crate::INTERNAL_CODE,
313            format!("write {}: {e}", output.display()),
314        )
315    })?;
316    let entity_count = engine.store().all_entities().filter(|e| !e.stub).count();
317
318    if ctx.json {
319        print_json(&json!({
320            "archive_path": output.to_string_lossy(),
321            "name": workspace_mem,
322            "entity_count": entity_count,
323            "size_bytes": size_bytes,
324        }))?;
325    } else {
326        print_markdown(&format!(
327            "# Exported `{workspace_mem}`\n\n- Archive: `{}`\n- Entities: {}\n- Size: {} bytes",
328            output.display(),
329            entity_count,
330            size_bytes,
331        ));
332    }
333    Ok(())
334}
335
336#[cfg(feature = "mem-repo")]
337fn default_output_path(
338    mem_name: &str,
339    config: &memstead_schema::MemConfig,
340) -> anyhow::Result<PathBuf> {
341    let version = config.version.as_ref().ok_or_else(|| {
342        // F1: typed envelope replaces the pre-fix INTERNAL-collapse
343        // path (config lives at
344        // `__MEMSTEAD:mems/<name>/config.json` for the mem-repo
345        // backend). The recovery hint
346        // names the engine-owned setter that mutates the right
347        // surface for whichever backend serves the mem.
348        CliError::from_engine_op(memstead_base::EngineError::MemConfigIncomplete {
349            mem: mem_name.to_string(),
350            missing_fields: vec!["version".to_string()],
351        })
352    })?;
353    // The mem name is supplied by the caller (engine mem state)
354    // rather than pulled from the now-optional in-config `name` field.
355    let filename = format!(
356        "{mem_name}-{version}.{}",
357        memstead_schema::ARCHIVE_EXTENSION
358    );
359    Ok(PathBuf::from(filename))
360}
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365    use clap::Parser;
366
367    /// Mem selection is `--mem`, converged onto the convention every
368    /// other subcommand uses; the former `--mem-name` outlier is gone.
369    #[test]
370    fn export_mem_selection_flag_is_mem_not_mem_name() {
371        let parsed = Args::try_parse_from(["export", "--mem", "specs", "--format", "mem"]).unwrap();
372        assert_eq!(parsed.mem_name.as_deref(), Some("specs"));
373        assert!(
374            Args::try_parse_from(["export", "--mem-name", "specs"]).is_err(),
375            "the retired --mem-name flag must not parse"
376        );
377    }
378}