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 .mounts_with_optional_config()
198 .find(|(name, _)| name == mem_name)
199 .and_then(|(_, c)| c)
200 .and_then(|c| c.schema.as_ref())
201 .map(|s| s.to_string());
202
203 let mut entities: Vec<&memstead_base::Entity> = engine
204 .store()
205 .all_entities()
206 .filter(|e| !e.stub && e.mem == *mem_name)
207 .collect();
208 entities.sort_by(|a, b| a.id.as_ref().cmp(b.id.as_ref()));
209
210 let envelopes: Vec<serde_json::Value> = entities
211 .iter()
212 .map(|entity| {
213 let body = memstead_base::render::render_entity_markdown(entity, None);
214 let tokens = memstead_base::chunking::estimate_tokens(&body);
215 let outgoing = engine.store().outgoing(&entity.id);
216 memstead_base::render::build_entity_envelope(
219 entity,
220 tokens,
221 None,
222 None,
223 None,
224 engine.mem_origin_class(entity.id.mem()),
225 outgoing,
226 None,
227 None,
228 None,
229 )
230 })
231 .collect();
232
233 let mut group = serde_json::Map::new();
234 if let Some(s) = schema_pin {
235 group.insert("schema".to_string(), json!(s));
236 }
237 group.insert(
238 "read_only".to_string(),
239 json!(!engine.mem_router().is_writable(mem_name)),
240 );
241 group.insert("entity_count".to_string(), json!(envelopes.len()));
242 group.insert("entities".to_string(), serde_json::Value::Array(envelopes));
243 mems.insert(mem_name.clone(), serde_json::Value::Object(group));
244 }
245
246 print_json(&json!({
247 "format": JSON_EXPORT_FORMAT,
248 "mems": mems,
249 }))
250}
251
252#[cfg(feature = "mem-repo")]
253fn run_markdown(
254 ctx: &CliContext,
255 engine: &memstead_base::Engine,
256 mem_filter: Option<&str>,
257) -> anyhow::Result<()> {
258 let result = engine
264 .export_markdown(mem_filter, None)
265 .map_err(CliError::from_engine_op)?;
266
267 if ctx.json {
268 let mut body = json!({
269 "written": result.written,
270 "unchanged": result.unchanged,
271 });
272 if !result.skipped_mounts.is_empty() {
273 body["skipped_mounts"] = serde_json::to_value(&result.skipped_mounts)
274 .unwrap_or_else(|_| serde_json::Value::Array(Vec::new()));
275 }
276 if !result.refused_entities.is_empty() {
277 body["refused_entities"] = serde_json::to_value(&result.refused_entities)
278 .unwrap_or_else(|_| serde_json::Value::Array(Vec::new()));
279 }
280 print_json(&body)?;
281 } else {
282 let mut block = format!(
283 "# Export — markdown\n\n- Written: {}\n- Unchanged: {}",
284 result.written, result.unchanged,
285 );
286 if !result.skipped_mounts.is_empty() {
287 block.push_str("\n\n## Skipped mounts\n");
288 for m in &result.skipped_mounts {
289 block.push_str(&format!(
290 "\n- `{}` — backend `{}` ({}); use `--format mem` for archive export",
291 m.mem, m.active_backend, m.reason,
292 ));
293 }
294 }
295 if !result.refused_entities.is_empty() {
299 block.push_str("\n\n## Refused entities\n");
300 for r in &result.refused_entities {
301 block.push_str(&format!("\n- `{}` [{}] — {}", r.id, r.reason, r.detail));
302 }
303 }
304 print_markdown(&block);
305 }
306 Ok(())
307}
308
309#[cfg(feature = "mem-repo")]
310fn run_mem(ctx: &CliContext, engine: &memstead_base::Engine, args: Args) -> anyhow::Result<()> {
311 let mem_name = resolve_mem_name(engine, args.mem_name)?;
312 let config = engine
318 .mem_configs_named()
319 .find(|(name, _)| *name == mem_name)
320 .map(|(_, c)| c)
321 .ok_or_else(|| {
322 CliError::new(
323 ExitKind::NotFound,
324 "UNKNOWN_MEM",
325 format!("mem config not found for '{mem_name}'"),
326 )
327 })?;
328
329 let output = match args.output {
330 Some(p) => p,
331 None => default_output_path(&mem_name, config)?,
332 };
333
334 let mut result = engine
335 .export_mem(&mem_name, &output)
336 .map_err(CliError::from_engine_op)?;
337
338 let dropped = if args.self_contained {
344 let self_contained = make_self_contained_on_disk(&output)?;
345 result.size_bytes = self_contained.bytes.len() as u64;
346 result.dangling_cross_mem_edges.clear();
347 Some(self_contained.dropped)
348 } else {
349 None
350 };
351
352 let dangling = &result.dangling_cross_mem_edges;
357
358 if ctx.json {
359 let mut warnings: Vec<_> = dangling
360 .iter()
361 .map(|e| {
362 json!({
363 "code": "DANGLING_CROSS_MEM_EDGE_IN_EXPORT",
364 "entity": e.entity_path,
365 "target_id": e.target_id,
366 "target_mem": e.target_mem,
367 })
368 })
369 .collect();
370 if let Some(dropped) = &dropped {
371 warnings.extend(dropped.iter().map(|e| {
372 json!({
373 "code": "CROSS_MEM_EDGE_DROPPED",
374 "entity": e.entity_path,
375 "target_id": e.target_id,
376 "target_mem": e.target_mem,
377 })
378 }));
379 }
380 warnings.extend(result.unterminated_fence_entities.iter().map(|id| {
381 json!({
382 "code": "UNTERMINATED_FENCE_IN_EXPORT",
383 "entity": id,
384 })
385 }));
386 print_json(&json!({
387 "archive_path": result.archive_path,
388 "name": result.name,
389 "version": result.version,
390 "entity_count": result.entity_count,
391 "size_bytes": result.size_bytes,
392 "self_contained": args.self_contained,
393 "warnings": warnings,
394 }))?;
395 } else {
396 let mut block = format!(
397 "# Exported `{}` v{}\n\n- Archive: `{}`\n- Entities: {}\n- Size: {} bytes",
398 result.name,
399 result.version,
400 result.archive_path,
401 result.entity_count,
402 result.size_bytes,
403 );
404 if args.self_contained {
405 block.push_str("\n- Self-contained: yes");
406 }
407 if !result.unterminated_fence_entities.is_empty() {
410 block.push_str(
411 "\n\n## Entities `install` will refuse\n\nEach ends a section inside an \
412 unterminated code fence, which absorbed the sections after it. Repair through \
413 the engine (replace the absorbing section) and re-export.\n",
414 );
415 for id in &result.unterminated_fence_entities {
416 block.push_str(&format!("\n- `{id}` [UNTERMINATED_FENCE_IN_EXPORT]"));
417 }
418 }
419 if !dangling.is_empty() {
420 block.push_str("\n\n## Warnings\n");
421 for e in dangling {
422 block.push_str(&format!(
423 "\n- **DANGLING_CROSS_MEM_EDGE_IN_EXPORT**: `{}` → `{}` (mem `{}`) — \
424 target lives outside this archive; `memstead install` will reject it unless \
425 mem `{}` is also present. Re-export with `--self-contained` to drop such \
426 rows (each reported; body wiki-link prose survives).",
427 e.entity_path, e.target_id, e.target_mem, e.target_mem,
428 ));
429 }
430 }
431 if let Some(dropped) = &dropped
432 && !dropped.is_empty()
433 {
434 block.push_str("\n\n## Dropped cross-mem edges\n");
435 for e in dropped {
436 block.push_str(&format!(
437 "\n- **CROSS_MEM_EDGE_DROPPED**: `{}` → `{}` (mem `{}`): the relationship \
438 row does not travel; a body wiki-link to the same target still does.",
439 e.entity_path, e.target_id, e.target_mem,
440 ));
441 }
442 }
443 print_markdown(&block);
444 }
445 Ok(())
446}
447
448fn make_self_contained_on_disk(
451 path: &std::path::Path,
452) -> anyhow::Result<memstead_base::validator::SelfContainedArchive> {
453 let bytes = std::fs::read(path).map_err(|e| {
454 CliError::new(
455 ExitKind::Generic,
456 crate::INTERNAL_CODE,
457 format!("read {}: {e}", path.display()),
458 )
459 })?;
460 let out = memstead_base::validator::make_archive_self_contained(&bytes).map_err(|e| {
461 CliError::new(
462 ExitKind::Generic,
463 "ARCHIVE_VALIDATION_FAILED",
464 format!("self-contained re-pack of {}: {e}", path.display()),
465 )
466 })?;
467 std::fs::write(path, &out.bytes).map_err(|e| {
468 CliError::new(
469 ExitKind::Generic,
470 crate::INTERNAL_CODE,
471 format!("write {}: {e}", path.display()),
472 )
473 })?;
474 Ok(out)
475}
476
477#[cfg(feature = "mem-repo")]
478fn resolve_mem_name(
479 engine: &memstead_base::Engine,
480 explicit: Option<String>,
481) -> anyhow::Result<String> {
482 if let Some(name) = explicit {
483 return Ok(name);
484 }
485 let writable: Vec<String> = engine
489 .mounts_with_optional_config()
490 .filter(|(name, _)| engine.mem_router().is_writable(name))
491 .map(|(name, _)| name.to_string())
492 .collect();
493
494 match writable.len() {
495 0 => Err(CliError::new(
496 ExitKind::Generic,
497 "NO_WRITABLE_MEM",
498 "no writable mem loaded — nothing to export",
499 )
500 .into()),
501 1 => Ok(writable.into_iter().next().unwrap()),
502 _ => Err(CliError::new(
503 ExitKind::Validation,
504 "AMBIGUOUS_MEM",
505 format!(
506 "multiple writable mems loaded ({}); pass --mem <name>",
507 writable.join(", ")
508 ),
509 )
510 .with_details(json!({ "mems": writable }))
511 .into()),
512 }
513}
514
515fn run_mem_filesystem(
522 ctx: &CliContext,
523 engine: &memstead_base::Engine,
524 args: Args,
525) -> anyhow::Result<()> {
526 let workspace_mem = engine
527 .mem_names()
528 .into_iter()
529 .next()
530 .map(String::from)
531 .unwrap_or_default();
532 if let Some(name) = args.mem_name.as_deref()
533 && name != workspace_mem
534 {
535 return Err(CliError::new(
536 ExitKind::NotFound,
537 "UNKNOWN_MEM",
538 format!(
539 "filesystem-mem is single-mem: workspace mem is `{workspace_mem}`, --mem `{name}` does not match"
540 ),
541 )
542 .into());
543 }
544
545 let bytes = engine
557 .export_mem_to_bytes(&workspace_mem)
558 .map_err(CliError::from_engine_op)?;
559
560 let output = match args.output {
561 Some(p) => p,
562 None => {
563 PathBuf::from(format!(
568 "{workspace_mem}.{}",
569 memstead_schema::ARCHIVE_EXTENSION
570 ))
571 }
572 };
573
574 std::fs::write(&output, &bytes).map_err(|e| {
575 CliError::new(
576 ExitKind::Generic,
577 crate::INTERNAL_CODE,
578 format!("write {}: {e}", output.display()),
579 )
580 })?;
581 let dropped = if args.self_contained {
582 Some(make_self_contained_on_disk(&output)?.dropped)
583 } else {
584 None
585 };
586 let size_bytes = std::fs::metadata(&output)
587 .map(|m| m.len() as usize)
588 .unwrap_or(bytes.len());
589 let entity_count = engine
593 .store()
594 .all_entities()
595 .filter(|e| !e.stub && e.id.mem() == workspace_mem)
596 .count();
597
598 if ctx.json {
599 let warnings: Vec<_> = dropped
600 .iter()
601 .flatten()
602 .map(|e| {
603 json!({
604 "code": "CROSS_MEM_EDGE_DROPPED",
605 "entity": e.entity_path,
606 "target_id": e.target_id,
607 "target_mem": e.target_mem,
608 })
609 })
610 .collect();
611 print_json(&json!({
612 "archive_path": output.to_string_lossy(),
613 "name": workspace_mem,
614 "entity_count": entity_count,
615 "size_bytes": size_bytes,
616 "self_contained": args.self_contained,
617 "warnings": warnings,
618 }))?;
619 } else {
620 let mut block = format!(
621 "# Exported `{workspace_mem}`\n\n- Archive: `{}`\n- Entities: {}\n- Size: {} bytes",
622 output.display(),
623 entity_count,
624 size_bytes,
625 );
626 if args.self_contained {
627 block.push_str("\n- Self-contained: yes");
628 let n = dropped.as_ref().map(|d| d.len()).unwrap_or(0);
629 if n > 0 {
630 block.push_str(&format!("\n- Cross-mem edges dropped: {n}"));
631 }
632 }
633 print_markdown(&block);
634 }
635 Ok(())
636}
637
638#[cfg(feature = "mem-repo")]
639fn default_output_path(
640 mem_name: &str,
641 config: &memstead_schema::MemConfig,
642) -> anyhow::Result<PathBuf> {
643 let version = config.version.as_ref().ok_or_else(|| {
644 CliError::from_engine_op(memstead_base::EngineError::MemConfigIncomplete {
651 mem: mem_name.to_string(),
652 missing_fields: vec!["version".to_string()],
653 })
654 })?;
655 let filename = format!(
658 "{mem_name}-{version}.{}",
659 memstead_schema::ARCHIVE_EXTENSION
660 );
661 Ok(PathBuf::from(filename))
662}
663
664fn run_llms_txt(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
676 let engine_holder = ctx.cli_engine()?;
677 let engine = engine_holder.base();
678 let mem = resolve_single_mem(engine, args.mem_name.as_deref())?;
679
680 let ctx_opts = memstead_base::engine::export_llms_txt::LlmsTxtContext {
681 authority: None,
682 href_prefix: args
683 .base_url
684 .clone()
685 .map(|u| u.trim_end_matches('/').to_string())
686 .unwrap_or_default(),
687 wider_project: Vec::new(),
688 };
689 let doc = engine
690 .render_llms_txt(&mem, &ctx_opts)
691 .map_err(CliError::from_engine_op)?;
692
693 match &args.output {
694 Some(path) => {
695 std::fs::write(path, &doc).map_err(|e| {
696 CliError::new(
697 ExitKind::Generic,
698 "IO_ERROR",
699 format!("write {}: {e}", path.display()),
700 )
701 })?;
702 if ctx.json {
703 print_json(&serde_json::json!({
704 "mem": mem,
705 "written": path.display().to_string(),
706 "bytes": doc.len(),
707 }))?;
708 } else {
709 println!("Wrote {} ({} bytes)", path.display(), doc.len());
710 }
711 }
712 None => print!("{doc}"),
716 }
717 Ok(())
718}
719
720fn resolve_single_mem(
724 engine: &memstead_base::Engine,
725 requested: Option<&str>,
726) -> Result<String, CliError> {
727 if let Some(m) = requested {
728 return Ok(m.to_string());
729 }
730 let writables: Vec<String> = engine
731 .writable_mem_names()
732 .iter()
733 .map(|s| s.to_string())
734 .collect();
735 match writables.as_slice() {
736 [one] => Ok(one.clone()),
737 [] => Err(CliError::new(
738 ExitKind::Validation,
739 "INVALID_INPUT",
740 "no writable mem loaded — pass --mem <name>",
741 )),
742 _ => Err(CliError::new(
743 ExitKind::Validation,
744 "INVALID_INPUT",
745 format!(
746 "multiple writable mems loaded ({}) — pass --mem <name>",
747 writables.join(", ")
748 ),
749 )),
750 }
751}
752
753fn run_html(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
759 let engine_holder = ctx.cli_engine()?;
760 let engine = engine_holder.base();
761 let mem = match &args.mem_name {
764 Some(m) => m.clone(),
765 None => {
766 let writables: Vec<String> = engine
767 .writable_mem_names()
768 .iter()
769 .map(|s| s.to_string())
770 .collect();
771 match writables.as_slice() {
772 [one] => one.clone(),
773 [] => {
774 return Err(CliError::new(
775 ExitKind::Validation,
776 "INVALID_INPUT",
777 "no writable mem loaded — pass --mem <name>",
778 )
779 .into());
780 }
781 _ => {
782 return Err(CliError::new(
783 ExitKind::Validation,
784 "INVALID_INPUT",
785 format!(
786 "multiple writable mems loaded ({}) — pass --mem <name>",
787 writables.join(", ")
788 ),
789 )
790 .into());
791 }
792 }
793 }
794 };
795 let now = time::OffsetDateTime::now_utc();
796 let export_date = format!(
797 "{:04}-{:02}-{:02}",
798 now.year(),
799 u8::from(now.month()),
800 now.day()
801 );
802 let html = engine
803 .render_html_export(&mem, &export_date)
804 .map_err(CliError::from_engine_op)?;
805 let out_path = args
806 .output
807 .clone()
808 .unwrap_or_else(|| PathBuf::from(format!("{mem}.html")));
809 std::fs::write(&out_path, &html).map_err(|e| {
810 CliError::new(
811 ExitKind::Generic,
812 "IO_ERROR",
813 format!("write {}: {e}", out_path.display()),
814 )
815 })?;
816 if ctx.json {
817 print_json(&serde_json::json!({
818 "format": "html",
819 "mem": mem,
820 "path": out_path,
821 "bytes": html.len(),
822 "exported": export_date,
823 }))?;
824 } else {
825 print_markdown(&format!(
826 "# 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",
827 out_path.display(),
828 html.len()
829 ));
830 }
831 Ok(())
832}
833
834#[cfg(test)]
835mod tests {
836 use super::*;
837 use clap::Parser;
838
839 #[test]
842 fn export_mem_selection_flag_is_mem_not_mem_name() {
843 let parsed = Args::try_parse_from(["export", "--mem", "specs", "--format", "mem"]).unwrap();
844 assert_eq!(parsed.mem_name.as_deref(), Some("specs"));
845 assert!(
846 Args::try_parse_from(["export", "--mem-name", "specs"]).is_err(),
847 "the retired --mem-name flag must not parse"
848 );
849 }
850}