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