Skip to main content

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