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