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 = "base-url", value_name = "URL")]
65 pub base_url: Option<String>,
66}
67
68#[derive(ValueEnum, Clone, Copy, Debug)]
69pub enum Format {
70 Markdown,
72 Mem,
74 Json,
76 Html,
79 LlmsTxt,
83}
84
85pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
86 if matches!(args.format, Format::Json) {
87 return run_json(ctx, args);
88 }
89 if matches!(args.format, Format::Html) {
90 return run_html(ctx, args);
91 }
92 if matches!(args.format, Format::LlmsTxt) {
93 return run_llms_txt(ctx, args);
94 }
95 match ctx.cli_engine()? {
96 #[cfg(feature = "mem-repo")]
97 CliEngine::MemRepo(engine) => match args.format {
98 Format::Markdown => run_markdown(ctx, &engine, args.mem_name.as_deref()),
99 Format::Mem => run_mem(ctx, &engine, args),
100 Format::Json => unreachable!("dispatched to run_json above"),
101 Format::Html => unreachable!("dispatched to run_html above"),
102 Format::LlmsTxt => unreachable!("dispatched to run_llms_txt above"),
103 },
104 CliEngine::Filesystem(engine) => match args.format {
105 Format::Markdown => Err(CliError::new(
111 ExitKind::Validation,
112 "INVALID_INPUT",
113 "--format markdown is not yet supported on filesystem-mem `memstead export` — entities are already on disk in their canonical form",
114 )
115 .into()),
116 Format::Mem => run_mem_filesystem(ctx, &engine, args),
117 Format::Json => unreachable!("dispatched to run_json above"),
118 Format::Html => unreachable!("dispatched to run_html above"),
119 Format::LlmsTxt => unreachable!("dispatched to run_llms_txt above"),
120 },
121 }
122}
123
124const JSON_EXPORT_FORMAT: &str = "memstead-export/v1";
128
129fn run_json(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
138 if args.output.is_some() {
142 return Err(CliError::new(
143 ExitKind::Validation,
144 "INVALID_INPUT",
145 "--output applies only to --format mem — the JSON document goes to stdout; redirect it instead",
146 )
147 .into());
148 }
149
150 let cli_engine = ctx.cli_engine()?;
151 let engine = cli_engine.base();
152
153 let all_names: Vec<String> = engine.mem_names().into_iter().map(String::from).collect();
154 let selected: Vec<String> = match &args.mem_name {
158 Some(name) => {
159 if !all_names.iter().any(|n| n == name) {
160 return Err(CliError::new(
161 ExitKind::NotFound,
162 "UNKNOWN_MEM",
163 format!(
164 "unknown mem '{name}' — loaded mems: {}",
165 all_names.join(", ")
166 ),
167 )
168 .with_details(json!({ "mem": name, "loaded": all_names }))
169 .into());
170 }
171 vec![name.clone()]
172 }
173 None => all_names
174 .iter()
175 .filter(|n| engine.mem_router().is_writable(n))
176 .cloned()
177 .collect(),
178 };
179
180 let mut mems = serde_json::Map::new();
181 for mem_name in &selected {
182 let schema_pin = engine
185 .mem_configs_named()
186 .find(|(name, _)| name == mem_name)
187 .and_then(|(_, c)| c.schema.as_ref())
188 .map(|s| s.to_string());
189
190 let mut entities: Vec<&memstead_base::Entity> = engine
191 .store()
192 .all_entities()
193 .filter(|e| !e.stub && e.mem == *mem_name)
194 .collect();
195 entities.sort_by(|a, b| a.id.as_ref().cmp(b.id.as_ref()));
196
197 let envelopes: Vec<serde_json::Value> = entities
198 .iter()
199 .map(|entity| {
200 let body = memstead_base::render::render_entity_markdown(entity, None);
201 let tokens = memstead_base::chunking::estimate_tokens(&body);
202 let outgoing = engine.store().outgoing(&entity.id);
203 memstead_base::render::build_entity_envelope(
206 entity,
207 tokens,
208 None,
209 None,
210 None,
211 engine.mem_origin_class(entity.id.mem()),
212 outgoing,
213 None,
214 None,
215 None,
216 )
217 })
218 .collect();
219
220 let mut group = serde_json::Map::new();
221 if let Some(s) = schema_pin {
222 group.insert("schema".to_string(), json!(s));
223 }
224 group.insert(
225 "read_only".to_string(),
226 json!(!engine.mem_router().is_writable(mem_name)),
227 );
228 group.insert("entity_count".to_string(), json!(envelopes.len()));
229 group.insert("entities".to_string(), serde_json::Value::Array(envelopes));
230 mems.insert(mem_name.clone(), serde_json::Value::Object(group));
231 }
232
233 print_json(&json!({
234 "format": JSON_EXPORT_FORMAT,
235 "mems": mems,
236 }))
237}
238
239#[cfg(feature = "mem-repo")]
240fn run_markdown(
241 ctx: &CliContext,
242 engine: &memstead_base::Engine,
243 mem_filter: Option<&str>,
244) -> anyhow::Result<()> {
245 let result = engine
251 .export_markdown(mem_filter, None)
252 .map_err(CliError::from_engine_op)?;
253
254 if ctx.json {
255 let mut body = json!({
256 "written": result.written,
257 "unchanged": result.unchanged,
258 });
259 if !result.skipped_mounts.is_empty() {
260 body["skipped_mounts"] = serde_json::to_value(&result.skipped_mounts)
261 .unwrap_or_else(|_| serde_json::Value::Array(Vec::new()));
262 }
263 print_json(&body)?;
264 } else {
265 let mut block = format!(
266 "# Export — markdown\n\n- Written: {}\n- Unchanged: {}",
267 result.written, result.unchanged,
268 );
269 if !result.skipped_mounts.is_empty() {
270 block.push_str("\n\n## Skipped mounts\n");
271 for m in &result.skipped_mounts {
272 block.push_str(&format!(
273 "\n- `{}` — backend `{}` ({}); use `--format mem` for archive export",
274 m.mem, m.active_backend, m.reason,
275 ));
276 }
277 }
278 print_markdown(&block);
279 }
280 Ok(())
281}
282
283#[cfg(feature = "mem-repo")]
284fn run_mem(ctx: &CliContext, engine: &memstead_base::Engine, args: Args) -> anyhow::Result<()> {
285 let mem_name = resolve_mem_name(engine, args.mem_name)?;
286 let config = engine
287 .mem_configs_named()
288 .find(|(name, _)| *name == mem_name)
289 .map(|(_, c)| c)
290 .ok_or_else(|| {
291 CliError::new(
292 ExitKind::NotFound,
293 "UNKNOWN_MEM",
294 format!("mem config not found for '{mem_name}'"),
295 )
296 })?;
297
298 let output = match args.output {
299 Some(p) => p,
300 None => default_output_path(&mem_name, config)?,
301 };
302
303 let result = engine
304 .export_mem(&mem_name, &output)
305 .map_err(CliError::from_engine_op)?;
306
307 let dangling = &result.dangling_cross_mem_edges;
312
313 if ctx.json {
314 let warnings: Vec<_> = dangling
315 .iter()
316 .map(|e| {
317 json!({
318 "code": "DANGLING_CROSS_MEM_EDGE_IN_EXPORT",
319 "entity": e.entity_path,
320 "target_id": e.target_id,
321 "target_mem": e.target_mem,
322 })
323 })
324 .collect();
325 print_json(&json!({
326 "archive_path": result.archive_path,
327 "name": result.name,
328 "version": result.version,
329 "entity_count": result.entity_count,
330 "size_bytes": result.size_bytes,
331 "warnings": warnings,
332 }))?;
333 } else {
334 let mut block = format!(
335 "# Exported `{}` v{}\n\n- Archive: `{}`\n- Entities: {}\n- Size: {} bytes",
336 result.name,
337 result.version,
338 result.archive_path,
339 result.entity_count,
340 result.size_bytes,
341 );
342 if !dangling.is_empty() {
343 block.push_str("\n\n## Warnings\n");
344 for e in dangling {
345 block.push_str(&format!(
346 "\n- **DANGLING_CROSS_MEM_EDGE_IN_EXPORT**: `{}` → `{}` (mem `{}`) — \
347 target lives outside this archive; `memstead install` will reject it unless \
348 mem `{}` is also present.",
349 e.entity_path, e.target_id, e.target_mem, e.target_mem,
350 ));
351 }
352 }
353 print_markdown(&block);
354 }
355 Ok(())
356}
357
358#[cfg(feature = "mem-repo")]
359fn resolve_mem_name(
360 engine: &memstead_base::Engine,
361 explicit: Option<String>,
362) -> anyhow::Result<String> {
363 if let Some(name) = explicit {
364 return Ok(name);
365 }
366 let writable: Vec<String> = engine
367 .mem_configs_named()
368 .filter(|(name, _)| engine.mem_router().is_writable(name))
369 .map(|(name, _)| name.to_string())
370 .collect();
371
372 match writable.len() {
373 0 => Err(CliError::new(
374 ExitKind::Generic,
375 "NO_WRITABLE_MEM",
376 "no writable mem loaded — nothing to export",
377 )
378 .into()),
379 1 => Ok(writable.into_iter().next().unwrap()),
380 _ => Err(CliError::new(
381 ExitKind::Validation,
382 "AMBIGUOUS_MEM",
383 format!(
384 "multiple writable mems loaded ({}); pass --mem <name>",
385 writable.join(", ")
386 ),
387 )
388 .with_details(json!({ "mems": writable }))
389 .into()),
390 }
391}
392
393fn run_mem_filesystem(
400 ctx: &CliContext,
401 engine: &memstead_base::Engine,
402 args: Args,
403) -> anyhow::Result<()> {
404 let workspace_mem = engine
405 .mem_names()
406 .into_iter()
407 .next()
408 .map(String::from)
409 .unwrap_or_default();
410 if let Some(name) = args.mem_name.as_deref()
411 && name != workspace_mem
412 {
413 return Err(CliError::new(
414 ExitKind::NotFound,
415 "UNKNOWN_MEM",
416 format!(
417 "filesystem-mem is single-mem: workspace mem is `{workspace_mem}`, --mem `{name}` does not match"
418 ),
419 )
420 .into());
421 }
422
423 let workspace_root =
426 crate::setup::find_filesystem_workspace_root(&std::env::current_dir().map_err(|e| {
427 CliError::new(
428 ExitKind::Generic,
429 crate::INTERNAL_CODE,
430 format!("current_dir: {e}"),
431 )
432 })?)
433 .ok_or_else(|| {
434 CliError::new(
435 ExitKind::NotFound,
436 "WORKSPACE_NOT_INITIALISED",
437 "no filesystem-mem workspace found from cwd",
438 )
439 })?;
440 let bytes =
441 memstead_base::filesystem::publish::assemble_archive(&workspace_root).map_err(|e| {
442 if matches!(
446 &e,
447 memstead_base::filesystem::publish::AssembleError::Config(
448 memstead_schema::PublishConversionError::MissingVersion
449 )
450 ) {
451 CliError::from_engine_op(memstead_base::EngineError::MemConfigIncomplete {
452 mem: workspace_mem.clone(),
453 missing_fields: vec!["version".to_string()],
454 })
455 } else {
456 CliError::new(ExitKind::Generic, "ARCHIVE_ASSEMBLY_FAILED", e.to_string())
457 }
458 })?;
459
460 let output = match args.output {
461 Some(p) => p,
462 None => {
463 PathBuf::from(format!(
468 "{workspace_mem}.{}",
469 memstead_schema::ARCHIVE_EXTENSION
470 ))
471 }
472 };
473
474 let size_bytes = bytes.len();
475 std::fs::write(&output, &bytes).map_err(|e| {
476 CliError::new(
477 ExitKind::Generic,
478 crate::INTERNAL_CODE,
479 format!("write {}: {e}", output.display()),
480 )
481 })?;
482 let entity_count = engine
486 .store()
487 .all_entities()
488 .filter(|e| !e.stub && e.id.mem() == workspace_mem)
489 .count();
490
491 if ctx.json {
492 print_json(&json!({
493 "archive_path": output.to_string_lossy(),
494 "name": workspace_mem,
495 "entity_count": entity_count,
496 "size_bytes": size_bytes,
497 }))?;
498 } else {
499 print_markdown(&format!(
500 "# Exported `{workspace_mem}`\n\n- Archive: `{}`\n- Entities: {}\n- Size: {} bytes",
501 output.display(),
502 entity_count,
503 size_bytes,
504 ));
505 }
506 Ok(())
507}
508
509#[cfg(feature = "mem-repo")]
510fn default_output_path(
511 mem_name: &str,
512 config: &memstead_schema::MemConfig,
513) -> anyhow::Result<PathBuf> {
514 let version = config.version.as_ref().ok_or_else(|| {
515 CliError::from_engine_op(memstead_base::EngineError::MemConfigIncomplete {
522 mem: mem_name.to_string(),
523 missing_fields: vec!["version".to_string()],
524 })
525 })?;
526 let filename = format!(
529 "{mem_name}-{version}.{}",
530 memstead_schema::ARCHIVE_EXTENSION
531 );
532 Ok(PathBuf::from(filename))
533}
534
535fn run_llms_txt(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
547 let engine_holder = ctx.cli_engine()?;
548 let engine = engine_holder.base();
549 let mem = resolve_single_mem(engine, args.mem_name.as_deref())?;
550
551 let ctx_opts = memstead_base::engine::export_llms_txt::LlmsTxtContext {
552 authority: None,
553 href_prefix: args
554 .base_url
555 .clone()
556 .map(|u| u.trim_end_matches('/').to_string())
557 .unwrap_or_default(),
558 wider_project: Vec::new(),
559 };
560 let doc = engine
561 .render_llms_txt(&mem, &ctx_opts)
562 .map_err(CliError::from_engine_op)?;
563
564 match &args.output {
565 Some(path) => {
566 std::fs::write(path, &doc).map_err(|e| {
567 CliError::new(
568 ExitKind::Generic,
569 "IO_ERROR",
570 format!("write {}: {e}", path.display()),
571 )
572 })?;
573 if ctx.json {
574 print_json(&serde_json::json!({
575 "mem": mem,
576 "written": path.display().to_string(),
577 "bytes": doc.len(),
578 }))?;
579 } else {
580 println!("Wrote {} ({} bytes)", path.display(), doc.len());
581 }
582 }
583 None => print!("{doc}"),
587 }
588 Ok(())
589}
590
591fn resolve_single_mem(
595 engine: &memstead_base::Engine,
596 requested: Option<&str>,
597) -> Result<String, CliError> {
598 if let Some(m) = requested {
599 return Ok(m.to_string());
600 }
601 let writables: Vec<String> = engine
602 .writable_mem_names()
603 .iter()
604 .map(|s| s.to_string())
605 .collect();
606 match writables.as_slice() {
607 [one] => Ok(one.clone()),
608 [] => Err(CliError::new(
609 ExitKind::Validation,
610 "INVALID_INPUT",
611 "no writable mem loaded — pass --mem <name>",
612 )),
613 _ => Err(CliError::new(
614 ExitKind::Validation,
615 "INVALID_INPUT",
616 format!(
617 "multiple writable mems loaded ({}) — pass --mem <name>",
618 writables.join(", ")
619 ),
620 )),
621 }
622}
623
624fn run_html(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
630 let engine_holder = ctx.cli_engine()?;
631 let engine = engine_holder.base();
632 let mem = match &args.mem_name {
635 Some(m) => m.clone(),
636 None => {
637 let writables: Vec<String> = engine
638 .writable_mem_names()
639 .iter()
640 .map(|s| s.to_string())
641 .collect();
642 match writables.as_slice() {
643 [one] => one.clone(),
644 [] => {
645 return Err(CliError::new(
646 ExitKind::Validation,
647 "INVALID_INPUT",
648 "no writable mem loaded — pass --mem <name>",
649 )
650 .into());
651 }
652 _ => {
653 return Err(CliError::new(
654 ExitKind::Validation,
655 "INVALID_INPUT",
656 format!(
657 "multiple writable mems loaded ({}) — pass --mem <name>",
658 writables.join(", ")
659 ),
660 )
661 .into());
662 }
663 }
664 }
665 };
666 let now = time::OffsetDateTime::now_utc();
667 let export_date = format!(
668 "{:04}-{:02}-{:02}",
669 now.year(),
670 u8::from(now.month()),
671 now.day()
672 );
673 let html = engine
674 .render_html_export(&mem, &export_date)
675 .map_err(CliError::from_engine_op)?;
676 let out_path = args
677 .output
678 .clone()
679 .unwrap_or_else(|| PathBuf::from(format!("{mem}.html")));
680 std::fs::write(&out_path, &html).map_err(|e| {
681 CliError::new(
682 ExitKind::Generic,
683 "IO_ERROR",
684 format!("write {}: {e}", out_path.display()),
685 )
686 })?;
687 if ctx.json {
688 print_json(&serde_json::json!({
689 "format": "html",
690 "mem": mem,
691 "path": out_path,
692 "bytes": html.len(),
693 "exported": export_date,
694 }))?;
695 } else {
696 print_markdown(&format!(
697 "# 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",
698 out_path.display(),
699 html.len()
700 ));
701 }
702 Ok(())
703}
704
705#[cfg(test)]
706mod tests {
707 use super::*;
708 use clap::Parser;
709
710 #[test]
713 fn export_mem_selection_flag_is_mem_not_mem_name() {
714 let parsed = Args::try_parse_from(["export", "--mem", "specs", "--format", "mem"]).unwrap();
715 assert_eq!(parsed.mem_name.as_deref(), Some("specs"));
716 assert!(
717 Args::try_parse_from(["export", "--mem-name", "specs"]).is_err(),
718 "the retired --mem-name flag must not parse"
719 );
720 }
721}