memstead_cli/commands/update.rs
1//! `memstead update` — strict-by-default entity update.
2//!
3//! Hash handling offers three opt-ins:
4//!
5//! * **Default (strict).** `--expected-hash <h>` must be supplied for any
6//! update that CHANGES CONTENT. Matches MCP's `memstead_update` contract.
7//! Safe for scripts, CI, pre-commit hooks. An anchors-only update
8//! (`--anchor` / `--anchor-unset` and nothing else) needs none: anchors live
9//! outside the content hash, so the token would compare a value the write
10//! cannot move.
11//! * **`--auto-hash`.** Refetch the current hash immediately before writing.
12//! Ergonomic for one-off interactive edits; the user accepts the race window.
13//! * **`--force`.** Skip the hash check entirely. Explicit opt-out.
14//!
15//! Only one of the three may be used per invocation.
16
17use std::path::PathBuf;
18
19use clap::Parser;
20use indexmap::IndexMap;
21use serde::Deserialize;
22
23#[cfg(feature = "mem-repo")]
24use memstead_base::ops::PatchArg;
25use memstead_base::vcs::Actor;
26use memstead_base::{EntityId, UpdateEntityArgs};
27
28use crate::CliError;
29use crate::output::{ExitKind, print_json, print_markdown};
30use crate::setup::{CliContext, CliEngine};
31
32#[derive(Parser, Debug)]
33pub struct Args {
34 /// Full entity ID (e.g. `specs--my-entity`). Required unless `--from` is given.
35 pub id: Option<String>,
36
37 /// Hash from `memstead entity <id>` (the `_hash` field). Required for any
38 /// update that changes content, unless `--auto-hash` or `--force` is
39 /// given. Not required for an anchors-only update (`--anchor` /
40 /// `--anchor-unset` and nothing else), because anchors live outside the
41 /// content hash and the token would compare a value the write cannot
42 /// move. With `--from`, this flag overrides the file's `expected_hash`
43 /// field and enforces CAS exactly as on the inline path.
44 #[arg(long = "expected-hash", value_name = "HASH")]
45 pub expected_hash: Option<String>,
46
47 /// Refetch the current hash immediately before writing.
48 /// Convenient for interactive use; accepts the race window between
49 /// the refetch and the write.
50 #[arg(long, conflicts_with_all = ["expected_hash", "force"])]
51 pub auto_hash: bool,
52
53 /// Skip the hash check entirely (explicit overwrite).
54 #[arg(long, conflicts_with_all = ["expected_hash", "auto_hash"])]
55 pub force: bool,
56
57 /// Replace section content: repeatable `--section key=value`. Body
58 /// wiki-links must take slug-form (`[[idempotency]]`, not the
59 /// title-case `[[Idempotency]]`) — a non-slug target refuses with
60 /// `INVALID_WIKI_LINK_TARGET` carrying a `proposed_slug` to retry with.
61 #[arg(long = "section", value_name = "KEY=VALUE", conflicts_with = "from")]
62 pub sections: Vec<String>,
63
64 /// Append to section content: repeatable `--append key=value`.
65 #[arg(long = "append", value_name = "KEY=VALUE", conflicts_with = "from")]
66 pub append: Vec<String>,
67
68 /// Find-and-replace inside a section: repeatable `--patch key=OLD=>NEW`.
69 /// Use `=>` (two chars) as the separator between old and new. Exact match
70 /// of the first occurrence; use `--patch-all` to replace every occurrence.
71 #[arg(long = "patch", value_name = "KEY=OLD=>NEW", conflicts_with = "from")]
72 pub patch: Vec<String>,
73
74 /// Replace every occurrence of OLD in the section — sibling of `--patch`.
75 /// Repeatable `--patch-all key=OLD=>NEW`.
76 #[arg(
77 long = "patch-all",
78 value_name = "KEY=OLD=>NEW",
79 conflicts_with = "from"
80 )]
81 pub patch_all: Vec<String>,
82
83 /// Metadata field: repeatable `--metadata key=value`.
84 #[arg(long = "metadata", value_name = "KEY=VALUE", conflicts_with = "from")]
85 pub metadata: Vec<String>,
86
87 /// Remove a metadata field: repeatable `--metadata-unset KEY`. Silent
88 /// no-op if the key is absent; errors on read-only fields (mem/id/type
89 /// plus the engine-stamped created_date/last_modified) or
90 /// schema-required fields.
91 #[arg(long = "metadata-unset", value_name = "KEY", conflicts_with = "from")]
92 pub metadata_unset: Vec<String>,
93
94 /// Atomic batched relation declaration: repeatable
95 /// `--declare-relations REL_TYPE:TARGET_ID`. Each entry is
96 /// validated like an individual `memstead relate` call (schema-shape,
97 /// cross-mem policy, target-id grammar) and appended to the
98 /// entity's relations BEFORE the strict wiki-link/relation
99 /// validator runs. Lets the agent add `[[target]]` body
100 /// wiki-links AND declare the backing relation in one
101 /// `memstead update` call without an interleaved `memstead relate`.
102 /// Absent Write-mem targets are auto-stubbed identically to
103 /// `memstead relate`'s add path. Each successful declaration is
104 /// echoed in the response's `relations_declared` (with
105 /// `target_was_stubbed` flagging the auto-stub case).
106 #[arg(
107 long = "declare-relations",
108 value_name = "REL_TYPE:TARGET_ID",
109 conflicts_with = "from"
110 )]
111 pub declare_relations: Vec<String>,
112
113 /// Provenance anchor: repeatable `--anchor '<json>'`, each a JSON
114 /// object of the anchor shape. Written into the mem-branch anchors
115 /// sidecar in the same commit as the update; a malformed anchor
116 /// refuses `INVALID_ANCHOR`. An update carrying only `--anchor` (no
117 /// section/metadata change) still commits the sidecar. Conflicts with
118 /// `--from` (the file's `anchors[]` is authoritative there).
119 #[arg(long = "anchor", value_name = "JSON", conflicts_with = "from")]
120 pub anchors: Vec<String>,
121
122 /// Explicit anchor removal: repeatable `--anchor-unset '<json>'`, each
123 /// a JSON object `{ "artifact": "…" }` optionally narrowed by
124 /// `"grain"` and/or `"class"` — a bare artifact removes every anchor
125 /// on it. Applied BEFORE the `--anchor` merge in the same commit
126 /// (anchors merge; writing never removes an anchor not named here).
127 /// Unsetting a nonexistent target is a no-op. A malformed selector
128 /// refuses `INVALID_ANCHOR`. Conflicts with `--from` (the file's
129 /// `anchors_unset[]` is authoritative there).
130 #[arg(long = "anchor-unset", value_name = "JSON", conflicts_with = "from")]
131 pub anchors_unset: Vec<String>,
132
133 /// Preview what would change without writing. Applies on both the
134 /// inline and `--from` paths; with `--from` it forces a dry run even
135 /// when the file's `dry_run` field is absent or `false`.
136 #[arg(long)]
137 pub dry_run: bool,
138
139 /// JSON file matching MCP `memstead_update` args shape. The file is the
140 /// single source of the mutation content — the content flags
141 /// (`--section` / `--append` / `--patch` / `--patch-all` / `--metadata` /
142 /// `--metadata-unset` / `--declare-relations` / `--anchor` /
143 /// `--anchor-unset`) conflict with `--from` rather than being silently
144 /// ignored. The flags that DO apply
145 /// alongside `--from`: the hash-mode flags (`--expected-hash`, which
146 /// overrides the file's `expected_hash` field; `--auto-hash`; `--force`),
147 /// `--dry-run` (forces a dry run even when the file says otherwise), and
148 /// `--note`.
149 #[arg(long = "from", value_name = "FILE")]
150 pub from: Option<PathBuf>,
151
152 /// Agent-authored provenance note (≤280 chars). When
153 /// `[mutations].require_notes = true` a missing note adds a
154 /// `NOTE_MISSING` warning.
155 #[arg(long)]
156 pub note: Option<String>,
157}
158
159/// Parse repeatable `--anchor-unset '<json>'` values into the engine's
160/// permissive `AnchorUnsetInput` shape — sibling of
161/// [`super::create::parse_anchor_list`]. Only JSON-shape errors refuse
162/// here; selector validation (missing artifact, unknown grain/class) is
163/// the engine's typed `INVALID_ANCHOR`.
164fn parse_anchor_unset_list(
165 items: &[String],
166) -> anyhow::Result<Vec<memstead_base::anchor::AnchorUnsetInput>> {
167 let mut out = Vec::with_capacity(items.len());
168 for raw in items {
169 let unset: memstead_base::anchor::AnchorUnsetInput =
170 serde_json::from_str(raw).map_err(|e| {
171 CliError::new(
172 ExitKind::Validation,
173 "INVALID_INPUT",
174 format!("--anchor-unset: expected a JSON selector object, got `{raw}`: {e}"),
175 )
176 })?;
177 out.push(unset);
178 }
179 Ok(out)
180}
181
182/// On-disk JSON payload shape — mirrors MCP `UpdateParams` + hash flags.
183/// `expected_hash` inside the file takes effect only in strict mode.
184#[derive(Debug, Deserialize)]
185#[serde(deny_unknown_fields)]
186struct UpdatePayload {
187 id: String,
188 expected_hash: Option<String>,
189 #[serde(default)]
190 sections: IndexMap<String, String>,
191 #[serde(default)]
192 append_sections: IndexMap<String, String>,
193 #[serde(default)]
194 patch_sections: IndexMap<String, PatchPayload>,
195 #[serde(default)]
196 metadata: IndexMap<String, String>,
197 #[serde(default)]
198 metadata_unset: Vec<String>,
199 #[serde(default)]
200 declare_relations: Vec<DeclareRelationPayload>,
201 /// Provenance anchors — matches the MCP `memstead_update` `anchors[]`
202 /// shape; validated engine-side into a typed `INVALID_ANCHOR` refusal
203 /// on malformed input. Merged into the entity's existing set (same
204 /// `(artifact, grain, class)` triple replaces, otherwise appends).
205 #[serde(default)]
206 anchors: Vec<memstead_base::anchor::AnchorInput>,
207 /// Explicit anchor removals — matches the MCP `memstead_update`
208 /// `anchors_unset[]` shape; applied before the `anchors` merge.
209 #[serde(default)]
210 anchors_unset: Vec<memstead_base::anchor::AnchorUnsetInput>,
211 #[serde(default)]
212 dry_run: bool,
213 /// Agent-authored provenance note — same semantics as
214 /// `create --from`: the command-line `--note` wins when both are
215 /// supplied. One JSON template can therefore feed both
216 /// `create --from` and `update --from`. The optimistic-locking
217 /// selectors (`auto_hash`, `force`) are deliberately flag-only: a
218 /// stored payload must never be able to disable locking on a
219 /// future run.
220 #[serde(default)]
221 note: Option<String>,
222 /// Tolerated for template symmetry with `create --from` (one JSON
223 /// document feeds both commands). Update cannot rename an entity,
224 /// so a supplied `title` is only *checked*: a value differing from
225 /// the entity's current title refuses with `INVALID_INPUT`
226 /// pointing at `memstead rename` — never silently dropped.
227 #[serde(default)]
228 title: Option<String>,
229 /// Tolerated for template symmetry with `create --from`; must
230 /// match the entity's current type (update cannot retype —
231 /// delete + create instead). A differing value refuses.
232 #[serde(default)]
233 entity_type: Option<String>,
234 /// Tolerated for template symmetry with `create --from`; must
235 /// match the mem encoded in the entity id (update cannot move an
236 /// entity between mems). A differing value refuses.
237 #[serde(default)]
238 mem: Option<String>,
239}
240
241#[derive(Debug, Deserialize, Clone)]
242#[serde(deny_unknown_fields)]
243#[cfg_attr(not(feature = "mem-repo"), allow(dead_code))]
244struct DeclareRelationPayload {
245 /// Target entity id (`mem--slug` or cross-mem form).
246 to: String,
247 /// Relationship type — case-insensitive on input; engine
248 /// canonicalises to UPPER_SNAKE_CASE.
249 rel_type: String,
250 /// Optional per-edge description. Validated against the rel-type's
251 /// `per_edge_description` posture in the engine.
252 #[serde(default)]
253 description: Option<String>,
254}
255
256#[derive(Debug, Deserialize)]
257#[serde(deny_unknown_fields)]
258#[cfg_attr(not(feature = "mem-repo"), allow(dead_code))]
259struct PatchPayload {
260 old: String,
261 new: String,
262 #[serde(default)]
263 all: bool,
264}
265
266/// Template-symmetry check against the live entity: a shared
267/// create/update template may carry `title` / `entity_type`; update
268/// can change neither, so a present-but-differing value refuses
269/// instead of being silently dropped. Absent entity → skip (the
270/// engine's own `ENTITY_NOT_FOUND` is the better error).
271fn check_template_identity(
272 entity: Option<&memstead_base::Entity>,
273 payload_title: Option<&str>,
274 payload_type: Option<&str>,
275) -> Result<(), CliError> {
276 let Some(entity) = entity else {
277 return Ok(());
278 };
279 if let Some(t) = payload_title
280 && t != entity.title
281 {
282 return Err(CliError::new(
283 ExitKind::Validation,
284 "INVALID_INPUT",
285 format!(
286 "template `title` {t:?} differs from the entity's current title {:?} — \
287 update cannot rename; use `memstead rename`",
288 entity.title
289 ),
290 ));
291 }
292 if let Some(ty) = payload_type
293 && ty != entity.entity_type
294 {
295 return Err(CliError::new(
296 ExitKind::Validation,
297 "INVALID_INPUT",
298 format!(
299 "template `entity_type` {ty:?} differs from the entity's current type {:?} — \
300 update cannot retype; delete + create instead",
301 entity.entity_type
302 ),
303 ));
304 }
305 Ok(())
306}
307
308pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
309 let mut payload = if let Some(ref file) = args.from {
310 let bytes = std::fs::read(file).map_err(|e| {
311 CliError::new(
312 ExitKind::Generic,
313 "INVALID_INPUT",
314 format!("failed to read {}: {e}", file.display()),
315 )
316 })?;
317 let mut parsed: UpdatePayload = serde_json::from_slice(&bytes).map_err(|e| {
318 CliError::new(
319 ExitKind::Validation,
320 "INVALID_INPUT",
321 format!("invalid JSON in {}: {e}", file.display()),
322 )
323 .with_details(serde_json::json!({
324 "path": file.display().to_string(),
325 "parser_error": e.to_string(),
326 }))
327 })?;
328 // The non-content flags apply on the `--from` path exactly as on the
329 // inline path (the content flags conflict at parse time): `--dry-run`
330 // forces a dry run, and an explicit `--expected-hash` overrides the
331 // file's `expected_hash` field. Neither is ever silently dropped.
332 parsed.dry_run |= args.dry_run;
333 if args.expected_hash.is_some() {
334 parsed.expected_hash = args.expected_hash.clone();
335 }
336 parsed
337 } else {
338 let id = args.id.clone().ok_or_else(|| {
339 CliError::new(
340 ExitKind::Validation,
341 "INVALID_INPUT",
342 "missing entity ID (or pass --from <file.json>)",
343 )
344 })?;
345 UpdatePayload {
346 id,
347 expected_hash: args.expected_hash.clone(),
348 sections: parse_kv_list(&args.sections, "--section")?,
349 append_sections: parse_kv_list(&args.append, "--append")?,
350 patch_sections: parse_patch_list_combined(&args.patch, &args.patch_all)?,
351 metadata: parse_kv_list(&args.metadata, "--metadata")?,
352 metadata_unset: args.metadata_unset.clone(),
353 declare_relations: parse_declare_relations(&args.declare_relations)?,
354 anchors: super::create::parse_anchor_list(&args.anchors)?,
355 anchors_unset: parse_anchor_unset_list(&args.anchors_unset)?,
356 dry_run: args.dry_run,
357 note: None,
358 title: None,
359 entity_type: None,
360 mem: None,
361 }
362 };
363
364 // `--note` (CLI flag) wins over a `note` carried in the `--from`
365 // payload when both are present — same precedence as `create --from`.
366 let note = args.note.clone().or_else(|| payload.note.clone());
367
368 let entity_id = EntityId::canonical(&payload.id);
369
370 // Template-symmetry consistency checks: a shared create/update
371 // template may carry `title` / `entity_type` / `mem`. Update can
372 // change none of them, so each present value must match the
373 // entity id's mem (checkable here) — the title/type compare runs
374 // against the live entity below, per engine flavour.
375 if let Some(m) = payload.mem.as_deref()
376 && m != entity_id.mem()
377 {
378 return Err(CliError::new(
379 ExitKind::Validation,
380 "INVALID_INPUT",
381 format!(
382 "template `mem` {m:?} does not match the mem in id `{entity_id}` — update cannot move an entity between mems (delete + create instead)"
383 ),
384 )
385 .into());
386 }
387
388 match ctx.cli_engine()? {
389 #[cfg(feature = "mem-repo")]
390 CliEngine::MemRepo(mut engine) => {
391 check_template_identity(
392 engine.get_entity(&entity_id),
393 payload.title.as_deref(),
394 payload.entity_type.as_deref(),
395 )?;
396 // Resolved AFTER the args are assembled, because whether the
397 // compare-and-swap token is required depends on the payload's own
398 // shape and the engine owns that predicate
399 // (consistency-sweep 03/04).
400 let explicit_hash = payload.expected_hash.take();
401
402 let patch_sections = payload
403 .patch_sections
404 .into_iter()
405 .map(|(k, v)| {
406 (
407 k,
408 PatchArg {
409 old: v.old,
410 new: v.new,
411 all: v.all,
412 },
413 )
414 })
415 .collect();
416
417 let declare_relations: Vec<memstead_base::ops::RelateArg> = payload
418 .declare_relations
419 .iter()
420 .map(|r| memstead_base::ops::RelateArg {
421 target: EntityId::canonical(&r.to),
422 rel_type: r.rel_type.clone(),
423 description: r.description.clone(),
424 })
425 .collect();
426 let update_args = UpdateEntityArgs {
427 anchors: payload.anchors,
428 id: entity_id.clone(),
429 expected_hash: None,
430 sections: payload.sections,
431 append_sections: payload.append_sections,
432 patch_sections,
433 metadata: payload.metadata,
434 metadata_unset: payload.metadata_unset,
435 dry_run: payload.dry_run,
436 declare_relations,
437 relations_unset: Vec::new(),
438 anchors_unset: payload.anchors_unset,
439 };
440 let mut update_args = update_args;
441 update_args.expected_hash = resolve_hash_mem_repo(
442 &engine,
443 &entity_id,
444 explicit_hash,
445 args.auto_hash,
446 args.force,
447 // `dry_run` joins the exemption because MCP's contract already
448 // says dry-run bypasses ONLY the hash check, and it is the
449 // documented stale-hash recovery path. Demanding a token here
450 // while MCP does not is a surface divergence
451 // (consistency-sweep 03/04).
452 !update_args.changes_content() || update_args.dry_run,
453 )?;
454
455 let result = engine
456 .update_entity_with_ctx(update_args, &crate::setup::cli_ctx_with_note(note.clone()))
457 .map_err(CliError::from_engine_op)?;
458 let mem_changed = engine.take_mem_changed_notices();
459
460 if ctx.json {
461 let mut body = serde_json::to_value(&result).unwrap_or(serde_json::Value::Null);
462 super::merge_mem_changed_json(&mut body, &mem_changed);
463 print_json(&body)?;
464 } else {
465 let header = if payload.dry_run {
466 format!("# Dry-run `{}`", result.id)
467 } else {
468 format!("# Updated `{}`", result.id)
469 };
470 let sections_line = render_section_mutations(&result.modified_sections);
471 let metadata_line = render_metadata_mutations(&result.modified_metadata);
472 let mut body = format!("{header}\n\n- Title: {}", result.title);
473 if let Some(line) = sections_line {
474 body.push_str(&format!("\n- Sections: {line}"));
475 }
476 if let Some(line) = metadata_line {
477 body.push_str(&format!("\n- Metadata: {line}"));
478 }
479 if !result.relations_declared.is_empty() {
480 let parts: Vec<String> = result
481 .relations_declared
482 .iter()
483 .map(|r| {
484 let stubbed_tag = if r.target_was_stubbed {
485 " (stubbed)"
486 } else {
487 ""
488 };
489 format!("{} → {}{}", r.rel_type, r.target, stubbed_tag)
490 })
491 .collect();
492 body.push_str(&format!("\n- Relations declared: {}", parts.join(", ")));
493 }
494 if !result.orphan_stubs_removed.is_empty() {
495 let ids: Vec<String> = result
496 .orphan_stubs_removed
497 .iter()
498 .map(|i| i.to_string())
499 .collect();
500 body.push_str(&format!("\n- Orphan stubs GC'd: {}", ids.join(", ")));
501 }
502 if !result.warnings.is_empty() {
503 let parts: Vec<String> =
504 result.warnings.iter().map(|w| w.to_string()).collect();
505 body.push_str(&format!("\n- Warnings: {}", parts.join("; ")));
506 }
507 body.push_str(&format!("\n- Hash: `{}`", result.content_hash));
508 body.push_str(&super::render_mem_changed_block(&mem_changed));
509 print_markdown(&body);
510 }
511 }
512 CliEngine::Filesystem(mut engine) => {
513 check_template_identity(
514 engine.get_entity(&entity_id),
515 payload.title.as_deref(),
516 payload.entity_type.as_deref(),
517 )?;
518 // The filesystem-mem `memstead_update` surface is intentionally
519 // smaller than mem-repo's: whole-section replacement,
520 // metadata set, and metadata unset are honoured;
521 // append_sections / patch_sections / dry_run are not yet
522 // wired on the filesystem engine. Surface that as a clear
523 // validation error rather than silently dropping the flags.
524 if !payload.append_sections.is_empty() {
525 return Err(CliError::new(
526 ExitKind::Validation,
527 "INVALID_INPUT",
528 "--append is not yet supported on filesystem-mem `memstead update`",
529 )
530 .into());
531 }
532 if !payload.patch_sections.is_empty() {
533 return Err(CliError::new(
534 ExitKind::Validation,
535 "INVALID_INPUT",
536 "--patch / --patch-all are not yet supported on filesystem-mem `memstead update`",
537 )
538 .into());
539 }
540 if payload.dry_run {
541 return Err(CliError::new(
542 ExitKind::Validation,
543 "INVALID_INPUT",
544 "--dry-run is not yet supported on filesystem-mem `memstead update`",
545 )
546 .into());
547 }
548
549 // Resolved after the args, as on the mem-repo path above.
550 let explicit_hash = payload.expected_hash.take();
551
552 let declare_relations: Vec<memstead_base::ops::RelateArg> = payload
553 .declare_relations
554 .iter()
555 .map(|r| memstead_base::ops::RelateArg {
556 target: EntityId::canonical(&r.to),
557 rel_type: r.rel_type.clone(),
558 description: r.description.clone(),
559 })
560 .collect();
561 let update_args = UpdateEntityArgs {
562 anchors: payload.anchors,
563 id: entity_id.clone(),
564 expected_hash: None,
565 sections: payload.sections,
566 // CLI's update surface doesn't accept
567 // append_sections / patch_sections on its wire
568 // today; pass empty.
569 append_sections: IndexMap::new(),
570 patch_sections: IndexMap::new(),
571 metadata: payload.metadata,
572 metadata_unset: payload.metadata_unset,
573 declare_relations,
574 dry_run: false,
575 relations_unset: Vec::new(),
576 anchors_unset: payload.anchors_unset,
577 };
578 let mut update_args = update_args;
579 update_args.expected_hash = resolve_hash_filesystem(
580 &engine,
581 &entity_id,
582 explicit_hash,
583 args.auto_hash,
584 args.force,
585 !update_args.changes_content(),
586 )?;
587 let outcome = engine
588 .update_entity(
589 update_args,
590 Actor::Cli,
591 Some(&crate::setup::cli_client_id()),
592 note.as_deref(),
593 )
594 .map_err(CliError::from_engine_op)?;
595
596 if ctx.json {
597 let relations_declared: Vec<serde_json::Value> = outcome
598 .relations_declared
599 .iter()
600 .map(|r| {
601 serde_json::json!({
602 "rel_type": r.rel_type,
603 "target": r.target.to_string(),
604 "target_was_stubbed": r.target_was_stubbed,
605 })
606 })
607 .collect();
608 print_json(&serde_json::json!({
609 "id": outcome.id.as_ref(),
610 "file_path": outcome.file_path,
611 "_hash": outcome.content_hash,
612 // Backend write identity — response-shape parity with
613 // the MCP filesystem flavour and the CLI's own
614 // relate/conflicts commands.
615 "write_id": outcome.write_id,
616 "modified_sections": outcome.modified_sections.replaced,
617 "modified_metadata_set": outcome.modified_metadata.set,
618 "modified_metadata_unset": outcome.modified_metadata.unset,
619 "relations_declared": relations_declared,
620 // Engine-emitted warnings (e.g. `NOTE_MISSING` under
621 // `[mutations].require_notes`) ride the response.
622 "warnings": outcome.warnings,
623 "orphan_stubs_removed": outcome
624 .orphan_stubs_removed
625 .iter()
626 .map(|i| i.to_string())
627 .collect::<Vec<_>>(),
628 }))?;
629 } else {
630 let mut body = format!("# Updated `{}`", outcome.id);
631 if !outcome.modified_sections.replaced.is_empty() {
632 let parts: Vec<String> = outcome
633 .modified_sections
634 .replaced
635 .iter()
636 .map(|k| format!("{k} (replaced)"))
637 .collect();
638 body.push_str(&format!("\n- Sections: {}", parts.join(", ")));
639 }
640 if !outcome.modified_metadata.set.is_empty()
641 || !outcome.modified_metadata.unset.is_empty()
642 {
643 let mut parts = Vec::new();
644 for k in &outcome.modified_metadata.set {
645 parts.push(format!("{k} (set)"));
646 }
647 for k in &outcome.modified_metadata.unset {
648 parts.push(format!("{k} (unset)"));
649 }
650 body.push_str(&format!("\n- Metadata: {}", parts.join(", ")));
651 }
652 if !outcome.relations_declared.is_empty() {
653 let parts: Vec<String> = outcome
654 .relations_declared
655 .iter()
656 .map(|r| {
657 let stubbed_tag = if r.target_was_stubbed {
658 " (stubbed)"
659 } else {
660 ""
661 };
662 format!("{} → {}{}", r.rel_type, r.target, stubbed_tag)
663 })
664 .collect();
665 body.push_str(&format!("\n- Relations declared: {}", parts.join(", ")));
666 }
667 if !outcome.orphan_stubs_removed.is_empty() {
668 let ids: Vec<String> = outcome
669 .orphan_stubs_removed
670 .iter()
671 .map(|i| i.to_string())
672 .collect();
673 body.push_str(&format!("\n- Orphan stubs GC'd: {}", ids.join(", ")));
674 }
675 if !outcome.warnings.is_empty() {
676 let parts: Vec<String> =
677 outcome.warnings.iter().map(|w| w.to_string()).collect();
678 body.push_str(&format!("\n- Warnings: {}", parts.join("; ")));
679 }
680 body.push_str(&format!("\n- Hash: `{}`", outcome.content_hash));
681 print_markdown(&body);
682 }
683 }
684 }
685 Ok(())
686}
687
688/// Render `modified_sections` as `identity (replaced), constraints (appended)`.
689/// Returns `None` when nothing was modified, letting the caller omit the line.
690#[cfg(feature = "mem-repo")]
691fn render_section_mutations(m: &memstead_git_branch::ModifiedSections) -> Option<String> {
692 let mut parts = Vec::new();
693 for k in &m.replaced {
694 parts.push(format!("{k} (replaced)"));
695 }
696 for k in &m.appended {
697 parts.push(format!("{k} (appended)"));
698 }
699 for k in &m.patched {
700 parts.push(format!("{k} (patched)"));
701 }
702 if parts.is_empty() {
703 None
704 } else {
705 Some(parts.join(", "))
706 }
707}
708
709/// Render `modified_metadata` as `level (set), tags (unset)`. `None` when empty.
710#[cfg(feature = "mem-repo")]
711fn render_metadata_mutations(m: &memstead_git_branch::ModifiedMetadata) -> Option<String> {
712 let mut parts = Vec::new();
713 for k in &m.set {
714 parts.push(format!("{k} (set)"));
715 }
716 for k in &m.unset {
717 parts.push(format!("{k} (unset)"));
718 }
719 if parts.is_empty() {
720 None
721 } else {
722 Some(parts.join(", "))
723 }
724}
725
726/// Resolve the hash the update will be issued with.
727///
728/// * `--force` and `--auto-hash` both refetch from the engine's in-memory
729/// store. Because the CLI initializes a fresh engine per invocation, the
730/// loaded hash matches the on-disk content as long as no concurrent writer
731/// changed the file between load and update (race window is microseconds).
732/// The two flags exist to encode user intent — `--auto-hash` for "I didn't
733/// bother reading the entity first," `--force` for "I intend to overwrite
734/// regardless of what's there."
735/// * Strict (default) → use the explicit `--expected-hash` / JSON field, else error.
736#[cfg(feature = "mem-repo")]
737fn resolve_hash_mem_repo(
738 engine: &memstead_base::Engine,
739 id: &EntityId,
740 explicit: Option<String>,
741 auto_hash: bool,
742 force: bool,
743 exempt: bool,
744) -> anyhow::Result<Option<String>> {
745 if auto_hash || force {
746 let entity = engine.get_entity(id).ok_or_else(|| {
747 CliError::new(
748 ExitKind::NotFound,
749 "ENTITY_NOT_FOUND",
750 format!("entity not found: {id}"),
751 )
752 .with_details(serde_json::json!({ "id": id.to_string() }))
753 })?;
754 return Ok(Some(entity.content_hash.clone()));
755 }
756 require_explicit_hash(explicit, exempt)
757}
758
759/// Filesystem-mem counterpart of [`resolve_hash_mem_repo`]. Same
760/// semantics; differs only in the engine accessor type.
761fn resolve_hash_filesystem(
762 engine: &memstead_base::Engine,
763 id: &EntityId,
764 explicit: Option<String>,
765 auto_hash: bool,
766 force: bool,
767 exempt: bool,
768) -> anyhow::Result<Option<String>> {
769 if auto_hash || force {
770 let entity = engine.get_entity(id).ok_or_else(|| {
771 CliError::new(
772 ExitKind::NotFound,
773 "ENTITY_NOT_FOUND",
774 format!("entity not found: {id}"),
775 )
776 .with_details(serde_json::json!({ "id": id.to_string() }))
777 })?;
778 return Ok(Some(entity.content_hash.clone()));
779 }
780 require_explicit_hash(explicit, exempt)
781}
782
783/// `exempt` waives the requirement (consistency-sweep 03/04). The
784/// compare-and-swap token asserts that the entity's CONTENT is unchanged, and
785/// on an anchors-only write the content is unchanged by construction: the
786/// anchors sidecar is outside `_hash` by deliberate design, so the token
787/// compares a value the guarded write cannot move. Demanding it therefore
788/// bought no protection and cost a read or dry-run roundtrip per entity,
789/// falling on exactly the backfill flows the anchor dialect exists to make
790/// attractive.
791///
792/// Callers derive `exempt` from the engine's own `changes_content()`, so this
793/// surface and MCP cannot come to disagree about whether a write is safe. The
794/// mem-repo path additionally waives it for `--dry-run`, matching the shipped
795/// MCP contract that a dry run bypasses only this check and is the designated
796/// stale-hash recovery path; a dry run writes nothing, so there is nothing to
797/// guard.
798///
799/// An EMPTY token counts as no token, here and on every other surface: it can
800/// never match a real hash, so treating it as a supplied one turned an
801/// anchors-only write into a spurious mismatch on whichever surface forgot.
802fn require_explicit_hash(explicit: Option<String>, exempt: bool) -> anyhow::Result<Option<String>> {
803 match explicit {
804 Some(h) if !h.is_empty() => Ok(Some(h)),
805 _ if exempt => Ok(None),
806 _ => Err(CliError::new(
807 ExitKind::Validation,
808 crate::HASH_FLAG_REQUIRED_CODE,
809 "missing --expected-hash. Read the entity first (memstead entity <id>) and pass its `_hash`, \
810 or use --auto-hash for one-off interactive updates, or --force to overwrite. \
811 An anchors-only update (--anchor / --anchor-unset and nothing else) needs none: \
812 anchors are outside the content hash.",
813 )
814 .into()),
815 }
816}
817
818/// Parse repeatable `--declare-relations REL_TYPE:TARGET_ID` into
819/// the structured payload used downstream. Splits on the FIRST `:`
820/// so the target id can itself contain colons (cross-mem
821/// `[[mem:slug]]` form). The rel-type half must match the
822/// `[A-Za-z][A-Za-z_]*` grammar already used by `memstead relate`;
823/// validation against the workspace's schema vocabulary happens at
824/// the engine layer.
825fn parse_declare_relations(items: &[String]) -> anyhow::Result<Vec<DeclareRelationPayload>> {
826 let mut out = Vec::with_capacity(items.len());
827 for raw in items {
828 let (rel_type, target) = raw.split_once(':').ok_or_else(|| {
829 CliError::new(
830 ExitKind::Validation,
831 "INVALID_INPUT",
832 format!("--declare-relations: expected REL_TYPE:TARGET_ID, got `{raw}`"),
833 )
834 })?;
835 if rel_type.is_empty() || target.is_empty() {
836 return Err(CliError::new(
837 ExitKind::Validation,
838 "INVALID_INPUT",
839 format!(
840 "--declare-relations: REL_TYPE and TARGET_ID must both be non-empty, got `{raw}`"
841 ),
842 )
843 .into());
844 }
845 out.push(DeclareRelationPayload {
846 to: target.to_string(),
847 rel_type: rel_type.to_string(),
848 description: None,
849 });
850 }
851 Ok(out)
852}
853
854fn parse_kv_list(items: &[String], flag: &str) -> anyhow::Result<IndexMap<String, String>> {
855 let mut out = IndexMap::with_capacity(items.len());
856 for raw in items {
857 let (k, v) = raw.split_once('=').ok_or_else(|| {
858 CliError::new(
859 ExitKind::Validation,
860 "INVALID_INPUT",
861 format!("{flag}: expected KEY=VALUE, got `{raw}`"),
862 )
863 })?;
864 out.insert(k.to_string(), v.to_string());
865 }
866 Ok(out)
867}
868
869fn parse_patch_list_combined(
870 first_only: &[String],
871 all: &[String],
872) -> anyhow::Result<IndexMap<String, PatchPayload>> {
873 let mut out = IndexMap::with_capacity(first_only.len() + all.len());
874 for (items, flag, replace_all) in [(first_only, "--patch", false), (all, "--patch-all", true)] {
875 for raw in items {
876 let (key, rest) = raw.split_once('=').ok_or_else(|| {
877 CliError::new(
878 ExitKind::Validation,
879 "INVALID_INPUT",
880 format!("{flag}: expected KEY=OLD=>NEW, got `{raw}`"),
881 )
882 })?;
883 let (old, new) = rest.split_once("=>").ok_or_else(|| {
884 CliError::new(
885 ExitKind::Validation,
886 "INVALID_INPUT",
887 format!("{flag}: expected KEY=OLD=>NEW (missing `=>`), got `{raw}`"),
888 )
889 })?;
890 if out.contains_key(key) {
891 return Err(CliError::new(
892 ExitKind::Validation,
893 "INVALID_INPUT",
894 format!(
895 "duplicate patch for section `{key}` -- only one of --patch / --patch-all per section"
896 ),
897 )
898 .into());
899 }
900 out.insert(
901 key.to_string(),
902 PatchPayload {
903 old: old.to_string(),
904 new: new.to_string(),
905 all: replace_all,
906 },
907 );
908 }
909 }
910 Ok(out)
911}