1use std::path::PathBuf;
14
15use clap::Parser;
16use indexmap::IndexMap;
17use serde::Deserialize;
18
19#[cfg(feature = "mem-repo")]
20use memstead_base::ops::PatchArg;
21use memstead_base::vcs::Actor;
22use memstead_base::{EntityId, UpdateEntityArgs};
23
24use crate::CliError;
25use crate::output::{ExitKind, print_json, print_markdown};
26use crate::setup::{CliContext, CliEngine};
27
28#[derive(Parser, Debug)]
29pub struct Args {
30 pub id: Option<String>,
32
33 #[arg(long = "expected-hash", value_name = "HASH")]
38 pub expected_hash: Option<String>,
39
40 #[arg(long, conflicts_with_all = ["expected_hash", "force"])]
44 pub auto_hash: bool,
45
46 #[arg(long, conflicts_with_all = ["expected_hash", "auto_hash"])]
48 pub force: bool,
49
50 #[arg(long = "section", value_name = "KEY=VALUE", conflicts_with = "from")]
55 pub sections: Vec<String>,
56
57 #[arg(long = "append", value_name = "KEY=VALUE", conflicts_with = "from")]
59 pub append: Vec<String>,
60
61 #[arg(long = "patch", value_name = "KEY=OLD=>NEW", conflicts_with = "from")]
65 pub patch: Vec<String>,
66
67 #[arg(
70 long = "patch-all",
71 value_name = "KEY=OLD=>NEW",
72 conflicts_with = "from"
73 )]
74 pub patch_all: Vec<String>,
75
76 #[arg(long = "metadata", value_name = "KEY=VALUE", conflicts_with = "from")]
78 pub metadata: Vec<String>,
79
80 #[arg(long = "metadata-unset", value_name = "KEY", conflicts_with = "from")]
85 pub metadata_unset: Vec<String>,
86
87 #[arg(
100 long = "declare-relations",
101 value_name = "REL_TYPE:TARGET_ID",
102 conflicts_with = "from"
103 )]
104 pub declare_relations: Vec<String>,
105
106 #[arg(long = "anchor", value_name = "JSON", conflicts_with = "from")]
113 pub anchors: Vec<String>,
114
115 #[arg(long = "anchor-unset", value_name = "JSON", conflicts_with = "from")]
124 pub anchors_unset: Vec<String>,
125
126 #[arg(long)]
130 pub dry_run: bool,
131
132 #[arg(long = "from", value_name = "FILE")]
143 pub from: Option<PathBuf>,
144
145 #[arg(long)]
149 pub note: Option<String>,
150}
151
152fn parse_anchor_unset_list(
158 items: &[String],
159) -> anyhow::Result<Vec<memstead_base::anchor::AnchorUnsetInput>> {
160 let mut out = Vec::with_capacity(items.len());
161 for raw in items {
162 let unset: memstead_base::anchor::AnchorUnsetInput =
163 serde_json::from_str(raw).map_err(|e| {
164 CliError::new(
165 ExitKind::Validation,
166 "INVALID_INPUT",
167 format!("--anchor-unset: expected a JSON selector object, got `{raw}`: {e}"),
168 )
169 })?;
170 out.push(unset);
171 }
172 Ok(out)
173}
174
175#[derive(Debug, Deserialize)]
178#[serde(deny_unknown_fields)]
179struct UpdatePayload {
180 id: String,
181 expected_hash: Option<String>,
182 #[serde(default)]
183 sections: IndexMap<String, String>,
184 #[serde(default)]
185 append_sections: IndexMap<String, String>,
186 #[serde(default)]
187 patch_sections: IndexMap<String, PatchPayload>,
188 #[serde(default)]
189 metadata: IndexMap<String, String>,
190 #[serde(default)]
191 metadata_unset: Vec<String>,
192 #[serde(default)]
193 declare_relations: Vec<DeclareRelationPayload>,
194 #[serde(default)]
199 anchors: Vec<memstead_base::anchor::AnchorInput>,
200 #[serde(default)]
203 anchors_unset: Vec<memstead_base::anchor::AnchorUnsetInput>,
204 #[serde(default)]
205 dry_run: bool,
206 #[serde(default)]
214 note: Option<String>,
215 #[serde(default)]
221 title: Option<String>,
222 #[serde(default)]
226 entity_type: Option<String>,
227 #[serde(default)]
231 mem: Option<String>,
232}
233
234#[derive(Debug, Deserialize, Clone)]
235#[serde(deny_unknown_fields)]
236#[cfg_attr(not(feature = "mem-repo"), allow(dead_code))]
237struct DeclareRelationPayload {
238 to: String,
240 rel_type: String,
243 #[serde(default)]
246 description: Option<String>,
247}
248
249#[derive(Debug, Deserialize)]
250#[serde(deny_unknown_fields)]
251#[cfg_attr(not(feature = "mem-repo"), allow(dead_code))]
252struct PatchPayload {
253 old: String,
254 new: String,
255 #[serde(default)]
256 all: bool,
257}
258
259fn check_template_identity(
265 entity: Option<&memstead_base::Entity>,
266 payload_title: Option<&str>,
267 payload_type: Option<&str>,
268) -> Result<(), CliError> {
269 let Some(entity) = entity else {
270 return Ok(());
271 };
272 if let Some(t) = payload_title
273 && t != entity.title
274 {
275 return Err(CliError::new(
276 ExitKind::Validation,
277 "INVALID_INPUT",
278 format!(
279 "template `title` {t:?} differs from the entity's current title {:?} — \
280 update cannot rename; use `memstead rename`",
281 entity.title
282 ),
283 ));
284 }
285 if let Some(ty) = payload_type
286 && ty != entity.entity_type
287 {
288 return Err(CliError::new(
289 ExitKind::Validation,
290 "INVALID_INPUT",
291 format!(
292 "template `entity_type` {ty:?} differs from the entity's current type {:?} — \
293 update cannot retype; delete + create instead",
294 entity.entity_type
295 ),
296 ));
297 }
298 Ok(())
299}
300
301pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
302 let payload = if let Some(ref file) = args.from {
303 let bytes = std::fs::read(file).map_err(|e| {
304 CliError::new(
305 ExitKind::Generic,
306 "INVALID_INPUT",
307 format!("failed to read {}: {e}", file.display()),
308 )
309 })?;
310 let mut parsed: UpdatePayload = serde_json::from_slice(&bytes).map_err(|e| {
311 CliError::new(
312 ExitKind::Validation,
313 "INVALID_INPUT",
314 format!("invalid JSON in {}: {e}", file.display()),
315 )
316 .with_details(serde_json::json!({
317 "path": file.display().to_string(),
318 "parser_error": e.to_string(),
319 }))
320 })?;
321 parsed.dry_run |= args.dry_run;
326 if args.expected_hash.is_some() {
327 parsed.expected_hash = args.expected_hash.clone();
328 }
329 parsed
330 } else {
331 let id = args.id.clone().ok_or_else(|| {
332 CliError::new(
333 ExitKind::Validation,
334 "INVALID_INPUT",
335 "missing entity ID (or pass --from <file.json>)",
336 )
337 })?;
338 UpdatePayload {
339 id,
340 expected_hash: args.expected_hash.clone(),
341 sections: parse_kv_list(&args.sections, "--section")?,
342 append_sections: parse_kv_list(&args.append, "--append")?,
343 patch_sections: parse_patch_list_combined(&args.patch, &args.patch_all)?,
344 metadata: parse_kv_list(&args.metadata, "--metadata")?,
345 metadata_unset: args.metadata_unset.clone(),
346 declare_relations: parse_declare_relations(&args.declare_relations)?,
347 anchors: super::create::parse_anchor_list(&args.anchors)?,
348 anchors_unset: parse_anchor_unset_list(&args.anchors_unset)?,
349 dry_run: args.dry_run,
350 note: None,
351 title: None,
352 entity_type: None,
353 mem: None,
354 }
355 };
356
357 let note = args.note.clone().or_else(|| payload.note.clone());
360
361 let entity_id = EntityId::canonical(&payload.id);
362
363 if let Some(m) = payload.mem.as_deref()
369 && m != entity_id.mem()
370 {
371 return Err(CliError::new(
372 ExitKind::Validation,
373 "INVALID_INPUT",
374 format!(
375 "template `mem` {m:?} does not match the mem in id `{entity_id}` — update cannot move an entity between mems (delete + create instead)"
376 ),
377 )
378 .into());
379 }
380
381 match ctx.cli_engine()? {
382 #[cfg(feature = "mem-repo")]
383 CliEngine::MemRepo(mut engine) => {
384 check_template_identity(
385 engine.get_entity(&entity_id),
386 payload.title.as_deref(),
387 payload.entity_type.as_deref(),
388 )?;
389 let expected_hash = resolve_hash_mem_repo(
390 &engine,
391 &entity_id,
392 payload.expected_hash,
393 args.auto_hash,
394 args.force,
395 )?;
396
397 let patch_sections = payload
398 .patch_sections
399 .into_iter()
400 .map(|(k, v)| {
401 (
402 k,
403 PatchArg {
404 old: v.old,
405 new: v.new,
406 all: v.all,
407 },
408 )
409 })
410 .collect();
411
412 let declare_relations: Vec<memstead_base::ops::RelateArg> = payload
413 .declare_relations
414 .iter()
415 .map(|r| memstead_base::ops::RelateArg {
416 to: EntityId::canonical(&r.to),
417 rel_type: r.rel_type.clone(),
418 description: r.description.clone(),
419 })
420 .collect();
421 let update_args = UpdateEntityArgs {
422 anchors: payload.anchors,
423 id: entity_id.clone(),
424 expected_hash: Some(expected_hash),
425 sections: payload.sections,
426 append_sections: payload.append_sections,
427 patch_sections,
428 metadata: payload.metadata,
429 metadata_unset: payload.metadata_unset,
430 dry_run: payload.dry_run,
431 declare_relations,
432 relations_unset: Vec::new(),
433 anchors_unset: payload.anchors_unset,
434 };
435
436 let result = engine
437 .update_entity_with_ctx(update_args, &crate::setup::cli_ctx_with_note(note.clone()))
438 .map_err(CliError::from_engine_op)?;
439 let mem_changed = engine.take_mem_changed_notices();
440
441 if ctx.json {
442 let mut body = serde_json::to_value(&result).unwrap_or(serde_json::Value::Null);
443 super::merge_mem_changed_json(&mut body, &mem_changed);
444 print_json(&body)?;
445 } else {
446 let header = if payload.dry_run {
447 format!("# Dry-run `{}`", result.id)
448 } else {
449 format!("# Updated `{}`", result.id)
450 };
451 let sections_line = render_section_mutations(&result.modified_sections);
452 let metadata_line = render_metadata_mutations(&result.modified_metadata);
453 let mut body = format!("{header}\n\n- Title: {}", result.title);
454 if let Some(line) = sections_line {
455 body.push_str(&format!("\n- Sections: {line}"));
456 }
457 if let Some(line) = metadata_line {
458 body.push_str(&format!("\n- Metadata: {line}"));
459 }
460 if !result.relations_declared.is_empty() {
461 let parts: Vec<String> = result
462 .relations_declared
463 .iter()
464 .map(|r| {
465 let stubbed_tag = if r.target_was_stubbed {
466 " (stubbed)"
467 } else {
468 ""
469 };
470 format!("{} → {}{}", r.rel_type, r.target, stubbed_tag)
471 })
472 .collect();
473 body.push_str(&format!("\n- Relations declared: {}", parts.join(", ")));
474 }
475 if !result.orphan_stubs_removed.is_empty() {
476 let ids: Vec<String> = result
477 .orphan_stubs_removed
478 .iter()
479 .map(|i| i.to_string())
480 .collect();
481 body.push_str(&format!("\n- Orphan stubs GC'd: {}", ids.join(", ")));
482 }
483 if !result.warnings.is_empty() {
484 let parts: Vec<String> =
485 result.warnings.iter().map(|w| w.to_string()).collect();
486 body.push_str(&format!("\n- Warnings: {}", parts.join("; ")));
487 }
488 body.push_str(&format!("\n- Hash: `{}`", result.content_hash));
489 body.push_str(&super::render_mem_changed_block(&mem_changed));
490 print_markdown(&body);
491 }
492 }
493 CliEngine::Filesystem(mut engine) => {
494 check_template_identity(
495 engine.get_entity(&entity_id),
496 payload.title.as_deref(),
497 payload.entity_type.as_deref(),
498 )?;
499 if !payload.append_sections.is_empty() {
506 return Err(CliError::new(
507 ExitKind::Validation,
508 "INVALID_INPUT",
509 "--append is not yet supported on filesystem-mem `memstead update`",
510 )
511 .into());
512 }
513 if !payload.patch_sections.is_empty() {
514 return Err(CliError::new(
515 ExitKind::Validation,
516 "INVALID_INPUT",
517 "--patch / --patch-all are not yet supported on filesystem-mem `memstead update`",
518 )
519 .into());
520 }
521 if payload.dry_run {
522 return Err(CliError::new(
523 ExitKind::Validation,
524 "INVALID_INPUT",
525 "--dry-run is not yet supported on filesystem-mem `memstead update`",
526 )
527 .into());
528 }
529
530 let expected_hash = resolve_hash_filesystem(
531 &engine,
532 &entity_id,
533 payload.expected_hash,
534 args.auto_hash,
535 args.force,
536 )?;
537
538 let declare_relations: Vec<memstead_base::ops::RelateArg> = payload
539 .declare_relations
540 .iter()
541 .map(|r| memstead_base::ops::RelateArg {
542 to: EntityId::canonical(&r.to),
543 rel_type: r.rel_type.clone(),
544 description: r.description.clone(),
545 })
546 .collect();
547 let update_args = UpdateEntityArgs {
548 anchors: payload.anchors,
549 id: entity_id.clone(),
550 expected_hash: Some(expected_hash),
551 sections: payload.sections,
552 append_sections: IndexMap::new(),
556 patch_sections: IndexMap::new(),
557 metadata: payload.metadata,
558 metadata_unset: payload.metadata_unset,
559 declare_relations,
560 dry_run: false,
561 relations_unset: Vec::new(),
562 anchors_unset: payload.anchors_unset,
563 };
564 let outcome = engine
565 .update_entity(
566 update_args,
567 Actor::Cli,
568 Some(&crate::setup::cli_client_id()),
569 note.as_deref(),
570 )
571 .map_err(CliError::from_engine_op)?;
572
573 if ctx.json {
574 let relations_declared: Vec<serde_json::Value> = outcome
575 .relations_declared
576 .iter()
577 .map(|r| {
578 serde_json::json!({
579 "rel_type": r.rel_type,
580 "target": r.target.to_string(),
581 "target_was_stubbed": r.target_was_stubbed,
582 })
583 })
584 .collect();
585 print_json(&serde_json::json!({
586 "id": outcome.id.as_ref(),
587 "file_path": outcome.file_path,
588 "_hash": outcome.content_hash,
589 "modified_sections": outcome.modified_sections.replaced,
590 "modified_metadata_set": outcome.modified_metadata.set,
591 "modified_metadata_unset": outcome.modified_metadata.unset,
592 "relations_declared": relations_declared,
593 "warnings": outcome.warnings,
596 "orphan_stubs_removed": outcome
597 .orphan_stubs_removed
598 .iter()
599 .map(|i| i.to_string())
600 .collect::<Vec<_>>(),
601 }))?;
602 } else {
603 let mut body = format!("# Updated `{}`", outcome.id);
604 if !outcome.modified_sections.replaced.is_empty() {
605 let parts: Vec<String> = outcome
606 .modified_sections
607 .replaced
608 .iter()
609 .map(|k| format!("{k} (replaced)"))
610 .collect();
611 body.push_str(&format!("\n- Sections: {}", parts.join(", ")));
612 }
613 if !outcome.modified_metadata.set.is_empty()
614 || !outcome.modified_metadata.unset.is_empty()
615 {
616 let mut parts = Vec::new();
617 for k in &outcome.modified_metadata.set {
618 parts.push(format!("{k} (set)"));
619 }
620 for k in &outcome.modified_metadata.unset {
621 parts.push(format!("{k} (unset)"));
622 }
623 body.push_str(&format!("\n- Metadata: {}", parts.join(", ")));
624 }
625 if !outcome.relations_declared.is_empty() {
626 let parts: Vec<String> = outcome
627 .relations_declared
628 .iter()
629 .map(|r| {
630 let stubbed_tag = if r.target_was_stubbed {
631 " (stubbed)"
632 } else {
633 ""
634 };
635 format!("{} → {}{}", r.rel_type, r.target, stubbed_tag)
636 })
637 .collect();
638 body.push_str(&format!("\n- Relations declared: {}", parts.join(", ")));
639 }
640 if !outcome.orphan_stubs_removed.is_empty() {
641 let ids: Vec<String> = outcome
642 .orphan_stubs_removed
643 .iter()
644 .map(|i| i.to_string())
645 .collect();
646 body.push_str(&format!("\n- Orphan stubs GC'd: {}", ids.join(", ")));
647 }
648 if !outcome.warnings.is_empty() {
649 let parts: Vec<String> =
650 outcome.warnings.iter().map(|w| w.to_string()).collect();
651 body.push_str(&format!("\n- Warnings: {}", parts.join("; ")));
652 }
653 body.push_str(&format!("\n- Hash: `{}`", outcome.content_hash));
654 print_markdown(&body);
655 }
656 }
657 }
658 Ok(())
659}
660
661#[cfg(feature = "mem-repo")]
664fn render_section_mutations(m: &memstead_git_branch::ModifiedSections) -> Option<String> {
665 let mut parts = Vec::new();
666 for k in &m.replaced {
667 parts.push(format!("{k} (replaced)"));
668 }
669 for k in &m.appended {
670 parts.push(format!("{k} (appended)"));
671 }
672 for k in &m.patched {
673 parts.push(format!("{k} (patched)"));
674 }
675 if parts.is_empty() {
676 None
677 } else {
678 Some(parts.join(", "))
679 }
680}
681
682#[cfg(feature = "mem-repo")]
684fn render_metadata_mutations(m: &memstead_git_branch::ModifiedMetadata) -> Option<String> {
685 let mut parts = Vec::new();
686 for k in &m.set {
687 parts.push(format!("{k} (set)"));
688 }
689 for k in &m.unset {
690 parts.push(format!("{k} (unset)"));
691 }
692 if parts.is_empty() {
693 None
694 } else {
695 Some(parts.join(", "))
696 }
697}
698
699#[cfg(feature = "mem-repo")]
710fn resolve_hash_mem_repo(
711 engine: &memstead_base::Engine,
712 id: &EntityId,
713 explicit: Option<String>,
714 auto_hash: bool,
715 force: bool,
716) -> anyhow::Result<String> {
717 if auto_hash || force {
718 let entity = engine.get_entity(id).ok_or_else(|| {
719 CliError::new(
720 ExitKind::NotFound,
721 "ENTITY_NOT_FOUND",
722 format!("entity not found: {id}"),
723 )
724 .with_details(serde_json::json!({ "id": id.to_string() }))
725 })?;
726 return Ok(entity.content_hash.clone());
727 }
728 require_explicit_hash(explicit)
729}
730
731fn resolve_hash_filesystem(
734 engine: &memstead_base::Engine,
735 id: &EntityId,
736 explicit: Option<String>,
737 auto_hash: bool,
738 force: bool,
739) -> anyhow::Result<String> {
740 if auto_hash || force {
741 let entity = engine.get_entity(id).ok_or_else(|| {
742 CliError::new(
743 ExitKind::NotFound,
744 "ENTITY_NOT_FOUND",
745 format!("entity not found: {id}"),
746 )
747 .with_details(serde_json::json!({ "id": id.to_string() }))
748 })?;
749 return Ok(entity.content_hash.clone());
750 }
751 require_explicit_hash(explicit)
752}
753
754fn require_explicit_hash(explicit: Option<String>) -> anyhow::Result<String> {
755 match explicit {
756 Some(h) if !h.is_empty() => Ok(h),
757 _ => Err(CliError::new(
758 ExitKind::Validation,
759 crate::HASH_FLAG_REQUIRED_CODE,
760 "missing --expected-hash. Read the entity first (memstead entity <id>) and pass its `_hash`, \
761 or use --auto-hash for one-off interactive updates, or --force to overwrite.",
762 )
763 .into()),
764 }
765}
766
767fn parse_declare_relations(items: &[String]) -> anyhow::Result<Vec<DeclareRelationPayload>> {
775 let mut out = Vec::with_capacity(items.len());
776 for raw in items {
777 let (rel_type, target) = raw.split_once(':').ok_or_else(|| {
778 CliError::new(
779 ExitKind::Validation,
780 "INVALID_INPUT",
781 format!("--declare-relations: expected REL_TYPE:TARGET_ID, got `{raw}`"),
782 )
783 })?;
784 if rel_type.is_empty() || target.is_empty() {
785 return Err(CliError::new(
786 ExitKind::Validation,
787 "INVALID_INPUT",
788 format!(
789 "--declare-relations: REL_TYPE and TARGET_ID must both be non-empty, got `{raw}`"
790 ),
791 )
792 .into());
793 }
794 out.push(DeclareRelationPayload {
795 to: target.to_string(),
796 rel_type: rel_type.to_string(),
797 description: None,
798 });
799 }
800 Ok(out)
801}
802
803fn parse_kv_list(items: &[String], flag: &str) -> anyhow::Result<IndexMap<String, String>> {
804 let mut out = IndexMap::with_capacity(items.len());
805 for raw in items {
806 let (k, v) = raw.split_once('=').ok_or_else(|| {
807 CliError::new(
808 ExitKind::Validation,
809 "INVALID_INPUT",
810 format!("{flag}: expected KEY=VALUE, got `{raw}`"),
811 )
812 })?;
813 out.insert(k.to_string(), v.to_string());
814 }
815 Ok(out)
816}
817
818fn parse_patch_list_combined(
819 first_only: &[String],
820 all: &[String],
821) -> anyhow::Result<IndexMap<String, PatchPayload>> {
822 let mut out = IndexMap::with_capacity(first_only.len() + all.len());
823 for (items, flag, replace_all) in [(first_only, "--patch", false), (all, "--patch-all", true)] {
824 for raw in items {
825 let (key, rest) = raw.split_once('=').ok_or_else(|| {
826 CliError::new(
827 ExitKind::Validation,
828 "INVALID_INPUT",
829 format!("{flag}: expected KEY=OLD=>NEW, got `{raw}`"),
830 )
831 })?;
832 let (old, new) = rest.split_once("=>").ok_or_else(|| {
833 CliError::new(
834 ExitKind::Validation,
835 "INVALID_INPUT",
836 format!("{flag}: expected KEY=OLD=>NEW (missing `=>`), got `{raw}`"),
837 )
838 })?;
839 if out.contains_key(key) {
840 return Err(CliError::new(
841 ExitKind::Validation,
842 "INVALID_INPUT",
843 format!(
844 "duplicate patch for section `{key}` -- only one of --patch / --patch-all per section"
845 ),
846 )
847 .into());
848 }
849 out.insert(
850 key.to_string(),
851 PatchPayload {
852 old: old.to_string(),
853 new: new.to_string(),
854 all: replace_all,
855 },
856 );
857 }
858 }
859 Ok(out)
860}