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)]
38 pub format: Format,
39
40 #[arg(long, short = 'o', value_name = "PATH")]
46 pub output: Option<PathBuf>,
47
48 #[arg(long = "mem", value_name = "NAME")]
57 pub mem_name: Option<String>,
58
59 #[arg(long)]
69 pub self_contained: bool,
70
71 #[arg(long = "base-url", value_name = "URL")]
77 pub base_url: Option<String>,
78}
79
80#[derive(ValueEnum, Clone, Copy, Debug)]
81pub enum Format {
82 Markdown,
84 Mem,
86 Json,
88 Html,
91 LlmsTxt,
95}
96
97pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
98 if matches!(args.format, Format::Json) {
99 return run_json(ctx, args);
100 }
101 if matches!(args.format, Format::Html) {
102 return run_html(ctx, args);
103 }
104 if matches!(args.format, Format::LlmsTxt) {
105 return run_llms_txt(ctx, args);
106 }
107 match ctx.cli_engine()? {
108 #[cfg(feature = "mem-repo")]
109 CliEngine::MemRepo(engine) => match args.format {
110 Format::Markdown => run_markdown(ctx, &engine, args.mem_name.as_deref()),
111 Format::Mem => run_mem(ctx, &engine, args),
112 Format::Json => unreachable!("dispatched to run_json above"),
113 Format::Html => unreachable!("dispatched to run_html above"),
114 Format::LlmsTxt => unreachable!("dispatched to run_llms_txt above"),
115 },
116 CliEngine::Filesystem(engine) => match args.format {
117 Format::Markdown => Err(CliError::new(
123 ExitKind::Validation,
124 "INVALID_INPUT",
125 "--format markdown is not yet supported on filesystem-mem `memstead export` — entities are already on disk in their canonical form",
126 )
127 .into()),
128 Format::Mem => run_mem_filesystem(ctx, &engine, args),
129 Format::Json => unreachable!("dispatched to run_json above"),
130 Format::Html => unreachable!("dispatched to run_html above"),
131 Format::LlmsTxt => unreachable!("dispatched to run_llms_txt above"),
132 },
133 }
134}
135
136const JSON_EXPORT_FORMAT: &str = "memstead-export/v1";
140
141fn run_json(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
150 if args.output.is_some() {
154 return Err(CliError::new(
155 ExitKind::Validation,
156 "INVALID_INPUT",
157 "--output applies only to --format mem — the JSON document goes to stdout; redirect it instead",
158 )
159 .into());
160 }
161
162 let cli_engine = ctx.cli_engine()?;
163 let engine = cli_engine.base();
164
165 let all_names: Vec<String> = engine.mem_names().into_iter().map(String::from).collect();
166 let selected: Vec<String> = match &args.mem_name {
170 Some(name) => {
171 if !all_names.iter().any(|n| n == name) {
172 return Err(CliError::new(
173 ExitKind::NotFound,
174 "UNKNOWN_MEM",
175 format!(
176 "unknown mem '{name}' — loaded mems: {}",
177 all_names.join(", ")
178 ),
179 )
180 .with_details(json!({ "mem": name, "loaded": all_names }))
181 .into());
182 }
183 vec![name.clone()]
184 }
185 None => all_names
186 .iter()
187 .filter(|n| engine.mem_router().is_writable(n))
188 .cloned()
189 .collect(),
190 };
191
192 let mut mems = serde_json::Map::new();
193 for mem_name in &selected {
194 let schema_pin = engine
197 .mem_configs_named()
198 .find(|(name, _)| name == mem_name)
199 .and_then(|(_, c)| c.schema.as_ref())
200 .map(|s| s.to_string());
201
202 let mut entities: Vec<&memstead_base::Entity> = engine
203 .store()
204 .all_entities()
205 .filter(|e| !e.stub && e.mem == *mem_name)
206 .collect();
207 entities.sort_by(|a, b| a.id.as_ref().cmp(b.id.as_ref()));
208
209 let envelopes: Vec<serde_json::Value> = entities
210 .iter()
211 .map(|entity| {
212 let body = memstead_base::render::render_entity_markdown(entity, None);
213 let tokens = memstead_base::chunking::estimate_tokens(&body);
214 let outgoing = engine.store().outgoing(&entity.id);
215 memstead_base::render::build_entity_envelope(
218 entity,
219 tokens,
220 None,
221 None,
222 None,
223 engine.mem_origin_class(entity.id.mem()),
224 outgoing,
225 None,
226 None,
227 None,
228 )
229 })
230 .collect();
231
232 let mut group = serde_json::Map::new();
233 if let Some(s) = schema_pin {
234 group.insert("schema".to_string(), json!(s));
235 }
236 group.insert(
237 "read_only".to_string(),
238 json!(!engine.mem_router().is_writable(mem_name)),
239 );
240 group.insert("entity_count".to_string(), json!(envelopes.len()));
241 group.insert("entities".to_string(), serde_json::Value::Array(envelopes));
242 mems.insert(mem_name.clone(), serde_json::Value::Object(group));
243 }
244
245 print_json(&json!({
246 "format": JSON_EXPORT_FORMAT,
247 "mems": mems,
248 }))
249}
250
251#[cfg(feature = "mem-repo")]
252fn run_markdown(
253 ctx: &CliContext,
254 engine: &memstead_base::Engine,
255 mem_filter: Option<&str>,
256) -> anyhow::Result<()> {
257 let result = engine
263 .export_markdown(mem_filter, None)
264 .map_err(CliError::from_engine_op)?;
265
266 if ctx.json {
267 let mut body = json!({
268 "written": result.written,
269 "unchanged": result.unchanged,
270 });
271 if !result.skipped_mounts.is_empty() {
272 body["skipped_mounts"] = serde_json::to_value(&result.skipped_mounts)
273 .unwrap_or_else(|_| serde_json::Value::Array(Vec::new()));
274 }
275 print_json(&body)?;
276 } else {
277 let mut block = format!(
278 "# Export — markdown\n\n- Written: {}\n- Unchanged: {}",
279 result.written, result.unchanged,
280 );
281 if !result.skipped_mounts.is_empty() {
282 block.push_str("\n\n## Skipped mounts\n");
283 for m in &result.skipped_mounts {
284 block.push_str(&format!(
285 "\n- `{}` — backend `{}` ({}); use `--format mem` for archive export",
286 m.mem, m.active_backend, m.reason,
287 ));
288 }
289 }
290 print_markdown(&block);
291 }
292 Ok(())
293}
294
295#[cfg(feature = "mem-repo")]
296fn run_mem(ctx: &CliContext, engine: &memstead_base::Engine, args: Args) -> anyhow::Result<()> {
297 let mem_name = resolve_mem_name(engine, args.mem_name)?;
298 let config = engine
299 .mem_configs_named()
300 .find(|(name, _)| *name == mem_name)
301 .map(|(_, c)| c)
302 .ok_or_else(|| {
303 CliError::new(
304 ExitKind::NotFound,
305 "UNKNOWN_MEM",
306 format!("mem config not found for '{mem_name}'"),
307 )
308 })?;
309
310 let output = match args.output {
311 Some(p) => p,
312 None => default_output_path(&mem_name, config)?,
313 };
314
315 let mut result = engine
316 .export_mem(&mem_name, &output)
317 .map_err(CliError::from_engine_op)?;
318
319 let dropped = if args.self_contained {
325 let self_contained = make_self_contained_on_disk(&output)?;
326 result.size_bytes = self_contained.bytes.len() as u64;
327 result.dangling_cross_mem_edges.clear();
328 Some(self_contained.dropped)
329 } else {
330 None
331 };
332
333 let dangling = &result.dangling_cross_mem_edges;
338
339 if ctx.json {
340 let mut warnings: Vec<_> = dangling
341 .iter()
342 .map(|e| {
343 json!({
344 "code": "DANGLING_CROSS_MEM_EDGE_IN_EXPORT",
345 "entity": e.entity_path,
346 "target_id": e.target_id,
347 "target_mem": e.target_mem,
348 })
349 })
350 .collect();
351 if let Some(dropped) = &dropped {
352 warnings.extend(dropped.iter().map(|e| {
353 json!({
354 "code": "CROSS_MEM_EDGE_DROPPED",
355 "entity": e.entity_path,
356 "target_id": e.target_id,
357 "target_mem": e.target_mem,
358 })
359 }));
360 }
361 print_json(&json!({
362 "archive_path": result.archive_path,
363 "name": result.name,
364 "version": result.version,
365 "entity_count": result.entity_count,
366 "size_bytes": result.size_bytes,
367 "self_contained": args.self_contained,
368 "warnings": warnings,
369 }))?;
370 } else {
371 let mut block = format!(
372 "# Exported `{}` v{}\n\n- Archive: `{}`\n- Entities: {}\n- Size: {} bytes",
373 result.name,
374 result.version,
375 result.archive_path,
376 result.entity_count,
377 result.size_bytes,
378 );
379 if args.self_contained {
380 block.push_str("\n- Self-contained: yes");
381 }
382 if !dangling.is_empty() {
383 block.push_str("\n\n## Warnings\n");
384 for e in dangling {
385 block.push_str(&format!(
386 "\n- **DANGLING_CROSS_MEM_EDGE_IN_EXPORT**: `{}` → `{}` (mem `{}`) — \
387 target lives outside this archive; `memstead install` will reject it unless \
388 mem `{}` is also present.",
389 e.entity_path, e.target_id, e.target_mem, e.target_mem,
390 ));
391 }
392 }
393 if let Some(dropped) = &dropped
394 && !dropped.is_empty()
395 {
396 block.push_str("\n\n## Dropped cross-mem edges\n");
397 for e in dropped {
398 block.push_str(&format!(
399 "\n- **CROSS_MEM_EDGE_DROPPED**: `{}` → `{}` (mem `{}`): the relationship \
400 row does not travel; a body wiki-link to the same target still does.",
401 e.entity_path, e.target_id, e.target_mem,
402 ));
403 }
404 }
405 print_markdown(&block);
406 }
407 Ok(())
408}
409
410fn make_self_contained_on_disk(
413 path: &std::path::Path,
414) -> anyhow::Result<memstead_base::validator::SelfContainedArchive> {
415 let bytes = std::fs::read(path).map_err(|e| {
416 CliError::new(
417 ExitKind::Generic,
418 crate::INTERNAL_CODE,
419 format!("read {}: {e}", path.display()),
420 )
421 })?;
422 let out = memstead_base::validator::make_archive_self_contained(&bytes).map_err(|e| {
423 CliError::new(
424 ExitKind::Generic,
425 "ARCHIVE_VALIDATION_FAILED",
426 format!("self-contained re-pack of {}: {e}", path.display()),
427 )
428 })?;
429 std::fs::write(path, &out.bytes).map_err(|e| {
430 CliError::new(
431 ExitKind::Generic,
432 crate::INTERNAL_CODE,
433 format!("write {}: {e}", path.display()),
434 )
435 })?;
436 Ok(out)
437}
438
439#[cfg(feature = "mem-repo")]
440fn resolve_mem_name(
441 engine: &memstead_base::Engine,
442 explicit: Option<String>,
443) -> anyhow::Result<String> {
444 if let Some(name) = explicit {
445 return Ok(name);
446 }
447 let writable: Vec<String> = engine
448 .mem_configs_named()
449 .filter(|(name, _)| engine.mem_router().is_writable(name))
450 .map(|(name, _)| name.to_string())
451 .collect();
452
453 match writable.len() {
454 0 => Err(CliError::new(
455 ExitKind::Generic,
456 "NO_WRITABLE_MEM",
457 "no writable mem loaded — nothing to export",
458 )
459 .into()),
460 1 => Ok(writable.into_iter().next().unwrap()),
461 _ => Err(CliError::new(
462 ExitKind::Validation,
463 "AMBIGUOUS_MEM",
464 format!(
465 "multiple writable mems loaded ({}); pass --mem <name>",
466 writable.join(", ")
467 ),
468 )
469 .with_details(json!({ "mems": writable }))
470 .into()),
471 }
472}
473
474fn run_mem_filesystem(
481 ctx: &CliContext,
482 engine: &memstead_base::Engine,
483 args: Args,
484) -> anyhow::Result<()> {
485 let workspace_mem = engine
486 .mem_names()
487 .into_iter()
488 .next()
489 .map(String::from)
490 .unwrap_or_default();
491 if let Some(name) = args.mem_name.as_deref()
492 && name != workspace_mem
493 {
494 return Err(CliError::new(
495 ExitKind::NotFound,
496 "UNKNOWN_MEM",
497 format!(
498 "filesystem-mem is single-mem: workspace mem is `{workspace_mem}`, --mem `{name}` does not match"
499 ),
500 )
501 .into());
502 }
503
504 let workspace_root =
507 crate::setup::find_filesystem_workspace_root(&std::env::current_dir().map_err(|e| {
508 CliError::new(
509 ExitKind::Generic,
510 crate::INTERNAL_CODE,
511 format!("current_dir: {e}"),
512 )
513 })?)
514 .ok_or_else(|| {
515 CliError::new(
516 ExitKind::NotFound,
517 "WORKSPACE_NOT_INITIALISED",
518 "no filesystem-mem workspace found from cwd",
519 )
520 })?;
521 let bytes =
522 memstead_base::filesystem::publish::assemble_archive(&workspace_root).map_err(|e| {
523 if matches!(
527 &e,
528 memstead_base::filesystem::publish::AssembleError::Config(
529 memstead_schema::PublishConversionError::MissingVersion
530 )
531 ) {
532 CliError::from_engine_op(memstead_base::EngineError::MemConfigIncomplete {
533 mem: workspace_mem.clone(),
534 missing_fields: vec!["version".to_string()],
535 })
536 } else {
537 CliError::new(ExitKind::Generic, "ARCHIVE_ASSEMBLY_FAILED", e.to_string())
538 }
539 })?;
540
541 let output = match args.output {
542 Some(p) => p,
543 None => {
544 PathBuf::from(format!(
549 "{workspace_mem}.{}",
550 memstead_schema::ARCHIVE_EXTENSION
551 ))
552 }
553 };
554
555 std::fs::write(&output, &bytes).map_err(|e| {
556 CliError::new(
557 ExitKind::Generic,
558 crate::INTERNAL_CODE,
559 format!("write {}: {e}", output.display()),
560 )
561 })?;
562 let dropped = if args.self_contained {
563 Some(make_self_contained_on_disk(&output)?.dropped)
564 } else {
565 None
566 };
567 let size_bytes = std::fs::metadata(&output)
568 .map(|m| m.len() as usize)
569 .unwrap_or(bytes.len());
570 let entity_count = engine
574 .store()
575 .all_entities()
576 .filter(|e| !e.stub && e.id.mem() == workspace_mem)
577 .count();
578
579 if ctx.json {
580 let warnings: Vec<_> = dropped
581 .iter()
582 .flatten()
583 .map(|e| {
584 json!({
585 "code": "CROSS_MEM_EDGE_DROPPED",
586 "entity": e.entity_path,
587 "target_id": e.target_id,
588 "target_mem": e.target_mem,
589 })
590 })
591 .collect();
592 print_json(&json!({
593 "archive_path": output.to_string_lossy(),
594 "name": workspace_mem,
595 "entity_count": entity_count,
596 "size_bytes": size_bytes,
597 "self_contained": args.self_contained,
598 "warnings": warnings,
599 }))?;
600 } else {
601 let mut block = format!(
602 "# Exported `{workspace_mem}`\n\n- Archive: `{}`\n- Entities: {}\n- Size: {} bytes",
603 output.display(),
604 entity_count,
605 size_bytes,
606 );
607 if args.self_contained {
608 block.push_str("\n- Self-contained: yes");
609 let n = dropped.as_ref().map(|d| d.len()).unwrap_or(0);
610 if n > 0 {
611 block.push_str(&format!("\n- Cross-mem edges dropped: {n}"));
612 }
613 }
614 print_markdown(&block);
615 }
616 Ok(())
617}
618
619#[cfg(feature = "mem-repo")]
620fn default_output_path(
621 mem_name: &str,
622 config: &memstead_schema::MemConfig,
623) -> anyhow::Result<PathBuf> {
624 let version = config.version.as_ref().ok_or_else(|| {
625 CliError::from_engine_op(memstead_base::EngineError::MemConfigIncomplete {
632 mem: mem_name.to_string(),
633 missing_fields: vec!["version".to_string()],
634 })
635 })?;
636 let filename = format!(
639 "{mem_name}-{version}.{}",
640 memstead_schema::ARCHIVE_EXTENSION
641 );
642 Ok(PathBuf::from(filename))
643}
644
645fn run_llms_txt(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
657 let engine_holder = ctx.cli_engine()?;
658 let engine = engine_holder.base();
659 let mem = resolve_single_mem(engine, args.mem_name.as_deref())?;
660
661 let ctx_opts = memstead_base::engine::export_llms_txt::LlmsTxtContext {
662 authority: None,
663 href_prefix: args
664 .base_url
665 .clone()
666 .map(|u| u.trim_end_matches('/').to_string())
667 .unwrap_or_default(),
668 wider_project: Vec::new(),
669 };
670 let doc = engine
671 .render_llms_txt(&mem, &ctx_opts)
672 .map_err(CliError::from_engine_op)?;
673
674 match &args.output {
675 Some(path) => {
676 std::fs::write(path, &doc).map_err(|e| {
677 CliError::new(
678 ExitKind::Generic,
679 "IO_ERROR",
680 format!("write {}: {e}", path.display()),
681 )
682 })?;
683 if ctx.json {
684 print_json(&serde_json::json!({
685 "mem": mem,
686 "written": path.display().to_string(),
687 "bytes": doc.len(),
688 }))?;
689 } else {
690 println!("Wrote {} ({} bytes)", path.display(), doc.len());
691 }
692 }
693 None => print!("{doc}"),
697 }
698 Ok(())
699}
700
701fn resolve_single_mem(
705 engine: &memstead_base::Engine,
706 requested: Option<&str>,
707) -> Result<String, CliError> {
708 if let Some(m) = requested {
709 return Ok(m.to_string());
710 }
711 let writables: Vec<String> = engine
712 .writable_mem_names()
713 .iter()
714 .map(|s| s.to_string())
715 .collect();
716 match writables.as_slice() {
717 [one] => Ok(one.clone()),
718 [] => Err(CliError::new(
719 ExitKind::Validation,
720 "INVALID_INPUT",
721 "no writable mem loaded — pass --mem <name>",
722 )),
723 _ => Err(CliError::new(
724 ExitKind::Validation,
725 "INVALID_INPUT",
726 format!(
727 "multiple writable mems loaded ({}) — pass --mem <name>",
728 writables.join(", ")
729 ),
730 )),
731 }
732}
733
734fn run_html(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
740 let engine_holder = ctx.cli_engine()?;
741 let engine = engine_holder.base();
742 let mem = match &args.mem_name {
745 Some(m) => m.clone(),
746 None => {
747 let writables: Vec<String> = engine
748 .writable_mem_names()
749 .iter()
750 .map(|s| s.to_string())
751 .collect();
752 match writables.as_slice() {
753 [one] => one.clone(),
754 [] => {
755 return Err(CliError::new(
756 ExitKind::Validation,
757 "INVALID_INPUT",
758 "no writable mem loaded — pass --mem <name>",
759 )
760 .into());
761 }
762 _ => {
763 return Err(CliError::new(
764 ExitKind::Validation,
765 "INVALID_INPUT",
766 format!(
767 "multiple writable mems loaded ({}) — pass --mem <name>",
768 writables.join(", ")
769 ),
770 )
771 .into());
772 }
773 }
774 }
775 };
776 let now = time::OffsetDateTime::now_utc();
777 let export_date = format!(
778 "{:04}-{:02}-{:02}",
779 now.year(),
780 u8::from(now.month()),
781 now.day()
782 );
783 let html = engine
784 .render_html_export(&mem, &export_date)
785 .map_err(CliError::from_engine_op)?;
786 let out_path = args
787 .output
788 .clone()
789 .unwrap_or_else(|| PathBuf::from(format!("{mem}.html")));
790 std::fs::write(&out_path, &html).map_err(|e| {
791 CliError::new(
792 ExitKind::Generic,
793 "IO_ERROR",
794 format!("write {}: {e}", out_path.display()),
795 )
796 })?;
797 if ctx.json {
798 print_json(&serde_json::json!({
799 "format": "html",
800 "mem": mem,
801 "path": out_path,
802 "bytes": html.len(),
803 "exported": export_date,
804 }))?;
805 } else {
806 print_markdown(&format!(
807 "# 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",
808 out_path.display(),
809 html.len()
810 ));
811 }
812 Ok(())
813}
814
815#[cfg(test)]
816mod tests {
817 use super::*;
818 use clap::Parser;
819
820 #[test]
823 fn export_mem_selection_flag_is_mem_not_mem_name() {
824 let parsed = Args::try_parse_from(["export", "--mem", "specs", "--format", "mem"]).unwrap();
825 assert_eq!(parsed.mem_name.as_deref(), Some("specs"));
826 assert!(
827 Args::try_parse_from(["export", "--mem-name", "specs"]).is_err(),
828 "the retired --mem-name flag must not parse"
829 );
830 }
831}