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#[derive(Parser, Debug)]
19pub struct Args {
20 #[arg(long, value_enum, default_value_t = Format::Markdown)]
24 pub format: Format,
25
26 #[arg(long, short = 'o', value_name = "PATH")]
30 pub output: Option<PathBuf>,
31
32 #[arg(long = "mem", value_name = "NAME")]
38 pub mem_name: Option<String>,
39}
40
41#[derive(ValueEnum, Clone, Copy, Debug)]
42pub enum Format {
43 Markdown,
45 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 => 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 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 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
227fn 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 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 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 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 CliError::from_engine_op(memstead_base::EngineError::MemConfigIncomplete {
349 mem: mem_name.to_string(),
350 missing_fields: vec!["version".to_string()],
351 })
352 })?;
353 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 #[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}