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 #[arg(long, value_delimiter = ',', value_name = "KEY")]
87 pub include: Vec<String>,
88}
89
90#[derive(ValueEnum, Clone, Copy, Debug)]
91pub enum Format {
92 Markdown,
94 Mem,
96 Json,
98 Html,
101 LlmsTxt,
105}
106
107pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
108 if !args.include.is_empty() && !matches!(args.format, Format::Json) {
109 return Err(CliError::new(
110 ExitKind::Validation,
111 "INVALID_INPUT",
112 "--include applies only to --format json",
113 )
114 .into());
115 }
116 if matches!(args.format, Format::Json) {
117 return run_json(ctx, args);
118 }
119 if matches!(args.format, Format::Html) {
120 return run_html(ctx, args);
121 }
122 if matches!(args.format, Format::LlmsTxt) {
123 return run_llms_txt(ctx, args);
124 }
125 match ctx.cli_engine()? {
126 #[cfg(feature = "mem-repo")]
127 CliEngine::MemRepo(engine) => match args.format {
128 Format::Markdown => run_markdown(ctx, &engine, args.mem_name.as_deref()),
129 Format::Mem => run_mem(ctx, &engine, args),
130 Format::Json => unreachable!("dispatched to run_json above"),
131 Format::Html => unreachable!("dispatched to run_html above"),
132 Format::LlmsTxt => unreachable!("dispatched to run_llms_txt above"),
133 },
134 CliEngine::Filesystem(engine) => match args.format {
135 Format::Markdown => Err(CliError::new(
141 ExitKind::Validation,
142 "INVALID_INPUT",
143 "--format markdown is not yet supported on filesystem-mem `memstead export` — entities are already on disk in their canonical form",
144 )
145 .into()),
146 Format::Mem => run_mem_filesystem(ctx, &engine, args),
147 Format::Json => unreachable!("dispatched to run_json above"),
148 Format::Html => unreachable!("dispatched to run_html above"),
149 Format::LlmsTxt => unreachable!("dispatched to run_llms_txt above"),
150 },
151 }
152}
153
154const JSON_EXPORT_FORMAT: &str = "memstead-export/v1";
158
159fn run_json(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
168 if args.output.is_some() {
172 return Err(CliError::new(
173 ExitKind::Validation,
174 "INVALID_INPUT",
175 "--output applies only to --format mem — the JSON document goes to stdout; redirect it instead",
176 )
177 .into());
178 }
179
180 const JSON_INCLUDE_KEYS: &[&str] = &["anchors"];
184 for key in &args.include {
185 if !JSON_INCLUDE_KEYS.contains(&key.as_str()) {
186 return Err(CliError::new(
187 ExitKind::Validation,
188 "INVALID_INPUT",
189 format!(
190 "unknown --include key {key:?} — allowed: {}",
191 JSON_INCLUDE_KEYS.join(", ")
192 ),
193 )
194 .into());
195 }
196 }
197 let include_anchors = args.include.iter().any(|k| k == "anchors");
198
199 let cli_engine = ctx.cli_engine()?;
200 let engine = cli_engine.base();
201
202 let all_names: Vec<String> = engine.mem_names().into_iter().map(String::from).collect();
203 let selected: Vec<String> = match &args.mem_name {
207 Some(name) => {
208 if !all_names.iter().any(|n| n == name) {
209 return Err(CliError::new(
210 ExitKind::NotFound,
211 "UNKNOWN_MEM",
212 format!(
213 "unknown mem '{name}' — loaded mems: {}",
214 all_names.join(", ")
215 ),
216 )
217 .with_details(json!({ "mem": name, "loaded": all_names }))
218 .into());
219 }
220 vec![name.clone()]
221 }
222 None => all_names
223 .iter()
224 .filter(|n| engine.mem_router().is_writable(n))
225 .cloned()
226 .collect(),
227 };
228
229 let mut mems = serde_json::Map::new();
230 for mem_name in &selected {
231 let schema_pin = engine
234 .mounts_with_optional_config()
235 .find(|(name, _)| name == mem_name)
236 .and_then(|(_, c)| c)
237 .and_then(|c| c.schema.as_ref())
238 .map(|s| s.to_string());
239
240 let mut entities: Vec<&memstead_base::Entity> = engine
241 .store()
242 .all_entities()
243 .filter(|e| !e.stub && e.mem == *mem_name)
244 .collect();
245 entities.sort_by(|a, b| a.id.as_ref().cmp(b.id.as_ref()));
246
247 let envelopes: Vec<serde_json::Value> = entities
248 .iter()
249 .map(|entity| {
250 let body = memstead_base::render::render_entity_markdown(entity, None);
251 let tokens = memstead_base::chunking::estimate_tokens(&body);
252 let outgoing = engine.store().outgoing(&entity.id);
253 let mut envelope = memstead_base::render::build_entity_envelope(
256 entity,
257 tokens,
258 None,
259 None,
260 None,
261 engine.mem_origin_class(entity.id.mem()),
262 outgoing,
263 None,
264 None,
265 None,
266 );
267 if include_anchors && let Some(obj) = envelope.as_object_mut() {
273 let anchors = engine.entity_anchors(&entity.id);
274 obj.insert(
275 "anchors".to_string(),
276 serde_json::to_value(&anchors).unwrap_or(serde_json::Value::Null),
277 );
278 }
279 envelope
280 })
281 .collect();
282
283 let mut group = serde_json::Map::new();
284 if let Some(s) = schema_pin {
285 group.insert("schema".to_string(), json!(s));
286 }
287 group.insert(
288 "read_only".to_string(),
289 json!(!engine.mem_router().is_writable(mem_name)),
290 );
291 group.insert("entity_count".to_string(), json!(envelopes.len()));
292 group.insert("entities".to_string(), serde_json::Value::Array(envelopes));
293 mems.insert(mem_name.clone(), serde_json::Value::Object(group));
294 }
295
296 print_json(&json!({
297 "format": JSON_EXPORT_FORMAT,
298 "mems": mems,
299 }))
300}
301
302#[cfg(feature = "mem-repo")]
303fn run_markdown(
304 ctx: &CliContext,
305 engine: &memstead_base::Engine,
306 mem_filter: Option<&str>,
307) -> anyhow::Result<()> {
308 let result = engine
314 .export_markdown(mem_filter, None)
315 .map_err(CliError::from_engine_op)?;
316
317 if ctx.json {
318 let mut body = json!({
319 "written": result.written,
320 "unchanged": result.unchanged,
321 });
322 if !result.skipped_mounts.is_empty() {
323 body["skipped_mounts"] = serde_json::to_value(&result.skipped_mounts)
324 .unwrap_or_else(|_| serde_json::Value::Array(Vec::new()));
325 }
326 if !result.refused_entities.is_empty() {
327 body["refused_entities"] = serde_json::to_value(&result.refused_entities)
328 .unwrap_or_else(|_| serde_json::Value::Array(Vec::new()));
329 }
330 print_json(&body)?;
331 } else {
332 let mut block = format!(
333 "# Export — markdown\n\n- Written: {}\n- Unchanged: {}",
334 result.written, result.unchanged,
335 );
336 if !result.skipped_mounts.is_empty() {
337 block.push_str("\n\n## Skipped mounts\n");
338 for m in &result.skipped_mounts {
339 block.push_str(&format!(
340 "\n- `{}` — backend `{}` ({}); use `--format mem` for archive export",
341 m.mem, m.active_backend, m.reason,
342 ));
343 }
344 }
345 if !result.refused_entities.is_empty() {
349 block.push_str("\n\n## Refused entities\n");
350 for r in &result.refused_entities {
351 block.push_str(&format!("\n- `{}` [{}] — {}", r.id, r.reason, r.detail));
352 }
353 }
354 print_markdown(&block);
355 }
356 Ok(())
357}
358
359#[cfg(feature = "mem-repo")]
360fn run_mem(ctx: &CliContext, engine: &memstead_base::Engine, args: Args) -> anyhow::Result<()> {
361 let mem_name = resolve_mem_name(engine, args.mem_name)?;
362 let config = engine
368 .mem_configs_named()
369 .find(|(name, _)| *name == mem_name)
370 .map(|(_, c)| c)
371 .ok_or_else(|| {
372 CliError::new(
373 ExitKind::NotFound,
374 "UNKNOWN_MEM",
375 format!("mem config not found for '{mem_name}'"),
376 )
377 })?;
378
379 let output = match args.output {
380 Some(p) => p,
381 None => default_output_path(&mem_name, config)?,
382 };
383
384 let mut result = engine
385 .export_mem(&mem_name, &output)
386 .map_err(CliError::from_engine_op)?;
387
388 let dropped = if args.self_contained {
394 let self_contained = make_self_contained_on_disk(&output)?;
395 result.size_bytes = self_contained.bytes.len() as u64;
396 result.dangling_cross_mem_edges.clear();
397 Some(self_contained.dropped)
398 } else {
399 None
400 };
401
402 let dangling = &result.dangling_cross_mem_edges;
407
408 if ctx.json {
409 let mut warnings: Vec<_> = dangling
410 .iter()
411 .map(|e| {
412 json!({
413 "code": "DANGLING_CROSS_MEM_EDGE_IN_EXPORT",
414 "entity": e.entity_path,
415 "target_id": e.target_id,
416 "target_mem": e.target_mem,
417 })
418 })
419 .collect();
420 if let Some(dropped) = &dropped {
421 warnings.extend(dropped.iter().map(|e| {
422 json!({
423 "code": "CROSS_MEM_EDGE_DROPPED",
424 "entity": e.entity_path,
425 "target_id": e.target_id,
426 "target_mem": e.target_mem,
427 })
428 }));
429 }
430 warnings.extend(result.unterminated_fence_entities.iter().map(|id| {
431 json!({
432 "code": "UNTERMINATED_FENCE_IN_EXPORT",
433 "entity": id,
434 })
435 }));
436 print_json(&json!({
437 "archive_path": result.archive_path,
438 "name": result.name,
439 "version": result.version,
440 "entity_count": result.entity_count,
441 "size_bytes": result.size_bytes,
442 "self_contained": args.self_contained,
443 "warnings": warnings,
444 }))?;
445 } else {
446 let mut block = format!(
447 "# Exported `{}` v{}\n\n- Archive: `{}`\n- Entities: {}\n- Size: {} bytes",
448 result.name,
449 result.version,
450 result.archive_path,
451 result.entity_count,
452 result.size_bytes,
453 );
454 if args.self_contained {
455 block.push_str("\n- Self-contained: yes");
456 }
457 if !result.unterminated_fence_entities.is_empty() {
460 block.push_str(
461 "\n\n## Entities `install` will refuse\n\nEach ends a section inside an \
462 unterminated code fence, which absorbed the sections after it. Repair through \
463 the engine (replace the absorbing section) and re-export.\n",
464 );
465 for id in &result.unterminated_fence_entities {
466 block.push_str(&format!("\n- `{id}` [UNTERMINATED_FENCE_IN_EXPORT]"));
467 }
468 }
469 if !dangling.is_empty() {
470 block.push_str("\n\n## Warnings\n");
471 for e in dangling {
472 block.push_str(&format!(
473 "\n- **DANGLING_CROSS_MEM_EDGE_IN_EXPORT**: `{}` → `{}` (mem `{}`) — \
474 target lives outside this archive; `memstead install` will reject it unless \
475 mem `{}` is also present. Re-export with `--self-contained` to drop such \
476 rows (each reported; body wiki-link prose survives).",
477 e.entity_path, e.target_id, e.target_mem, e.target_mem,
478 ));
479 }
480 }
481 if let Some(dropped) = &dropped
482 && !dropped.is_empty()
483 {
484 block.push_str("\n\n## Dropped cross-mem edges\n");
485 for e in dropped {
486 block.push_str(&format!(
487 "\n- **CROSS_MEM_EDGE_DROPPED**: `{}` → `{}` (mem `{}`): the relationship \
488 row does not travel; a body wiki-link to the same target still does.",
489 e.entity_path, e.target_id, e.target_mem,
490 ));
491 }
492 }
493 print_markdown(&block);
494 }
495 Ok(())
496}
497
498fn make_self_contained_on_disk(
501 path: &std::path::Path,
502) -> anyhow::Result<memstead_base::validator::SelfContainedArchive> {
503 let bytes = std::fs::read(path).map_err(|e| {
504 CliError::new(
505 ExitKind::Generic,
506 crate::INTERNAL_CODE,
507 format!("read {}: {e}", path.display()),
508 )
509 })?;
510 let out = memstead_base::validator::make_archive_self_contained(&bytes).map_err(|e| {
511 CliError::new(
512 ExitKind::Generic,
513 "ARCHIVE_VALIDATION_FAILED",
514 format!("self-contained re-pack of {}: {e}", path.display()),
515 )
516 })?;
517 std::fs::write(path, &out.bytes).map_err(|e| {
518 CliError::new(
519 ExitKind::Generic,
520 crate::INTERNAL_CODE,
521 format!("write {}: {e}", path.display()),
522 )
523 })?;
524 Ok(out)
525}
526
527#[cfg(feature = "mem-repo")]
528fn resolve_mem_name(
529 engine: &memstead_base::Engine,
530 explicit: Option<String>,
531) -> anyhow::Result<String> {
532 if let Some(name) = explicit {
533 return Ok(name);
534 }
535 let writable: Vec<String> = engine
539 .mounts_with_optional_config()
540 .filter(|(name, _)| engine.mem_router().is_writable(name))
541 .map(|(name, _)| name.to_string())
542 .collect();
543
544 match writable.len() {
545 0 => Err(CliError::new(
546 ExitKind::Generic,
547 "NO_WRITABLE_MEM",
548 "no writable mem loaded — nothing to export",
549 )
550 .into()),
551 1 => Ok(writable.into_iter().next().unwrap()),
552 _ => Err(CliError::new(
553 ExitKind::Validation,
554 "AMBIGUOUS_MEM",
555 format!(
556 "multiple writable mems loaded ({}); pass --mem <name>",
557 writable.join(", ")
558 ),
559 )
560 .with_details(json!({ "mems": writable }))
561 .into()),
562 }
563}
564
565fn run_mem_filesystem(
572 ctx: &CliContext,
573 engine: &memstead_base::Engine,
574 args: Args,
575) -> anyhow::Result<()> {
576 let workspace_mem = engine
577 .mem_names()
578 .into_iter()
579 .next()
580 .map(String::from)
581 .unwrap_or_default();
582 if let Some(name) = args.mem_name.as_deref()
583 && name != workspace_mem
584 {
585 return Err(CliError::new(
586 ExitKind::NotFound,
587 "UNKNOWN_MEM",
588 format!(
589 "filesystem-mem is single-mem: workspace mem is `{workspace_mem}`, --mem `{name}` does not match"
590 ),
591 )
592 .into());
593 }
594
595 let bytes = engine
607 .export_mem_to_bytes(&workspace_mem)
608 .map_err(CliError::from_engine_op)?;
609
610 let output = match args.output {
611 Some(p) => p,
612 None => {
613 PathBuf::from(format!(
618 "{workspace_mem}.{}",
619 memstead_schema::ARCHIVE_EXTENSION
620 ))
621 }
622 };
623
624 std::fs::write(&output, &bytes).map_err(|e| {
625 CliError::new(
626 ExitKind::Generic,
627 crate::INTERNAL_CODE,
628 format!("write {}: {e}", output.display()),
629 )
630 })?;
631 let dropped = if args.self_contained {
632 Some(make_self_contained_on_disk(&output)?.dropped)
633 } else {
634 None
635 };
636 let size_bytes = std::fs::metadata(&output)
637 .map(|m| m.len() as usize)
638 .unwrap_or(bytes.len());
639 let entity_count = engine
643 .store()
644 .all_entities()
645 .filter(|e| !e.stub && e.id.mem() == workspace_mem)
646 .count();
647
648 if ctx.json {
649 let warnings: Vec<_> = dropped
650 .iter()
651 .flatten()
652 .map(|e| {
653 json!({
654 "code": "CROSS_MEM_EDGE_DROPPED",
655 "entity": e.entity_path,
656 "target_id": e.target_id,
657 "target_mem": e.target_mem,
658 })
659 })
660 .collect();
661 print_json(&json!({
662 "archive_path": output.to_string_lossy(),
663 "name": workspace_mem,
664 "entity_count": entity_count,
665 "size_bytes": size_bytes,
666 "self_contained": args.self_contained,
667 "warnings": warnings,
668 }))?;
669 } else {
670 let mut block = format!(
671 "# Exported `{workspace_mem}`\n\n- Archive: `{}`\n- Entities: {}\n- Size: {} bytes",
672 output.display(),
673 entity_count,
674 size_bytes,
675 );
676 if args.self_contained {
677 block.push_str("\n- Self-contained: yes");
678 let n = dropped.as_ref().map(|d| d.len()).unwrap_or(0);
679 if n > 0 {
680 block.push_str(&format!("\n- Cross-mem edges dropped: {n}"));
681 }
682 }
683 print_markdown(&block);
684 }
685 Ok(())
686}
687
688#[cfg(feature = "mem-repo")]
689fn default_output_path(
690 mem_name: &str,
691 config: &memstead_schema::MemConfig,
692) -> anyhow::Result<PathBuf> {
693 let version = config.version.as_ref().ok_or_else(|| {
694 CliError::from_engine_op(memstead_base::EngineError::MemConfigIncomplete {
701 mem: mem_name.to_string(),
702 missing_fields: vec!["version".to_string()],
703 })
704 })?;
705 let filename = format!(
708 "{mem_name}-{version}.{}",
709 memstead_schema::ARCHIVE_EXTENSION
710 );
711 Ok(PathBuf::from(filename))
712}
713
714fn run_llms_txt(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
726 let engine_holder = ctx.cli_engine()?;
727 let engine = engine_holder.base();
728 let mem = resolve_single_mem(engine, args.mem_name.as_deref())?;
729
730 let ctx_opts = memstead_base::engine::export_llms_txt::LlmsTxtContext {
731 authority: None,
732 href_prefix: args
733 .base_url
734 .clone()
735 .map(|u| u.trim_end_matches('/').to_string())
736 .unwrap_or_default(),
737 wider_project: Vec::new(),
738 };
739 let doc = engine
740 .render_llms_txt(&mem, &ctx_opts)
741 .map_err(CliError::from_engine_op)?;
742
743 match &args.output {
744 Some(path) => {
745 std::fs::write(path, &doc).map_err(|e| {
746 CliError::new(
747 ExitKind::Generic,
748 "IO_ERROR",
749 format!("write {}: {e}", path.display()),
750 )
751 })?;
752 if ctx.json {
753 print_json(&serde_json::json!({
754 "mem": mem,
755 "written": path.display().to_string(),
756 "bytes": doc.len(),
757 }))?;
758 } else {
759 println!("Wrote {} ({} bytes)", path.display(), doc.len());
760 }
761 }
762 None => print!("{doc}"),
766 }
767 Ok(())
768}
769
770fn resolve_single_mem(
774 engine: &memstead_base::Engine,
775 requested: Option<&str>,
776) -> Result<String, CliError> {
777 if let Some(m) = requested {
778 return Ok(m.to_string());
779 }
780 let writables: Vec<String> = engine
781 .writable_mem_names()
782 .iter()
783 .map(|s| s.to_string())
784 .collect();
785 match writables.as_slice() {
786 [one] => Ok(one.clone()),
787 [] => Err(CliError::new(
788 ExitKind::Validation,
789 "INVALID_INPUT",
790 "no writable mem loaded — pass --mem <name>",
791 )),
792 _ => Err(CliError::new(
793 ExitKind::Validation,
794 "INVALID_INPUT",
795 format!(
796 "multiple writable mems loaded ({}) — pass --mem <name>",
797 writables.join(", ")
798 ),
799 )),
800 }
801}
802
803fn run_html(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
809 let engine_holder = ctx.cli_engine()?;
810 let engine = engine_holder.base();
811 let mem = match &args.mem_name {
814 Some(m) => m.clone(),
815 None => {
816 let writables: Vec<String> = engine
817 .writable_mem_names()
818 .iter()
819 .map(|s| s.to_string())
820 .collect();
821 match writables.as_slice() {
822 [one] => one.clone(),
823 [] => {
824 return Err(CliError::new(
825 ExitKind::Validation,
826 "INVALID_INPUT",
827 "no writable mem loaded — pass --mem <name>",
828 )
829 .into());
830 }
831 _ => {
832 return Err(CliError::new(
833 ExitKind::Validation,
834 "INVALID_INPUT",
835 format!(
836 "multiple writable mems loaded ({}) — pass --mem <name>",
837 writables.join(", ")
838 ),
839 )
840 .into());
841 }
842 }
843 }
844 };
845 let now = time::OffsetDateTime::now_utc();
846 let export_date = format!(
847 "{:04}-{:02}-{:02}",
848 now.year(),
849 u8::from(now.month()),
850 now.day()
851 );
852 let html = engine
853 .render_html_export(&mem, &export_date)
854 .map_err(CliError::from_engine_op)?;
855 let out_path = args
856 .output
857 .clone()
858 .unwrap_or_else(|| PathBuf::from(format!("{mem}.html")));
859 std::fs::write(&out_path, &html).map_err(|e| {
860 CliError::new(
861 ExitKind::Generic,
862 "IO_ERROR",
863 format!("write {}: {e}", out_path.display()),
864 )
865 })?;
866 if ctx.json {
867 print_json(&serde_json::json!({
868 "format": "html",
869 "mem": mem,
870 "path": out_path,
871 "bytes": html.len(),
872 "exported": export_date,
873 }))?;
874 } else {
875 print_markdown(&format!(
876 "# 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",
877 out_path.display(),
878 html.len()
879 ));
880 }
881 Ok(())
882}
883
884#[cfg(test)]
885mod tests {
886 use super::*;
887 use clap::Parser;
888
889 #[test]
892 fn export_mem_selection_flag_is_mem_not_mem_name() {
893 let parsed = Args::try_parse_from(["export", "--mem", "specs", "--format", "mem"]).unwrap();
894 assert_eq!(parsed.mem_name.as_deref(), Some("specs"));
895 assert!(
896 Args::try_parse_from(["export", "--mem-name", "specs"]).is_err(),
897 "the retired --mem-name flag must not parse"
898 );
899 }
900}