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, value_name = "ID")]
68 pub root: Option<String>,
69
70 #[arg(long, value_name = "REL", value_delimiter = ',')]
75 pub via: Vec<String>,
76
77 #[arg(long, value_enum, default_value_t = ChainDirection::Out)]
83 pub direction: ChainDirection,
84
85 #[arg(long, value_name = "N")]
88 pub depth: Option<usize>,
89
90 #[arg(long)]
100 pub self_contained: bool,
101
102 #[arg(long = "base-url", value_name = "URL")]
108 pub base_url: Option<String>,
109
110 #[arg(long, value_delimiter = ',', value_name = "KEY")]
118 pub include: Vec<String>,
119}
120
121#[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq, Eq)]
124pub enum ChainDirection {
125 Out,
126 In,
127 Both,
128}
129
130impl From<ChainDirection> for memstead_base::graph::query::TraversalDirection {
131 fn from(d: ChainDirection) -> Self {
132 match d {
133 ChainDirection::Out => Self::Out,
134 ChainDirection::In => Self::In,
135 ChainDirection::Both => Self::Both,
136 }
137 }
138}
139
140fn chain_scope(args: &Args) -> Option<memstead_base::graph::chain::ChainScope> {
142 args.root
143 .as_deref()
144 .map(|root| memstead_base::graph::chain::ChainScope {
145 root: memstead_base::EntityId::canonical(root),
146 via: args.via.clone(),
147 direction: args.direction.into(),
148 depth: args.depth.unwrap_or(usize::MAX),
149 })
150}
151
152#[derive(ValueEnum, Clone, Copy, Debug)]
153pub enum Format {
154 Markdown,
156 Mem,
158 Json,
160 Html,
163 LlmsTxt,
167}
168
169pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
170 if args.root.is_some() && args.via.is_empty() {
173 return Err(CliError::new(
174 ExitKind::Validation,
175 "INVALID_INPUT",
176 "--root selects a chain and needs --via <REL[,REL]>: the rel-types the chain follows",
177 )
178 .with_details(json!({ "field": "via" }))
179 .into());
180 }
181 if args.root.is_none() && (!args.via.is_empty() || args.depth.is_some()) {
182 return Err(CliError::new(
183 ExitKind::Validation,
184 "INVALID_INPUT",
185 "--via and --depth describe a chain and need --root <ID>",
186 )
187 .with_details(json!({ "field": "root" }))
188 .into());
189 }
190 if args.root.is_some() && !matches!(args.format, Format::Json | Format::Html | Format::LlmsTxt)
191 {
192 return Err(CliError::new(
193 ExitKind::Validation,
194 "INVALID_INPUT",
195 "--root selects a chain within a rendered export (json, html, llms-txt); the \
196 markdown regeneration and the .mem archive always carry the whole mem",
197 )
198 .into());
199 }
200 if !args.include.is_empty() && !matches!(args.format, Format::Json) {
201 return Err(CliError::new(
202 ExitKind::Validation,
203 "INVALID_INPUT",
204 "--include applies only to --format json",
205 )
206 .into());
207 }
208 if matches!(args.format, Format::Json) {
209 return run_json(ctx, args);
210 }
211 if matches!(args.format, Format::Html) {
212 return run_html(ctx, args);
213 }
214 if matches!(args.format, Format::LlmsTxt) {
215 return run_llms_txt(ctx, args);
216 }
217 match ctx.cli_engine()? {
218 #[cfg(feature = "mem-repo")]
219 CliEngine::MemRepo(engine) => match args.format {
220 Format::Markdown => run_markdown(ctx, &engine, args.mem_name.as_deref()),
221 Format::Mem => run_mem(ctx, &engine, args),
222 Format::Json => unreachable!("dispatched to run_json above"),
223 Format::Html => unreachable!("dispatched to run_html above"),
224 Format::LlmsTxt => unreachable!("dispatched to run_llms_txt above"),
225 },
226 CliEngine::Filesystem(engine) => match args.format {
227 Format::Markdown => Err(CliError::new(
233 ExitKind::Validation,
234 "INVALID_INPUT",
235 "--format markdown is not yet supported on filesystem-mem `memstead export` — entities are already on disk in their canonical form",
236 )
237 .into()),
238 Format::Mem => run_mem_filesystem(ctx, &engine, args),
239 Format::Json => unreachable!("dispatched to run_json above"),
240 Format::Html => unreachable!("dispatched to run_html above"),
241 Format::LlmsTxt => unreachable!("dispatched to run_llms_txt above"),
242 },
243 }
244}
245
246const JSON_EXPORT_FORMAT: &str = "memstead-export/v1";
250
251fn run_json(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
260 if args.output.is_some() {
264 return Err(CliError::new(
265 ExitKind::Validation,
266 "INVALID_INPUT",
267 "--output applies only to --format mem — the JSON document goes to stdout; redirect it instead",
268 )
269 .into());
270 }
271
272 const JSON_INCLUDE_KEYS: &[&str] = &["anchors"];
276 for key in &args.include {
277 if !JSON_INCLUDE_KEYS.contains(&key.as_str()) {
278 return Err(CliError::new(
279 ExitKind::Validation,
280 "INVALID_INPUT",
281 format!(
282 "unknown --include key {key:?} — allowed: {}",
283 JSON_INCLUDE_KEYS.join(", ")
284 ),
285 )
286 .into());
287 }
288 }
289 let include_anchors = args.include.iter().any(|k| k == "anchors");
290
291 let cli_engine = ctx.cli_engine()?;
292 let engine = cli_engine.base();
293
294 let scope = chain_scope(&args);
297 let chain = match &scope {
298 Some(scope) => {
299 let mem = args
300 .mem_name
301 .clone()
302 .unwrap_or_else(|| scope.root.mem().to_string());
303 Some((
304 mem.clone(),
305 engine
306 .chain_set(&mem, scope)
307 .map_err(CliError::from_engine_op)?,
308 ))
309 }
310 None => None,
311 };
312
313 let all_names: Vec<String> = engine.mem_names().into_iter().map(String::from).collect();
314 let selected: Vec<String> = match chain.as_ref().map(|(m, _)| m).or(args.mem_name.as_ref()) {
318 Some(name) => {
319 if !all_names.iter().any(|n| n == name) {
320 return Err(CliError::new(
321 ExitKind::NotFound,
322 "UNKNOWN_MEM",
323 format!(
324 "unknown mem '{name}' — loaded mems: {}",
325 all_names.join(", ")
326 ),
327 )
328 .with_details(json!({ "mem": name, "loaded": all_names }))
329 .into());
330 }
331 vec![name.clone()]
332 }
333 None => all_names
334 .iter()
335 .filter(|n| engine.mem_router().is_writable(n))
336 .cloned()
337 .collect(),
338 };
339
340 let mut mems = serde_json::Map::new();
341 for mem_name in &selected {
342 let schema_pin = engine
345 .mounts_with_optional_config()
346 .find(|(name, _)| name == mem_name)
347 .and_then(|(_, c)| c)
348 .and_then(|c| c.schema.as_ref())
349 .map(|s| s.to_string());
350
351 let mut entities: Vec<&memstead_base::Entity> = engine
352 .store()
353 .all_entities()
354 .filter(|e| !e.stub && e.mem == *mem_name)
355 .filter(|e| chain.as_ref().is_none_or(|(_, c)| c.contains(&e.id)))
356 .collect();
357 entities.sort_by(|a, b| a.id.as_ref().cmp(b.id.as_ref()));
358
359 let envelopes: Vec<serde_json::Value> = entities
360 .iter()
361 .map(|entity| {
362 let body = memstead_base::render::render_entity_markdown(entity, None);
363 let tokens = memstead_base::chunking::estimate_tokens(&body);
364 let outgoing = engine.store().outgoing(&entity.id);
365 let mut envelope = memstead_base::render::build_entity_envelope(
368 entity,
369 tokens,
370 None,
371 None,
372 None,
373 engine.mem_origin_class(entity.id.mem()),
374 outgoing,
375 None,
376 None,
377 None,
378 );
379 if include_anchors && let Some(obj) = envelope.as_object_mut() {
385 let anchors = engine.entity_anchors(&entity.id);
386 obj.insert(
387 "anchors".to_string(),
388 serde_json::to_value(&anchors).unwrap_or(serde_json::Value::Null),
389 );
390 }
391 if chain.is_some()
396 && !include_anchors
397 && let Some(obj) = envelope.as_object_mut()
398 {
399 let resolved = engine.entity_anchors_resolved(&entity.id);
400 obj.insert(
401 "anchors".to_string(),
402 serde_json::to_value(&resolved).unwrap_or(serde_json::Value::Null),
403 );
404 }
405 envelope
406 })
407 .collect();
408
409 let mut group = serde_json::Map::new();
410 if let Some(s) = schema_pin {
411 group.insert("schema".to_string(), json!(s));
412 }
413 group.insert(
414 "read_only".to_string(),
415 json!(!engine.mem_router().is_writable(mem_name)),
416 );
417 group.insert("entity_count".to_string(), json!(envelopes.len()));
418 group.insert("entities".to_string(), serde_json::Value::Array(envelopes));
419 if let Some((_, chain_set)) = &chain {
424 let topology = engine
425 .mem_topology_scoped(mem_name, Some(chain_set))
426 .map_err(CliError::from_engine_op)?;
427 group.insert(
428 "chain".to_string(),
429 json!({
430 "root": chain_set.scope.root.to_string(),
431 "via": chain_set.scope.via,
432 "direction": chain_set.scope.direction.as_wire(),
433 "depth": (chain_set.scope.depth != usize::MAX).then_some(chain_set.scope.depth),
434 "nodes": topology.nodes.iter().map(|n| &n.id).collect::<Vec<_>>(),
435 "edges": topology.edges,
436 "reached": chain_set.reached.iter().map(|r| json!({
437 "id": r.id.to_string(),
438 "via_edge": r.via_edge,
439 "depth": r.depth,
440 "direction": r.direction.as_wire(),
441 })).collect::<Vec<_>>(),
442 }),
443 );
444 }
445 mems.insert(mem_name.clone(), serde_json::Value::Object(group));
446 }
447
448 print_json(&json!({
449 "format": JSON_EXPORT_FORMAT,
450 "mems": mems,
451 }))
452}
453
454#[cfg(feature = "mem-repo")]
455fn run_markdown(
456 ctx: &CliContext,
457 engine: &memstead_base::Engine,
458 mem_filter: Option<&str>,
459) -> anyhow::Result<()> {
460 let result = engine
466 .export_markdown(mem_filter, None)
467 .map_err(CliError::from_engine_op)?;
468
469 if ctx.json {
470 let mut body = json!({
471 "written": result.written,
472 "unchanged": result.unchanged,
473 });
474 if !result.skipped_mounts.is_empty() {
475 body["skipped_mounts"] = serde_json::to_value(&result.skipped_mounts)
476 .unwrap_or_else(|_| serde_json::Value::Array(Vec::new()));
477 }
478 if !result.refused_entities.is_empty() {
479 body["refused_entities"] = serde_json::to_value(&result.refused_entities)
480 .unwrap_or_else(|_| serde_json::Value::Array(Vec::new()));
481 }
482 print_json(&body)?;
483 } else {
484 let mut block = format!(
485 "# Export — markdown\n\n- Written: {}\n- Unchanged: {}",
486 result.written, result.unchanged,
487 );
488 if !result.skipped_mounts.is_empty() {
489 block.push_str("\n\n## Skipped mounts\n");
490 for m in &result.skipped_mounts {
491 block.push_str(&format!(
492 "\n- `{}` — backend `{}` ({}); use `--format mem` for archive export",
493 m.mem, m.active_backend, m.reason,
494 ));
495 }
496 }
497 if !result.refused_entities.is_empty() {
501 block.push_str("\n\n## Refused entities\n");
502 for r in &result.refused_entities {
503 block.push_str(&format!("\n- `{}` [{}] — {}", r.id, r.reason, r.detail));
504 }
505 }
506 print_markdown(&block);
507 }
508 Ok(())
509}
510
511#[cfg(feature = "mem-repo")]
512fn run_mem(ctx: &CliContext, engine: &memstead_base::Engine, args: Args) -> anyhow::Result<()> {
513 let mem_name = resolve_mem_name(engine, args.mem_name)?;
514 let config = engine
520 .mem_configs_named()
521 .find(|(name, _)| *name == mem_name)
522 .map(|(_, c)| c)
523 .ok_or_else(|| {
524 CliError::new(
525 ExitKind::NotFound,
526 "UNKNOWN_MEM",
527 format!("mem config not found for '{mem_name}'"),
528 )
529 })?;
530
531 let output = match args.output {
532 Some(p) => p,
533 None => default_output_path(&mem_name, config)?,
534 };
535
536 let mut result = engine
537 .export_mem(&mem_name, &output)
538 .map_err(CliError::from_engine_op)?;
539
540 let dropped = if args.self_contained {
546 let self_contained = make_self_contained_on_disk(&output)?;
547 result.size_bytes = self_contained.bytes.len() as u64;
548 result.dangling_cross_mem_edges.clear();
549 Some(self_contained.dropped)
550 } else {
551 None
552 };
553
554 let dangling = &result.dangling_cross_mem_edges;
559
560 if ctx.json {
561 let mut warnings: Vec<_> = dangling
562 .iter()
563 .map(|e| {
564 json!({
565 "code": "DANGLING_CROSS_MEM_EDGE_IN_EXPORT",
566 "entity": e.entity_path,
567 "target_id": e.target_id,
568 "target_mem": e.target_mem,
569 })
570 })
571 .collect();
572 if let Some(dropped) = &dropped {
573 warnings.extend(dropped.iter().map(|e| {
574 json!({
575 "code": "CROSS_MEM_EDGE_DROPPED",
576 "entity": e.entity_path,
577 "target_id": e.target_id,
578 "target_mem": e.target_mem,
579 })
580 }));
581 }
582 warnings.extend(result.unterminated_fence_entities.iter().map(|id| {
583 json!({
584 "code": "UNTERMINATED_FENCE_IN_EXPORT",
585 "entity": id,
586 })
587 }));
588 print_json(&json!({
589 "archive_path": result.archive_path,
590 "name": result.name,
591 "version": result.version,
592 "entity_count": result.entity_count,
593 "size_bytes": result.size_bytes,
594 "self_contained": args.self_contained,
595 "warnings": warnings,
596 }))?;
597 } else {
598 let mut block = format!(
599 "# Exported `{}` v{}\n\n- Archive: `{}`\n- Entities: {}\n- Size: {} bytes",
600 result.name,
601 result.version,
602 result.archive_path,
603 result.entity_count,
604 result.size_bytes,
605 );
606 if args.self_contained {
607 block.push_str("\n- Self-contained: yes");
608 }
609 if !result.unterminated_fence_entities.is_empty() {
612 block.push_str(
613 "\n\n## Entities `install` will refuse\n\nEach ends a section inside an \
614 unterminated code fence, which absorbed the sections after it. Repair through \
615 the engine (replace the absorbing section) and re-export.\n",
616 );
617 for id in &result.unterminated_fence_entities {
618 block.push_str(&format!("\n- `{id}` [UNTERMINATED_FENCE_IN_EXPORT]"));
619 }
620 }
621 if !dangling.is_empty() {
622 block.push_str("\n\n## Warnings\n");
623 for e in dangling {
624 block.push_str(&format!(
625 "\n- **DANGLING_CROSS_MEM_EDGE_IN_EXPORT**: `{}` → `{}` (mem `{}`) — \
626 target lives outside this archive; `memstead install` will reject it unless \
627 mem `{}` is also present. Re-export with `--self-contained` to drop such \
628 rows (each reported; body wiki-link prose survives).",
629 e.entity_path, e.target_id, e.target_mem, e.target_mem,
630 ));
631 }
632 }
633 if let Some(dropped) = &dropped
634 && !dropped.is_empty()
635 {
636 block.push_str("\n\n## Dropped cross-mem edges\n");
637 for e in dropped {
638 block.push_str(&format!(
639 "\n- **CROSS_MEM_EDGE_DROPPED**: `{}` → `{}` (mem `{}`): the relationship \
640 row does not travel; a body wiki-link to the same target still does.",
641 e.entity_path, e.target_id, e.target_mem,
642 ));
643 }
644 }
645 print_markdown(&block);
646 }
647 Ok(())
648}
649
650fn make_self_contained_on_disk(
653 path: &std::path::Path,
654) -> anyhow::Result<memstead_base::validator::SelfContainedArchive> {
655 let bytes = std::fs::read(path).map_err(|e| {
656 CliError::new(
657 ExitKind::Generic,
658 crate::INTERNAL_CODE,
659 format!("read {}: {e}", path.display()),
660 )
661 })?;
662 let out = memstead_base::validator::make_archive_self_contained(&bytes).map_err(|e| {
663 CliError::new(
664 ExitKind::Generic,
665 "ARCHIVE_VALIDATION_FAILED",
666 format!("self-contained re-pack of {}: {e}", path.display()),
667 )
668 })?;
669 std::fs::write(path, &out.bytes).map_err(|e| {
670 CliError::new(
671 ExitKind::Generic,
672 crate::INTERNAL_CODE,
673 format!("write {}: {e}", path.display()),
674 )
675 })?;
676 Ok(out)
677}
678
679#[cfg(feature = "mem-repo")]
680fn resolve_mem_name(
681 engine: &memstead_base::Engine,
682 explicit: Option<String>,
683) -> anyhow::Result<String> {
684 if let Some(name) = explicit {
685 return Ok(name);
686 }
687 let writable: Vec<String> = engine
691 .mounts_with_optional_config()
692 .filter(|(name, _)| engine.mem_router().is_writable(name))
693 .map(|(name, _)| name.to_string())
694 .collect();
695
696 match writable.len() {
697 0 => Err(CliError::new(
698 ExitKind::Generic,
699 "NO_WRITABLE_MEM",
700 "no writable mem loaded — nothing to export",
701 )
702 .into()),
703 1 => Ok(writable.into_iter().next().unwrap()),
704 _ => Err(CliError::new(
705 ExitKind::Validation,
706 "AMBIGUOUS_MEM",
707 format!(
708 "multiple writable mems loaded ({}); pass --mem <name>",
709 writable.join(", ")
710 ),
711 )
712 .with_details(json!({ "mems": writable }))
713 .into()),
714 }
715}
716
717fn run_mem_filesystem(
724 ctx: &CliContext,
725 engine: &memstead_base::Engine,
726 args: Args,
727) -> anyhow::Result<()> {
728 let workspace_mem = engine
729 .mem_names()
730 .into_iter()
731 .next()
732 .map(String::from)
733 .unwrap_or_default();
734 if let Some(name) = args.mem_name.as_deref()
735 && name != workspace_mem
736 {
737 return Err(CliError::new(
738 ExitKind::NotFound,
739 "UNKNOWN_MEM",
740 format!(
741 "filesystem-mem is single-mem: workspace mem is `{workspace_mem}`, --mem `{name}` does not match"
742 ),
743 )
744 .into());
745 }
746
747 let bytes = engine
759 .export_mem_to_bytes(&workspace_mem)
760 .map_err(CliError::from_engine_op)?;
761
762 let output = match args.output {
763 Some(p) => p,
764 None => {
765 PathBuf::from(format!(
770 "{workspace_mem}.{}",
771 memstead_schema::ARCHIVE_EXTENSION
772 ))
773 }
774 };
775
776 std::fs::write(&output, &bytes).map_err(|e| {
777 CliError::new(
778 ExitKind::Generic,
779 crate::INTERNAL_CODE,
780 format!("write {}: {e}", output.display()),
781 )
782 })?;
783 let dropped = if args.self_contained {
784 Some(make_self_contained_on_disk(&output)?.dropped)
785 } else {
786 None
787 };
788 let size_bytes = std::fs::metadata(&output)
789 .map(|m| m.len() as usize)
790 .unwrap_or(bytes.len());
791 let entity_count = engine
795 .store()
796 .all_entities()
797 .filter(|e| !e.stub && e.id.mem() == workspace_mem)
798 .count();
799
800 if ctx.json {
801 let warnings: Vec<_> = dropped
802 .iter()
803 .flatten()
804 .map(|e| {
805 json!({
806 "code": "CROSS_MEM_EDGE_DROPPED",
807 "entity": e.entity_path,
808 "target_id": e.target_id,
809 "target_mem": e.target_mem,
810 })
811 })
812 .collect();
813 print_json(&json!({
814 "archive_path": output.to_string_lossy(),
815 "name": workspace_mem,
816 "entity_count": entity_count,
817 "size_bytes": size_bytes,
818 "self_contained": args.self_contained,
819 "warnings": warnings,
820 }))?;
821 } else {
822 let mut block = format!(
823 "# Exported `{workspace_mem}`\n\n- Archive: `{}`\n- Entities: {}\n- Size: {} bytes",
824 output.display(),
825 entity_count,
826 size_bytes,
827 );
828 if args.self_contained {
829 block.push_str("\n- Self-contained: yes");
830 let n = dropped.as_ref().map(|d| d.len()).unwrap_or(0);
831 if n > 0 {
832 block.push_str(&format!("\n- Cross-mem edges dropped: {n}"));
833 }
834 }
835 print_markdown(&block);
836 }
837 Ok(())
838}
839
840#[cfg(feature = "mem-repo")]
841fn default_output_path(
842 mem_name: &str,
843 config: &memstead_schema::MemConfig,
844) -> anyhow::Result<PathBuf> {
845 let version = config.version.as_ref().ok_or_else(|| {
846 CliError::from_engine_op(memstead_base::EngineError::MemConfigIncomplete {
853 mem: mem_name.to_string(),
854 missing_fields: vec!["version".to_string()],
855 })
856 })?;
857 let filename = format!(
860 "{mem_name}-{version}.{}",
861 memstead_schema::ARCHIVE_EXTENSION
862 );
863 Ok(PathBuf::from(filename))
864}
865
866fn run_llms_txt(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
878 let engine_holder = ctx.cli_engine()?;
879 let engine = engine_holder.base();
880 let implied_mem = args
882 .root
883 .as_deref()
884 .map(|r| memstead_base::EntityId::canonical(r).mem().to_string());
885 let mem = resolve_single_mem(engine, args.mem_name.as_deref().or(implied_mem.as_deref()))?;
886
887 let ctx_opts = memstead_base::engine::export_llms_txt::LlmsTxtContext {
888 authority: None,
889 href_prefix: args
890 .base_url
891 .clone()
892 .map(|u| u.trim_end_matches('/').to_string())
893 .unwrap_or_default(),
894 wider_project: Vec::new(),
895 };
896 let chain = match chain_scope(&args) {
897 Some(scope) => Some(
898 engine
899 .chain_set(&mem, &scope)
900 .map_err(CliError::from_engine_op)?,
901 ),
902 None => None,
903 };
904 let doc = engine
905 .render_llms_txt_scoped(&mem, &ctx_opts, chain.as_ref())
906 .map_err(CliError::from_engine_op)?;
907
908 match &args.output {
909 Some(path) => {
910 std::fs::write(path, &doc).map_err(|e| {
911 CliError::new(
912 ExitKind::Generic,
913 "IO_ERROR",
914 format!("write {}: {e}", path.display()),
915 )
916 })?;
917 if ctx.json {
918 print_json(&serde_json::json!({
919 "mem": mem,
920 "written": path.display().to_string(),
921 "bytes": doc.len(),
922 }))?;
923 } else {
924 println!("Wrote {} ({} bytes)", path.display(), doc.len());
925 }
926 }
927 None => print!("{doc}"),
931 }
932 Ok(())
933}
934
935fn resolve_single_mem(
939 engine: &memstead_base::Engine,
940 requested: Option<&str>,
941) -> Result<String, CliError> {
942 if let Some(m) = requested {
943 return Ok(m.to_string());
944 }
945 let writables: Vec<String> = engine
946 .writable_mem_names()
947 .iter()
948 .map(|s| s.to_string())
949 .collect();
950 match writables.as_slice() {
951 [one] => Ok(one.clone()),
952 [] => Err(CliError::new(
953 ExitKind::Validation,
954 "INVALID_INPUT",
955 "no writable mem loaded — pass --mem <name>",
956 )),
957 _ => Err(CliError::new(
958 ExitKind::Validation,
959 "INVALID_INPUT",
960 format!(
961 "multiple writable mems loaded ({}) — pass --mem <name>",
962 writables.join(", ")
963 ),
964 )),
965 }
966}
967
968fn run_html(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
974 let engine_holder = ctx.cli_engine()?;
975 let engine = engine_holder.base();
976 let implied_mem = args
980 .root
981 .as_deref()
982 .map(|r| memstead_base::EntityId::canonical(r).mem().to_string());
983 let mem = match args.mem_name.as_ref().or(implied_mem.as_ref()) {
984 Some(m) => m.clone(),
985 None => {
986 let writables: Vec<String> = engine
987 .writable_mem_names()
988 .iter()
989 .map(|s| s.to_string())
990 .collect();
991 match writables.as_slice() {
992 [one] => one.clone(),
993 [] => {
994 return Err(CliError::new(
995 ExitKind::Validation,
996 "INVALID_INPUT",
997 "no writable mem loaded — pass --mem <name>",
998 )
999 .into());
1000 }
1001 _ => {
1002 return Err(CliError::new(
1003 ExitKind::Validation,
1004 "INVALID_INPUT",
1005 format!(
1006 "multiple writable mems loaded ({}) — pass --mem <name>",
1007 writables.join(", ")
1008 ),
1009 )
1010 .into());
1011 }
1012 }
1013 }
1014 };
1015 let now = time::OffsetDateTime::now_utc();
1016 let export_date = format!(
1017 "{:04}-{:02}-{:02}",
1018 now.year(),
1019 u8::from(now.month()),
1020 now.day()
1021 );
1022 let chain = match chain_scope(&args) {
1023 Some(scope) => Some(
1024 engine
1025 .chain_set(&mem, &scope)
1026 .map_err(CliError::from_engine_op)?,
1027 ),
1028 None => None,
1029 };
1030 let html = engine
1031 .render_html_export_scoped(&mem, &export_date, chain.as_ref())
1032 .map_err(CliError::from_engine_op)?;
1033 let out_path = args
1034 .output
1035 .clone()
1036 .unwrap_or_else(|| PathBuf::from(format!("{mem}.html")));
1037 std::fs::write(&out_path, &html).map_err(|e| {
1038 CliError::new(
1039 ExitKind::Generic,
1040 "IO_ERROR",
1041 format!("write {}: {e}", out_path.display()),
1042 )
1043 })?;
1044 if ctx.json {
1045 print_json(&serde_json::json!({
1046 "format": "html",
1047 "mem": mem,
1048 "path": out_path,
1049 "bytes": html.len(),
1050 "exported": export_date,
1051 }))?;
1052 } else {
1053 print_markdown(&format!(
1054 "# 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",
1055 out_path.display(),
1056 html.len()
1057 ));
1058 }
1059 Ok(())
1060}
1061
1062#[cfg(test)]
1063mod tests {
1064 use super::*;
1065 use clap::Parser;
1066
1067 #[test]
1070 fn export_mem_selection_flag_is_mem_not_mem_name() {
1071 let parsed = Args::try_parse_from(["export", "--mem", "specs", "--format", "mem"]).unwrap();
1072 assert_eq!(parsed.mem_name.as_deref(), Some("specs"));
1073 assert!(
1074 Args::try_parse_from(["export", "--mem-name", "specs"]).is_err(),
1075 "the retired --mem-name flag must not parse"
1076 );
1077 }
1078}