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")]
36 pub expected_hash: Option<String>,
37
38 #[arg(long, conflicts_with_all = ["expected_hash", "force"])]
42 pub auto_hash: bool,
43
44 #[arg(long, conflicts_with_all = ["expected_hash", "auto_hash"])]
46 pub force: bool,
47
48 #[arg(long = "section", value_name = "KEY=VALUE")]
53 pub sections: Vec<String>,
54
55 #[arg(long = "append", value_name = "KEY=VALUE")]
57 pub append: Vec<String>,
58
59 #[arg(long = "patch", value_name = "KEY=OLD=>NEW")]
63 pub patch: Vec<String>,
64
65 #[arg(long = "patch-all", value_name = "KEY=OLD=>NEW")]
68 pub patch_all: Vec<String>,
69
70 #[arg(long = "metadata", value_name = "KEY=VALUE")]
72 pub metadata: Vec<String>,
73
74 #[arg(long = "metadata-unset", value_name = "KEY")]
79 pub metadata_unset: Vec<String>,
80
81 #[arg(long = "declare-relations", value_name = "REL_TYPE:TARGET_ID")]
94 pub declare_relations: Vec<String>,
95
96 #[arg(long = "anchor", value_name = "JSON")]
103 pub anchors: Vec<String>,
104
105 #[arg(long)]
107 pub dry_run: bool,
108
109 #[arg(long = "from", value_name = "FILE")]
112 pub from: Option<PathBuf>,
113
114 #[arg(long)]
118 pub note: Option<String>,
119}
120
121#[derive(Debug, Deserialize)]
124#[serde(deny_unknown_fields)]
125struct UpdatePayload {
126 id: String,
127 expected_hash: Option<String>,
128 #[serde(default)]
129 sections: IndexMap<String, String>,
130 #[serde(default)]
131 append_sections: IndexMap<String, String>,
132 #[serde(default)]
133 patch_sections: IndexMap<String, PatchPayload>,
134 #[serde(default)]
135 metadata: IndexMap<String, String>,
136 #[serde(default)]
137 metadata_unset: Vec<String>,
138 #[serde(default)]
139 declare_relations: Vec<DeclareRelationPayload>,
140 #[serde(default)]
144 anchors: Vec<memstead_base::anchor::AnchorInput>,
145 #[serde(default)]
146 dry_run: bool,
147}
148
149#[derive(Debug, Deserialize, Clone)]
150#[serde(deny_unknown_fields)]
151#[cfg_attr(not(feature = "mem-repo"), allow(dead_code))]
152struct DeclareRelationPayload {
153 to: String,
155 rel_type: String,
158 #[serde(default)]
161 description: Option<String>,
162}
163
164#[derive(Debug, Deserialize)]
165#[serde(deny_unknown_fields)]
166#[cfg_attr(not(feature = "mem-repo"), allow(dead_code))]
167struct PatchPayload {
168 old: String,
169 new: String,
170 #[serde(default)]
171 all: bool,
172}
173
174pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
175 let payload = if let Some(ref file) = args.from {
176 let bytes = std::fs::read(file).map_err(|e| {
177 CliError::new(
178 ExitKind::Generic,
179 "INVALID_INPUT",
180 format!("failed to read {}: {e}", file.display()),
181 )
182 })?;
183 let parsed: UpdatePayload = serde_json::from_slice(&bytes).map_err(|e| {
184 CliError::new(
185 ExitKind::Validation,
186 "INVALID_INPUT",
187 format!("invalid JSON in {}: {e}", file.display()),
188 )
189 .with_details(serde_json::json!({
190 "path": file.display().to_string(),
191 "parser_error": e.to_string(),
192 }))
193 })?;
194 parsed
195 } else {
196 let id = args.id.clone().ok_or_else(|| {
197 CliError::new(
198 ExitKind::Validation,
199 "INVALID_INPUT",
200 "missing entity ID (or pass --from <file.json>)",
201 )
202 })?;
203 UpdatePayload {
204 id,
205 expected_hash: args.expected_hash.clone(),
206 sections: parse_kv_list(&args.sections, "--section")?,
207 append_sections: parse_kv_list(&args.append, "--append")?,
208 patch_sections: parse_patch_list_combined(&args.patch, &args.patch_all)?,
209 metadata: parse_kv_list(&args.metadata, "--metadata")?,
210 metadata_unset: args.metadata_unset.clone(),
211 declare_relations: parse_declare_relations(&args.declare_relations)?,
212 anchors: super::create::parse_anchor_list(&args.anchors)?,
213 dry_run: args.dry_run,
214 }
215 };
216
217 let entity_id = EntityId::canonical(&payload.id);
218
219 match ctx.cli_engine()? {
220 #[cfg(feature = "mem-repo")]
221 CliEngine::MemRepo(mut engine) => {
222 let expected_hash = resolve_hash_mem_repo(
223 &engine,
224 &entity_id,
225 payload.expected_hash,
226 args.auto_hash,
227 args.force,
228 )?;
229
230 let patch_sections = payload
231 .patch_sections
232 .into_iter()
233 .map(|(k, v)| {
234 (
235 k,
236 PatchArg {
237 old: v.old,
238 new: v.new,
239 all: v.all,
240 },
241 )
242 })
243 .collect();
244
245 let declare_relations: Vec<memstead_base::ops::RelateArg> = payload
246 .declare_relations
247 .iter()
248 .map(|r| memstead_base::ops::RelateArg {
249 to: EntityId::canonical(&r.to),
250 rel_type: r.rel_type.clone(),
251 description: r.description.clone(),
252 })
253 .collect();
254 let update_args = UpdateEntityArgs {
255 anchors: payload.anchors,
256 id: entity_id.clone(),
257 expected_hash: Some(expected_hash),
258 sections: payload.sections,
259 append_sections: payload.append_sections,
260 patch_sections,
261 metadata: payload.metadata,
262 metadata_unset: payload.metadata_unset,
263 dry_run: payload.dry_run,
264 declare_relations,
265 relations_unset: Vec::new(),
266 };
267
268 let result = engine
269 .update_entity_with_ctx(
270 update_args,
271 &crate::setup::cli_ctx_with_note(args.note.clone()),
272 )
273 .map_err(CliError::from_engine_op)?;
274 let mem_changed = engine.take_mem_changed_notices();
275
276 if ctx.json {
277 let mut body = serde_json::to_value(&result).unwrap_or(serde_json::Value::Null);
278 super::merge_mem_changed_json(&mut body, &mem_changed);
279 print_json(&body)?;
280 } else {
281 let header = if payload.dry_run {
282 format!("# Dry-run `{}`", result.id)
283 } else {
284 format!("# Updated `{}`", result.id)
285 };
286 let sections_line = render_section_mutations(&result.modified_sections);
287 let metadata_line = render_metadata_mutations(&result.modified_metadata);
288 let mut body = format!("{header}\n\n- Title: {}", result.title);
289 if let Some(line) = sections_line {
290 body.push_str(&format!("\n- Sections: {line}"));
291 }
292 if let Some(line) = metadata_line {
293 body.push_str(&format!("\n- Metadata: {line}"));
294 }
295 if !result.relations_declared.is_empty() {
296 let parts: Vec<String> = result
297 .relations_declared
298 .iter()
299 .map(|r| {
300 let stubbed_tag = if r.target_was_stubbed {
301 " (stubbed)"
302 } else {
303 ""
304 };
305 format!("{} → {}{}", r.rel_type, r.target, stubbed_tag)
306 })
307 .collect();
308 body.push_str(&format!("\n- Relations declared: {}", parts.join(", ")));
309 }
310 if !result.orphan_stubs_removed.is_empty() {
311 let ids: Vec<String> = result
312 .orphan_stubs_removed
313 .iter()
314 .map(|i| i.to_string())
315 .collect();
316 body.push_str(&format!("\n- Orphan stubs GC'd: {}", ids.join(", ")));
317 }
318 if !result.warnings.is_empty() {
319 let parts: Vec<String> =
320 result.warnings.iter().map(|w| w.to_string()).collect();
321 body.push_str(&format!("\n- Warnings: {}", parts.join("; ")));
322 }
323 body.push_str(&format!("\n- Hash: `{}`", result.content_hash));
324 body.push_str(&super::render_mem_changed_block(&mem_changed));
325 print_markdown(&body);
326 }
327 }
328 CliEngine::Filesystem(mut engine) => {
329 if !payload.append_sections.is_empty() {
336 return Err(CliError::new(
337 ExitKind::Validation,
338 "INVALID_INPUT",
339 "--append is not yet supported on filesystem-mem `memstead update`",
340 )
341 .into());
342 }
343 if !payload.patch_sections.is_empty() {
344 return Err(CliError::new(
345 ExitKind::Validation,
346 "INVALID_INPUT",
347 "--patch / --patch-all are not yet supported on filesystem-mem `memstead update`",
348 )
349 .into());
350 }
351 if payload.dry_run {
352 return Err(CliError::new(
353 ExitKind::Validation,
354 "INVALID_INPUT",
355 "--dry-run is not yet supported on filesystem-mem `memstead update`",
356 )
357 .into());
358 }
359
360 let expected_hash = resolve_hash_filesystem(
361 &engine,
362 &entity_id,
363 payload.expected_hash,
364 args.auto_hash,
365 args.force,
366 )?;
367
368 let declare_relations: Vec<memstead_base::ops::RelateArg> = payload
369 .declare_relations
370 .iter()
371 .map(|r| memstead_base::ops::RelateArg {
372 to: EntityId::canonical(&r.to),
373 rel_type: r.rel_type.clone(),
374 description: r.description.clone(),
375 })
376 .collect();
377 let update_args = UpdateEntityArgs {
378 anchors: payload.anchors,
379 id: entity_id.clone(),
380 expected_hash: Some(expected_hash),
381 sections: payload.sections,
382 append_sections: IndexMap::new(),
386 patch_sections: IndexMap::new(),
387 metadata: payload.metadata,
388 metadata_unset: payload.metadata_unset,
389 declare_relations,
390 dry_run: false,
391 relations_unset: Vec::new(),
392 };
393 let outcome = engine
394 .update_entity(update_args, Actor::Cli, None, args.note.as_deref())
395 .map_err(CliError::from_engine_op)?;
396
397 if ctx.json {
398 let relations_declared: Vec<serde_json::Value> = outcome
399 .relations_declared
400 .iter()
401 .map(|r| {
402 serde_json::json!({
403 "rel_type": r.rel_type,
404 "target": r.target.to_string(),
405 "target_was_stubbed": r.target_was_stubbed,
406 })
407 })
408 .collect();
409 print_json(&serde_json::json!({
410 "id": outcome.id.as_ref(),
411 "file_path": outcome.file_path,
412 "_hash": outcome.content_hash,
413 "modified_sections": outcome.modified_sections.replaced,
414 "modified_metadata_set": outcome.modified_metadata.set,
415 "modified_metadata_unset": outcome.modified_metadata.unset,
416 "relations_declared": relations_declared,
417 "warnings": outcome.warnings,
420 "orphan_stubs_removed": outcome
421 .orphan_stubs_removed
422 .iter()
423 .map(|i| i.to_string())
424 .collect::<Vec<_>>(),
425 }))?;
426 } else {
427 let mut body = format!("# Updated `{}`", outcome.id);
428 if !outcome.modified_sections.replaced.is_empty() {
429 let parts: Vec<String> = outcome
430 .modified_sections
431 .replaced
432 .iter()
433 .map(|k| format!("{k} (replaced)"))
434 .collect();
435 body.push_str(&format!("\n- Sections: {}", parts.join(", ")));
436 }
437 if !outcome.modified_metadata.set.is_empty()
438 || !outcome.modified_metadata.unset.is_empty()
439 {
440 let mut parts = Vec::new();
441 for k in &outcome.modified_metadata.set {
442 parts.push(format!("{k} (set)"));
443 }
444 for k in &outcome.modified_metadata.unset {
445 parts.push(format!("{k} (unset)"));
446 }
447 body.push_str(&format!("\n- Metadata: {}", parts.join(", ")));
448 }
449 if !outcome.relations_declared.is_empty() {
450 let parts: Vec<String> = outcome
451 .relations_declared
452 .iter()
453 .map(|r| {
454 let stubbed_tag = if r.target_was_stubbed {
455 " (stubbed)"
456 } else {
457 ""
458 };
459 format!("{} → {}{}", r.rel_type, r.target, stubbed_tag)
460 })
461 .collect();
462 body.push_str(&format!("\n- Relations declared: {}", parts.join(", ")));
463 }
464 if !outcome.orphan_stubs_removed.is_empty() {
465 let ids: Vec<String> = outcome
466 .orphan_stubs_removed
467 .iter()
468 .map(|i| i.to_string())
469 .collect();
470 body.push_str(&format!("\n- Orphan stubs GC'd: {}", ids.join(", ")));
471 }
472 if !outcome.warnings.is_empty() {
473 let parts: Vec<String> =
474 outcome.warnings.iter().map(|w| w.to_string()).collect();
475 body.push_str(&format!("\n- Warnings: {}", parts.join("; ")));
476 }
477 body.push_str(&format!("\n- Hash: `{}`", outcome.content_hash));
478 print_markdown(&body);
479 }
480 }
481 }
482 Ok(())
483}
484
485#[cfg(feature = "mem-repo")]
488fn render_section_mutations(m: &memstead_git_branch::ModifiedSections) -> Option<String> {
489 let mut parts = Vec::new();
490 for k in &m.replaced {
491 parts.push(format!("{k} (replaced)"));
492 }
493 for k in &m.appended {
494 parts.push(format!("{k} (appended)"));
495 }
496 for k in &m.patched {
497 parts.push(format!("{k} (patched)"));
498 }
499 if parts.is_empty() {
500 None
501 } else {
502 Some(parts.join(", "))
503 }
504}
505
506#[cfg(feature = "mem-repo")]
508fn render_metadata_mutations(m: &memstead_git_branch::ModifiedMetadata) -> Option<String> {
509 let mut parts = Vec::new();
510 for k in &m.set {
511 parts.push(format!("{k} (set)"));
512 }
513 for k in &m.unset {
514 parts.push(format!("{k} (unset)"));
515 }
516 if parts.is_empty() {
517 None
518 } else {
519 Some(parts.join(", "))
520 }
521}
522
523#[cfg(feature = "mem-repo")]
534fn resolve_hash_mem_repo(
535 engine: &memstead_base::Engine,
536 id: &EntityId,
537 explicit: Option<String>,
538 auto_hash: bool,
539 force: bool,
540) -> anyhow::Result<String> {
541 if auto_hash || force {
542 let entity = engine.get_entity(id).ok_or_else(|| {
543 CliError::new(
544 ExitKind::NotFound,
545 "ENTITY_NOT_FOUND",
546 format!("entity not found: {id}"),
547 )
548 .with_details(serde_json::json!({ "id": id.to_string() }))
549 })?;
550 return Ok(entity.content_hash.clone());
551 }
552 require_explicit_hash(explicit)
553}
554
555fn resolve_hash_filesystem(
558 engine: &memstead_base::Engine,
559 id: &EntityId,
560 explicit: Option<String>,
561 auto_hash: bool,
562 force: bool,
563) -> anyhow::Result<String> {
564 if auto_hash || force {
565 let entity = engine.get_entity(id).ok_or_else(|| {
566 CliError::new(
567 ExitKind::NotFound,
568 "ENTITY_NOT_FOUND",
569 format!("entity not found: {id}"),
570 )
571 .with_details(serde_json::json!({ "id": id.to_string() }))
572 })?;
573 return Ok(entity.content_hash.clone());
574 }
575 require_explicit_hash(explicit)
576}
577
578fn require_explicit_hash(explicit: Option<String>) -> anyhow::Result<String> {
579 match explicit {
580 Some(h) if !h.is_empty() => Ok(h),
581 _ => Err(CliError::new(
582 ExitKind::Validation,
583 crate::HASH_FLAG_REQUIRED_CODE,
584 "missing --expected-hash. Read the entity first (memstead entity <id>) and pass its `_hash`, \
585 or use --auto-hash for one-off interactive updates, or --force to overwrite.",
586 )
587 .into()),
588 }
589}
590
591fn parse_declare_relations(items: &[String]) -> anyhow::Result<Vec<DeclareRelationPayload>> {
599 let mut out = Vec::with_capacity(items.len());
600 for raw in items {
601 let (rel_type, target) = raw.split_once(':').ok_or_else(|| {
602 CliError::new(
603 ExitKind::Validation,
604 "INVALID_INPUT",
605 format!("--declare-relations: expected REL_TYPE:TARGET_ID, got `{raw}`"),
606 )
607 })?;
608 if rel_type.is_empty() || target.is_empty() {
609 return Err(CliError::new(
610 ExitKind::Validation,
611 "INVALID_INPUT",
612 format!(
613 "--declare-relations: REL_TYPE and TARGET_ID must both be non-empty, got `{raw}`"
614 ),
615 )
616 .into());
617 }
618 out.push(DeclareRelationPayload {
619 to: target.to_string(),
620 rel_type: rel_type.to_string(),
621 description: None,
622 });
623 }
624 Ok(out)
625}
626
627fn parse_kv_list(items: &[String], flag: &str) -> anyhow::Result<IndexMap<String, String>> {
628 let mut out = IndexMap::with_capacity(items.len());
629 for raw in items {
630 let (k, v) = raw.split_once('=').ok_or_else(|| {
631 CliError::new(
632 ExitKind::Validation,
633 "INVALID_INPUT",
634 format!("{flag}: expected KEY=VALUE, got `{raw}`"),
635 )
636 })?;
637 out.insert(k.to_string(), v.to_string());
638 }
639 Ok(out)
640}
641
642fn parse_patch_list_combined(
643 first_only: &[String],
644 all: &[String],
645) -> anyhow::Result<IndexMap<String, PatchPayload>> {
646 let mut out = IndexMap::with_capacity(first_only.len() + all.len());
647 for (items, flag, replace_all) in [(first_only, "--patch", false), (all, "--patch-all", true)] {
648 for raw in items {
649 let (key, rest) = raw.split_once('=').ok_or_else(|| {
650 CliError::new(
651 ExitKind::Validation,
652 "INVALID_INPUT",
653 format!("{flag}: expected KEY=OLD=>NEW, got `{raw}`"),
654 )
655 })?;
656 let (old, new) = rest.split_once("=>").ok_or_else(|| {
657 CliError::new(
658 ExitKind::Validation,
659 "INVALID_INPUT",
660 format!("{flag}: expected KEY=OLD=>NEW (missing `=>`), got `{raw}`"),
661 )
662 })?;
663 if out.contains_key(key) {
664 return Err(CliError::new(
665 ExitKind::Validation,
666 "INVALID_INPUT",
667 format!(
668 "duplicate patch for section `{key}` -- only one of --patch / --patch-all per section"
669 ),
670 )
671 .into());
672 }
673 out.insert(
674 key.to_string(),
675 PatchPayload {
676 old: old.to_string(),
677 new: new.to_string(),
678 all: replace_all,
679 },
680 );
681 }
682 }
683 Ok(out)
684}