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)]
27pub struct Args {
28 #[arg(long, value_enum, default_value_t = Format::Markdown)]
34 pub format: Format,
35
36 #[arg(long, short = 'o', value_name = "PATH")]
41 pub output: Option<PathBuf>,
42
43 #[arg(long = "mem", value_name = "NAME")]
52 pub mem_name: Option<String>,
53}
54
55#[derive(ValueEnum, Clone, Copy, Debug)]
56pub enum Format {
57 Markdown,
59 Mem,
61 Json,
63 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 => 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
102const JSON_EXPORT_FORMAT: &str = "memstead-export/v1";
106
107fn run_json(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
116 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 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 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 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 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
367fn 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 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 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 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 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 CliError::from_engine_op(memstead_base::EngineError::MemConfigIncomplete {
496 mem: mem_name.to_string(),
497 missing_fields: vec!["version".to_string()],
498 })
499 })?;
500 let filename = format!(
503 "{mem_name}-{version}.{}",
504 memstead_schema::ARCHIVE_EXTENSION
505 );
506 Ok(PathBuf::from(filename))
507}
508
509fn run_html(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
515 let engine_holder = ctx.cli_engine()?;
516 let engine = engine_holder.base();
517 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 #[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}