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