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)]
43 pub format: Format,
44
45 #[arg(long, short = 'o', value_name = "PATH")]
51 pub output: Option<PathBuf>,
52
53 #[arg(long = "mem", value_name = "NAME")]
62 pub mem_name: Option<String>,
63
64 #[arg(long, value_name = "ID")]
73 pub root: Option<String>,
74
75 #[arg(long, value_name = "REL", value_delimiter = ',')]
80 pub via: Vec<String>,
81
82 #[arg(long, value_enum, default_value_t = ChainDirection::Out)]
88 pub direction: ChainDirection,
89
90 #[arg(long, value_name = "N")]
93 pub depth: Option<usize>,
94
95 #[arg(long)]
105 pub self_contained: bool,
106
107 #[arg(long = "base-url", value_name = "URL")]
113 pub base_url: Option<String>,
114
115 #[arg(long, value_delimiter = ',', value_name = "KEY")]
123 pub include: Vec<String>,
124}
125
126#[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq, Eq)]
129pub enum ChainDirection {
130 Out,
131 In,
132 Both,
133}
134
135impl From<ChainDirection> for memstead_base::graph::query::TraversalDirection {
136 fn from(d: ChainDirection) -> Self {
137 match d {
138 ChainDirection::Out => Self::Out,
139 ChainDirection::In => Self::In,
140 ChainDirection::Both => Self::Both,
141 }
142 }
143}
144
145fn chain_scope(args: &Args) -> Option<memstead_base::graph::chain::ChainScope> {
147 args.root
148 .as_deref()
149 .map(|root| memstead_base::graph::chain::ChainScope {
150 root: memstead_base::EntityId::canonical(root),
151 via: args.via.clone(),
152 direction: args.direction.into(),
153 depth: args.depth.unwrap_or(usize::MAX),
154 })
155}
156
157#[derive(ValueEnum, Clone, Copy, Debug)]
158pub enum Format {
159 Markdown,
161 Mem,
163 Json,
165 Html,
168 LlmsTxt,
172}
173
174pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
175 if args.root.is_some() && args.via.is_empty() {
178 return Err(CliError::new(
179 ExitKind::Validation,
180 "INVALID_INPUT",
181 "--root selects a chain and needs --via <REL[,REL]>: the rel-types the chain follows",
182 )
183 .with_details(json!({ "field": "via" }))
184 .into());
185 }
186 if args.root.is_none() && (!args.via.is_empty() || args.depth.is_some()) {
187 return Err(CliError::new(
188 ExitKind::Validation,
189 "INVALID_INPUT",
190 "--via and --depth describe a chain and need --root <ID>",
191 )
192 .with_details(json!({ "field": "root" }))
193 .into());
194 }
195 if args.root.is_some() && !matches!(args.format, Format::Json | Format::Html | Format::LlmsTxt)
196 {
197 return Err(CliError::new(
198 ExitKind::Validation,
199 "INVALID_INPUT",
200 "--root selects a chain within a rendered export (json, html, llms-txt); the \
201 markdown regeneration and the .mem archive always carry the whole mem",
202 )
203 .into());
204 }
205 if !args.include.is_empty() && !matches!(args.format, Format::Json) {
206 return Err(CliError::new(
207 ExitKind::Validation,
208 "INVALID_INPUT",
209 "--include applies only to --format json",
210 )
211 .into());
212 }
213 if matches!(args.format, Format::Json) {
214 return run_json(ctx, args);
215 }
216 if matches!(args.format, Format::Html) {
217 return run_html(ctx, args);
218 }
219 if matches!(args.format, Format::LlmsTxt) {
220 return run_llms_txt(ctx, args);
221 }
222 match ctx.cli_engine()? {
223 #[cfg(feature = "mem-repo")]
224 CliEngine::MemRepo(engine) => match args.format {
225 Format::Markdown => run_markdown(ctx, &engine, args.mem_name.as_deref()),
226 Format::Mem => run_mem(ctx, &engine, args),
227 Format::Json => unreachable!("dispatched to run_json above"),
228 Format::Html => unreachable!("dispatched to run_html above"),
229 Format::LlmsTxt => unreachable!("dispatched to run_llms_txt above"),
230 },
231 CliEngine::Filesystem(engine) => match args.format {
232 Format::Markdown => Err(CliError::new(
238 ExitKind::Validation,
239 "INVALID_INPUT",
240 "--format markdown is not yet supported on filesystem-mem `memstead export` — entities are already on disk in their canonical form",
241 )
242 .into()),
243 Format::Mem => run_mem_filesystem(ctx, &engine, args),
244 Format::Json => unreachable!("dispatched to run_json above"),
245 Format::Html => unreachable!("dispatched to run_html above"),
246 Format::LlmsTxt => unreachable!("dispatched to run_llms_txt above"),
247 },
248 }
249}
250
251const JSON_EXPORT_FORMAT: &str = "memstead-export/v1";
255
256fn run_json(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
265 if args.output.is_some() {
269 return Err(CliError::new(
270 ExitKind::Validation,
271 "INVALID_INPUT",
272 "--output applies only to --format mem — the JSON document goes to stdout; redirect it instead",
273 )
274 .into());
275 }
276
277 const JSON_INCLUDE_KEYS: &[&str] = &["anchors"];
281 for key in &args.include {
282 if !JSON_INCLUDE_KEYS.contains(&key.as_str()) {
283 return Err(CliError::new(
284 ExitKind::Validation,
285 "INVALID_INPUT",
286 format!(
287 "unknown --include key {key:?} — allowed: {}",
288 JSON_INCLUDE_KEYS.join(", ")
289 ),
290 )
291 .into());
292 }
293 }
294 let include_anchors = args.include.iter().any(|k| k == "anchors");
295
296 let cli_engine = ctx.cli_engine()?;
297 let engine = cli_engine.base();
298
299 let scope = chain_scope(&args);
302 let chain = match &scope {
303 Some(scope) => {
304 let mem = args
305 .mem_name
306 .clone()
307 .unwrap_or_else(|| scope.root.mem().to_string());
308 Some((
309 mem.clone(),
310 engine
311 .chain_set(&mem, scope)
312 .map_err(CliError::from_engine_op)?,
313 ))
314 }
315 None => None,
316 };
317
318 let all_names: Vec<String> = engine.mem_names().into_iter().map(String::from).collect();
319 let selected: Vec<String> = match chain.as_ref().map(|(m, _)| m).or(args.mem_name.as_ref()) {
323 Some(name) => {
324 if !all_names.iter().any(|n| n == name) {
325 return Err(CliError::new(
326 ExitKind::NotFound,
327 "UNKNOWN_MEM",
328 format!(
329 "unknown mem '{name}' — loaded mems: {}",
330 all_names.join(", ")
331 ),
332 )
333 .with_details(json!({ "mem": name, "loaded": all_names }))
334 .into());
335 }
336 vec![name.clone()]
337 }
338 None => all_names
339 .iter()
340 .filter(|n| engine.mem_router().is_writable(n))
341 .cloned()
342 .collect(),
343 };
344
345 let mut mems = serde_json::Map::new();
346 for mem_name in &selected {
347 let schema_pin = engine
350 .mounts_with_optional_config()
351 .find(|(name, _)| name == mem_name)
352 .and_then(|(_, c)| c)
353 .and_then(|c| c.schema.as_ref())
354 .map(|s| s.to_string());
355
356 let mut entities: Vec<&memstead_base::Entity> = engine
357 .store()
358 .all_entities()
359 .filter(|e| !e.stub && e.mem == *mem_name)
360 .filter(|e| chain.as_ref().is_none_or(|(_, c)| c.contains(&e.id)))
361 .collect();
362 entities.sort_by(|a, b| a.id.as_ref().cmp(b.id.as_ref()));
363
364 let envelopes: Vec<serde_json::Value> = entities
365 .iter()
366 .map(|entity| {
367 let body = memstead_base::render::render_entity_markdown(entity, None);
368 let tokens = memstead_base::chunking::estimate_tokens(&body);
369 let outgoing = engine.store().outgoing(&entity.id);
370 let mut envelope = memstead_base::render::build_entity_envelope(
373 entity,
374 tokens,
375 None,
376 None,
377 None,
378 engine.mem_origin_class(entity.id.mem()),
379 outgoing,
380 None,
381 None,
382 None,
383 );
384 if include_anchors && let Some(obj) = envelope.as_object_mut() {
390 let anchors = engine.entity_anchors(&entity.id);
391 obj.insert(
392 "anchors".to_string(),
393 serde_json::to_value(&anchors).unwrap_or(serde_json::Value::Null),
394 );
395 }
396 if chain.is_some()
401 && !include_anchors
402 && let Some(obj) = envelope.as_object_mut()
403 {
404 let resolved = engine.entity_anchors_resolved(&entity.id);
405 obj.insert(
406 "anchors".to_string(),
407 serde_json::to_value(&resolved).unwrap_or(serde_json::Value::Null),
408 );
409 }
410 envelope
411 })
412 .collect();
413
414 let mut group = serde_json::Map::new();
415 if let Some(s) = schema_pin {
416 group.insert("schema".to_string(), json!(s));
417 }
418 group.insert(
419 "read_only".to_string(),
420 json!(!engine.mem_router().is_writable(mem_name)),
421 );
422 group.insert("entity_count".to_string(), json!(envelopes.len()));
423 group.insert("entities".to_string(), serde_json::Value::Array(envelopes));
424 if let Some((_, chain_set)) = &chain {
429 let topology = engine
430 .mem_topology_scoped(mem_name, Some(chain_set))
431 .map_err(CliError::from_engine_op)?;
432 group.insert(
433 "chain".to_string(),
434 json!({
435 "root": chain_set.scope.root.to_string(),
436 "via": chain_set.scope.via,
437 "direction": chain_set.scope.direction.as_wire(),
438 "depth": (chain_set.scope.depth != usize::MAX).then_some(chain_set.scope.depth),
439 "nodes": topology.nodes.iter().map(|n| &n.id).collect::<Vec<_>>(),
440 "edges": topology.edges,
441 "reached": chain_set.reached.iter().map(|r| json!({
442 "id": r.id.to_string(),
443 "via_edge": r.via_edge,
444 "depth": r.depth,
445 "direction": r.direction.as_wire(),
446 })).collect::<Vec<_>>(),
447 }),
448 );
449 }
450 mems.insert(mem_name.clone(), serde_json::Value::Object(group));
451 }
452
453 print_json(&json!({
454 "format": JSON_EXPORT_FORMAT,
455 "mems": mems,
456 }))
457}
458
459#[cfg(feature = "mem-repo")]
460fn run_markdown(
461 ctx: &CliContext,
462 engine: &memstead_base::Engine,
463 mem_filter: Option<&str>,
464) -> anyhow::Result<()> {
465 let result = engine
471 .export_markdown(mem_filter, None)
472 .map_err(CliError::from_engine_op)?;
473
474 if ctx.json {
475 let mut body = json!({
476 "written": result.written,
477 "unchanged": result.unchanged,
478 });
479 if !result.skipped_mounts.is_empty() {
480 body["skipped_mounts"] = serde_json::to_value(&result.skipped_mounts)
481 .unwrap_or_else(|_| serde_json::Value::Array(Vec::new()));
482 }
483 if !result.refused_entities.is_empty() {
484 body["refused_entities"] = serde_json::to_value(&result.refused_entities)
485 .unwrap_or_else(|_| serde_json::Value::Array(Vec::new()));
486 }
487 print_json(&body)?;
488 } else {
489 let mut block = format!(
490 "# Export — markdown\n\n- Written: {}\n- Unchanged: {}",
491 result.written, result.unchanged,
492 );
493 if !result.skipped_mounts.is_empty() {
494 block.push_str("\n\n## Skipped mounts\n");
495 for m in &result.skipped_mounts {
496 block.push_str(&format!(
497 "\n- `{}` — backend `{}` ({}); use `--format mem` for archive export",
498 m.mem, m.active_backend, m.reason,
499 ));
500 }
501 }
502 if !result.refused_entities.is_empty() {
506 block.push_str("\n\n## Refused entities\n");
507 for r in &result.refused_entities {
508 block.push_str(&format!("\n- `{}` [{}] — {}", r.id, r.reason, r.detail));
509 }
510 }
511 print_markdown(&block);
512 }
513 Ok(())
514}
515
516#[cfg(feature = "mem-repo")]
517fn run_mem(ctx: &CliContext, engine: &memstead_base::Engine, args: Args) -> anyhow::Result<()> {
518 let mem_name = resolve_mem_name(engine, args.mem_name)?;
519 let config = engine
525 .mem_configs_named()
526 .find(|(name, _)| *name == mem_name)
527 .map(|(_, c)| c)
528 .ok_or_else(|| {
529 CliError::new(
530 ExitKind::NotFound,
531 "UNKNOWN_MEM",
532 format!("mem config not found for '{mem_name}'"),
533 )
534 })?;
535
536 let output = match args.output {
537 Some(p) => p,
538 None => default_output_path(&mem_name, config)?,
539 };
540
541 let mut result = engine
542 .export_mem(&mem_name, &output)
543 .map_err(CliError::from_engine_op)?;
544
545 let dropped = if args.self_contained {
551 let self_contained = make_self_contained_on_disk(&output)?;
552 result.size_bytes = self_contained.bytes.len() as u64;
553 result.dangling_cross_mem_edges.clear();
554 Some(self_contained.dropped)
555 } else {
556 None
557 };
558
559 let dangling = &result.dangling_cross_mem_edges;
564
565 if ctx.json {
566 let mut warnings: Vec<_> = dangling
567 .iter()
568 .map(|e| {
569 json!({
570 "code": "DANGLING_CROSS_MEM_EDGE_IN_EXPORT",
571 "entity": e.entity_path,
572 "target_id": e.target_id,
573 "target_mem": e.target_mem,
574 })
575 })
576 .collect();
577 if let Some(dropped) = &dropped {
578 warnings.extend(dropped.iter().map(|e| {
579 json!({
580 "code": "CROSS_MEM_EDGE_DROPPED",
581 "entity": e.entity_path,
582 "target_id": e.target_id,
583 "target_mem": e.target_mem,
584 })
585 }));
586 }
587 warnings.extend(result.unterminated_fence_entities.iter().map(|id| {
588 json!({
589 "code": "UNTERMINATED_FENCE_IN_EXPORT",
590 "entity": id,
591 })
592 }));
593 print_json(&json!({
594 "archive_path": result.archive_path,
595 "name": result.name,
596 "version": result.version,
597 "entity_count": result.entity_count,
598 "size_bytes": result.size_bytes,
599 "self_contained": args.self_contained,
600 "redactions": result.redactions,
601 "warnings": warnings,
602 }))?;
603 } else {
604 let mut block = format!(
605 "# Exported `{}` v{}\n\n- Archive: `{}`\n- Entities: {}\n- Size: {} bytes",
606 result.name,
607 result.version,
608 result.archive_path,
609 result.entity_count,
610 result.size_bytes,
611 );
612 if args.self_contained {
613 block.push_str("\n- Self-contained: yes");
614 }
615 if !result.redactions.is_empty() {
616 let listed: Vec<String> = result
617 .redactions
618 .iter()
619 .map(|r| format!("{} {}", r.class, r.count))
620 .collect();
621 block.push_str(&format!(
622 "\n- Redacted in provenance: {} (each span reads `[redacted:<class>]`)",
623 listed.join(", ")
624 ));
625 }
626 if !result.unterminated_fence_entities.is_empty() {
629 block.push_str(
630 "\n\n## Entities `install` will refuse\n\nEach ends a section inside an \
631 unterminated code fence, which absorbed the sections after it. Repair through \
632 the engine (replace the absorbing section) and re-export.\n",
633 );
634 for id in &result.unterminated_fence_entities {
635 block.push_str(&format!("\n- `{id}` [UNTERMINATED_FENCE_IN_EXPORT]"));
636 }
637 }
638 if !dangling.is_empty() {
639 block.push_str("\n\n## Warnings\n");
640 for e in dangling {
641 block.push_str(&format!(
642 "\n- **DANGLING_CROSS_MEM_EDGE_IN_EXPORT**: `{}` → `{}` (mem `{}`) — \
643 target lives outside this archive; `memstead install` will reject it unless \
644 mem `{}` is also present. Re-export with `--self-contained` to drop such \
645 rows (each reported; body wiki-link prose survives).",
646 e.entity_path, e.target_id, e.target_mem, e.target_mem,
647 ));
648 }
649 }
650 if let Some(dropped) = &dropped
651 && !dropped.is_empty()
652 {
653 block.push_str("\n\n## Dropped cross-mem edges\n");
654 for e in dropped {
655 block.push_str(&format!(
656 "\n- **CROSS_MEM_EDGE_DROPPED**: `{}` → `{}` (mem `{}`): the relationship \
657 row does not travel; a body wiki-link to the same target still does.",
658 e.entity_path, e.target_id, e.target_mem,
659 ));
660 }
661 }
662 print_markdown(&block);
663 }
664 Ok(())
665}
666
667fn make_self_contained_on_disk(
670 path: &std::path::Path,
671) -> anyhow::Result<memstead_base::validator::SelfContainedArchive> {
672 let bytes = std::fs::read(path).map_err(|e| {
673 CliError::new(
674 ExitKind::Generic,
675 crate::INTERNAL_CODE,
676 format!("read {}: {e}", path.display()),
677 )
678 })?;
679 let out = memstead_base::validator::make_archive_self_contained(&bytes).map_err(|e| {
680 CliError::new(
681 ExitKind::Generic,
682 "ARCHIVE_VALIDATION_FAILED",
683 format!("self-contained re-pack of {}: {e}", path.display()),
684 )
685 })?;
686 std::fs::write(path, &out.bytes).map_err(|e| {
687 CliError::new(
688 ExitKind::Generic,
689 crate::INTERNAL_CODE,
690 format!("write {}: {e}", path.display()),
691 )
692 })?;
693 Ok(out)
694}
695
696#[cfg(feature = "mem-repo")]
697fn resolve_mem_name(
698 engine: &memstead_base::Engine,
699 explicit: Option<String>,
700) -> anyhow::Result<String> {
701 if let Some(name) = explicit {
702 return Ok(name);
703 }
704 let writable: Vec<String> = engine
708 .mounts_with_optional_config()
709 .filter(|(name, _)| engine.mem_router().is_writable(name))
710 .map(|(name, _)| name.to_string())
711 .collect();
712
713 match writable.len() {
714 0 => Err(CliError::new(
715 ExitKind::Generic,
716 "NO_WRITABLE_MEM",
717 "no writable mem loaded — nothing to export",
718 )
719 .into()),
720 1 => Ok(writable.into_iter().next().unwrap()),
721 _ => Err(CliError::new(
722 ExitKind::Validation,
723 "AMBIGUOUS_MEM",
724 format!(
725 "multiple writable mems loaded ({}); pass --mem <name>",
726 writable.join(", ")
727 ),
728 )
729 .with_details(json!({ "mems": writable }))
730 .into()),
731 }
732}
733
734fn run_mem_filesystem(
741 ctx: &CliContext,
742 engine: &memstead_base::Engine,
743 args: Args,
744) -> anyhow::Result<()> {
745 let workspace_mem = engine
746 .mem_names()
747 .into_iter()
748 .next()
749 .map(String::from)
750 .unwrap_or_default();
751 if let Some(name) = args.mem_name.as_deref()
752 && name != workspace_mem
753 {
754 return Err(CliError::new(
755 ExitKind::NotFound,
756 "UNKNOWN_MEM",
757 format!(
758 "filesystem-mem is single-mem: workspace mem is `{workspace_mem}`, --mem `{name}` does not match"
759 ),
760 )
761 .into());
762 }
763
764 let report = engine
776 .export_mem_bytes_report(&workspace_mem)
777 .map_err(CliError::from_engine_op)?;
778 let bytes = report.bytes;
779
780 let output = match args.output {
781 Some(p) => p,
782 None => {
783 PathBuf::from(format!(
788 "{workspace_mem}.{}",
789 memstead_schema::ARCHIVE_EXTENSION
790 ))
791 }
792 };
793
794 std::fs::write(&output, &bytes).map_err(|e| {
795 CliError::new(
796 ExitKind::Generic,
797 crate::INTERNAL_CODE,
798 format!("write {}: {e}", output.display()),
799 )
800 })?;
801 let dropped = if args.self_contained {
802 Some(make_self_contained_on_disk(&output)?.dropped)
803 } else {
804 None
805 };
806 let size_bytes = std::fs::metadata(&output)
807 .map(|m| m.len() as usize)
808 .unwrap_or(bytes.len());
809 let entity_count = engine
813 .store()
814 .all_entities()
815 .filter(|e| !e.stub && e.id.mem() == workspace_mem)
816 .count();
817
818 if ctx.json {
819 let warnings: Vec<_> = dropped
820 .iter()
821 .flatten()
822 .map(|e| {
823 json!({
824 "code": "CROSS_MEM_EDGE_DROPPED",
825 "entity": e.entity_path,
826 "target_id": e.target_id,
827 "target_mem": e.target_mem,
828 })
829 })
830 .collect();
831 print_json(&json!({
832 "archive_path": output.to_string_lossy(),
833 "name": workspace_mem,
834 "entity_count": entity_count,
835 "size_bytes": size_bytes,
836 "self_contained": args.self_contained,
837 "redactions": report.redactions,
838 "warnings": warnings,
839 }))?;
840 } else {
841 let mut block = format!(
842 "# Exported `{workspace_mem}`\n\n- Archive: `{}`\n- Entities: {}\n- Size: {} bytes",
843 output.display(),
844 entity_count,
845 size_bytes,
846 );
847 if !report.redactions.is_empty() {
848 let listed: Vec<String> = report
849 .redactions
850 .iter()
851 .map(|r| format!("{} {}", r.class, r.count))
852 .collect();
853 block.push_str(&format!(
854 "\n- Redacted in provenance: {} (each span reads `[redacted:<class>]`)",
855 listed.join(", ")
856 ));
857 }
858 if args.self_contained {
859 block.push_str("\n- Self-contained: yes");
860 let n = dropped.as_ref().map(|d| d.len()).unwrap_or(0);
861 if n > 0 {
862 block.push_str(&format!("\n- Cross-mem edges dropped: {n}"));
863 }
864 }
865 print_markdown(&block);
866 }
867 Ok(())
868}
869
870#[cfg(feature = "mem-repo")]
871fn default_output_path(
872 mem_name: &str,
873 config: &memstead_schema::MemConfig,
874) -> anyhow::Result<PathBuf> {
875 let version = config.version.as_ref().ok_or_else(|| {
876 CliError::from_engine_op(memstead_base::EngineError::MemConfigIncomplete {
883 mem: mem_name.to_string(),
884 missing_fields: vec!["version".to_string()],
885 })
886 })?;
887 let filename = format!(
890 "{mem_name}-{version}.{}",
891 memstead_schema::ARCHIVE_EXTENSION
892 );
893 Ok(PathBuf::from(filename))
894}
895
896fn run_llms_txt(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
908 let engine_holder = ctx.cli_engine()?;
909 let engine = engine_holder.base();
910 let implied_mem = args
912 .root
913 .as_deref()
914 .map(|r| memstead_base::EntityId::canonical(r).mem().to_string());
915 let mem = resolve_single_mem(engine, args.mem_name.as_deref().or(implied_mem.as_deref()))?;
916
917 let ctx_opts = memstead_base::engine::export_llms_txt::LlmsTxtContext {
918 authority: None,
919 href_prefix: args
920 .base_url
921 .clone()
922 .map(|u| u.trim_end_matches('/').to_string())
923 .unwrap_or_default(),
924 wider_project: Vec::new(),
925 };
926 let chain = match chain_scope(&args) {
927 Some(scope) => Some(
928 engine
929 .chain_set(&mem, &scope)
930 .map_err(CliError::from_engine_op)?,
931 ),
932 None => None,
933 };
934 let doc = engine
935 .render_llms_txt_scoped(&mem, &ctx_opts, chain.as_ref())
936 .map_err(CliError::from_engine_op)?;
937
938 match &args.output {
939 Some(path) => {
940 std::fs::write(path, &doc).map_err(|e| {
941 CliError::new(
942 ExitKind::Generic,
943 "IO_ERROR",
944 format!("write {}: {e}", path.display()),
945 )
946 })?;
947 if ctx.json {
948 print_json(&serde_json::json!({
949 "mem": mem,
950 "written": path.display().to_string(),
951 "bytes": doc.len(),
952 }))?;
953 } else {
954 println!("Wrote {} ({} bytes)", path.display(), doc.len());
955 }
956 }
957 None => print!("{doc}"),
961 }
962 Ok(())
963}
964
965fn resolve_single_mem(
969 engine: &memstead_base::Engine,
970 requested: Option<&str>,
971) -> Result<String, CliError> {
972 if let Some(m) = requested {
973 return Ok(m.to_string());
974 }
975 let writables: Vec<String> = engine
976 .writable_mem_names()
977 .iter()
978 .map(|s| s.to_string())
979 .collect();
980 match writables.as_slice() {
981 [one] => Ok(one.clone()),
982 [] => Err(CliError::new(
983 ExitKind::Validation,
984 "INVALID_INPUT",
985 "no writable mem loaded — pass --mem <name>",
986 )),
987 _ => Err(CliError::new(
988 ExitKind::Validation,
989 "INVALID_INPUT",
990 format!(
991 "multiple writable mems loaded ({}) — pass --mem <name>",
992 writables.join(", ")
993 ),
994 )),
995 }
996}
997
998fn run_html(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
1004 let engine_holder = ctx.cli_engine()?;
1005 let engine = engine_holder.base();
1006 let implied_mem = args
1010 .root
1011 .as_deref()
1012 .map(|r| memstead_base::EntityId::canonical(r).mem().to_string());
1013 let mem = match args.mem_name.as_ref().or(implied_mem.as_ref()) {
1014 Some(m) => m.clone(),
1015 None => {
1016 let writables: Vec<String> = engine
1017 .writable_mem_names()
1018 .iter()
1019 .map(|s| s.to_string())
1020 .collect();
1021 match writables.as_slice() {
1022 [one] => one.clone(),
1023 [] => {
1024 return Err(CliError::new(
1025 ExitKind::Validation,
1026 "INVALID_INPUT",
1027 "no writable mem loaded — pass --mem <name>",
1028 )
1029 .into());
1030 }
1031 _ => {
1032 return Err(CliError::new(
1033 ExitKind::Validation,
1034 "INVALID_INPUT",
1035 format!(
1036 "multiple writable mems loaded ({}) — pass --mem <name>",
1037 writables.join(", ")
1038 ),
1039 )
1040 .into());
1041 }
1042 }
1043 }
1044 };
1045 let now = time::OffsetDateTime::now_utc();
1046 let export_date = format!(
1047 "{:04}-{:02}-{:02}",
1048 now.year(),
1049 u8::from(now.month()),
1050 now.day()
1051 );
1052 let chain = match chain_scope(&args) {
1053 Some(scope) => Some(
1054 engine
1055 .chain_set(&mem, &scope)
1056 .map_err(CliError::from_engine_op)?,
1057 ),
1058 None => None,
1059 };
1060 let html = engine
1061 .render_html_export_scoped(&mem, &export_date, chain.as_ref())
1062 .map_err(CliError::from_engine_op)?;
1063 let out_path = args
1064 .output
1065 .clone()
1066 .unwrap_or_else(|| PathBuf::from(format!("{mem}.html")));
1067 std::fs::write(&out_path, &html).map_err(|e| {
1068 CliError::new(
1069 ExitKind::Generic,
1070 "IO_ERROR",
1071 format!("write {}: {e}", out_path.display()),
1072 )
1073 })?;
1074 if ctx.json {
1075 print_json(&serde_json::json!({
1076 "format": "html",
1077 "mem": mem,
1078 "path": out_path,
1079 "bytes": html.len(),
1080 "exported": export_date,
1081 }))?;
1082 } else {
1083 print_markdown(&format!(
1084 "# 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",
1085 out_path.display(),
1086 html.len()
1087 ));
1088 }
1089 Ok(())
1090}
1091
1092#[cfg(test)]
1093mod tests {
1094 use super::*;
1095 use clap::Parser;
1096
1097 #[test]
1100 fn export_mem_selection_flag_is_mem_not_mem_name() {
1101 let parsed = Args::try_parse_from(["export", "--mem", "specs", "--format", "mem"]).unwrap();
1102 assert_eq!(parsed.mem_name.as_deref(), Some("specs"));
1103 assert!(
1104 Args::try_parse_from(["export", "--mem-name", "specs"]).is_err(),
1105 "the retired --mem-name flag must not parse"
1106 );
1107 }
1108}