Skip to main content

memstead_cli/
lib.rs

1//! Memstead CLI library — the command modules, utility modules, and
2//! the shared `CliError` behind the `memstead` binary (`src/main.rs`).
3//!
4//! One crate, two build configs. The default build (`mem-repo`
5//! feature on) is the full `memstead`: every subcommand, including the
6//! multi-mem / mem-repo lifecycle (mem, workspace, install,
7//! batch-update, recover). `--no-default-features` drops the git-branch
8//! backend and the mem-repo-only subcommands, yielding the lean
9//! engine-agnostic surface (a CI / wasm-adjacent config, not shipped).
10
11pub mod auth;
12pub mod cli;
13pub mod commands;
14#[cfg(feature = "mem-repo")]
15pub mod outer_gitignore;
16pub mod output;
17pub mod registry;
18pub mod setup;
19
20use output::ExitKind;
21
22/// Stable wire token for genuinely-systemic failures the agent can't
23/// recover from (I/O panic, store corruption, unreachable branch).
24/// The `code` field is non-optional, so this constant exists for
25/// callsites that explicitly choose it — defaulting to it is not
26/// possible. Adding a new callsite using this constant should carry a
27/// comment explaining why no recoverable typed code applies.
28pub const INTERNAL_CODE: &str = "INTERNAL";
29
30/// Argument-validation refusal: mutating commands require one of
31/// `--auto-hash`, `--expected-hash`, or `--force`. The typed code lets
32/// agents branch on the wire token rather than parsing the message.
33pub const HASH_FLAG_REQUIRED_CODE: &str = "HASH_FLAG_REQUIRED";
34
35/// `memstead init <target>` refusal: target directory is non-empty (the
36/// init refuses to scribble over existing files / pre-existing
37/// workspaces).
38pub const TARGET_NOT_EMPTY_CODE: &str = "TARGET_NOT_EMPTY";
39
40/// `memstead overview --chunk <N>` refusal: requested chunk index is
41/// beyond the actual chunk count.
42pub const CHUNK_OUT_OF_RANGE_CODE: &str = "CHUNK_OUT_OF_RANGE";
43
44/// `memstead init` refusal: ancestor walk found an existing
45/// `.memstead/workspace.toml` above the target. Without this guard a
46/// standalone init would silently nest a fresh filesystem-mem
47/// workspace inside an existing one, with neither workspace aware of
48/// the other.
49pub const WORKSPACE_ALREADY_EXISTS_ABOVE_CODE: &str = "WORKSPACE_ALREADY_EXISTS_ABOVE";
50
51/// `memstead install <archive>` refusal: archive failed strict
52/// validation (any of the variants the strict-archive validator can
53/// produce).
54pub const ARCHIVE_VALIDATION_FAILED_CODE: &str = "ARCHIVE_VALIDATION_FAILED";
55
56/// Typed CLI error that carries an exit-code kind, a stable
57/// `UPPER_SNAKE_CASE` code (matching `EngineError::code()` for
58/// engine-sourced errors), and an optional structured details payload.
59/// Wrap with `anyhow::Error` via `.into()` or `map_err` to propagate up
60/// to `main`, which renders the error through
61/// [`output::print_cli_error`] in the documented `{code, message, details}`
62/// shape (or `memstead: ERROR [<CODE>]: <message>` on the text channel).
63///
64/// `code` is non-optional, so every construction site spells the wire
65/// token — there is no `Option`-default-to-`INTERNAL` fallback that
66/// could leak `INTERNAL` when a callsite forgets to set it.
67/// Engine-sourced errors capture `EngineError::code()` via
68/// [`CliError::from_engine_op`]; setup-layer paths pin their own typed
69/// token at construction time.
70///
71/// `details` is the structured recovery payload — e.g. `HashMismatch`
72/// populates `{"current": "<hash>"}` so scripts can lift the recovery
73/// hash without re-reading the entity. The renderer surfaces it under
74/// the `details` key of the JSON envelope.
75#[derive(Debug)]
76pub struct CliError {
77    pub kind: ExitKind,
78    pub code: &'static str,
79    pub message: String,
80    pub details: Option<serde_json::Value>,
81}
82
83impl std::fmt::Display for CliError {
84    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
85        f.write_str(&self.message)
86    }
87}
88
89impl std::error::Error for CliError {}
90
91impl CliError {
92    /// Construct a typed CLI error with an exit-kind, wire code, and
93    /// human message. Every callsite spells the code at construction
94    /// time — there is no Option-default-to-INTERNAL fallback. Use
95    /// [`INTERNAL_CODE`] explicitly for genuinely-systemic paths.
96    pub fn new(kind: ExitKind, code: &'static str, message: impl Into<String>) -> Self {
97        Self {
98            kind,
99            code,
100            message: message.into(),
101            details: None,
102        }
103    }
104
105    pub fn with_details(mut self, details: serde_json::Value) -> Self {
106        self.details = Some(details);
107        self
108    }
109
110    /// Override the wire code on an existing `CliError`. This helper exists
111    /// for callsites that build an error in steps (e.g.
112    /// `setup` layers that mint the error before knowing whether to
113    /// retag it with a more specific code). New code should spell the
114    /// final code at [`CliError::new`] construction time.
115    pub fn with_code(mut self, code: &'static str) -> Self {
116        self.code = code;
117        self
118    }
119
120    /// Field accessor preserved as a method for backward compatibility
121    /// with the previous `Option<&'static str>` API. Now a trivial
122    /// `self.code` return; kept so existing call sites don't need to
123    /// flip method-call syntax to field-access syntax.
124    pub fn effective_code(&self) -> &'static str {
125        self.code
126    }
127
128    /// Map an [`memstead_base::EngineError`] to a typed CLI error with the
129    /// right exit code (`NOT_FOUND` → 3, `HASH_MISMATCH` → 4,
130    /// validation errors → 5, everything else → 1) and the typed wire
131    /// code from `EngineError::code()`. Recovery payloads
132    /// (`HashMismatch.current`, `HasIncomingRefs.referrers`,
133    /// `WikiLinkWithoutRelation.missing`, etc.) land under `details` so
134    /// `--json` callers consume the same `{code, message, details}`
135    /// envelope they get over MCP — bit-identical wire shape across
136    /// surfaces is the agent contract this method delivers.
137    pub fn from_engine_op(e: memstead_base::EngineError) -> Self {
138        use memstead_base::EngineError::*;
139        let code = e.code();
140        let (kind, details) = match &e {
141            NotFound { id } => (ExitKind::NotFound, Some(serde_json::json!({ "id": id }))),
142            HashMismatch {
143                id,
144                current,
145                is_stub,
146            } => (
147                ExitKind::HashMismatch,
148                Some(serde_json::json!({
149                    "id": id,
150                    "current": current,
151                    "is_stub": is_stub,
152                })),
153            ),
154            HasIncomingRefs { id, referrers } => {
155                let referrers_json: Vec<_> = referrers
156                    .iter()
157                    .map(|r| {
158                        serde_json::json!({
159                            "from_id": r.from_id,
160                            "rel_types": r.rel_types,
161                            "mem": r.mem,
162                            "capability": "write",
163                        })
164                    })
165                    .collect();
166                (
167                    ExitKind::Validation,
168                    Some(serde_json::json!({
169                        "id": id,
170                        "referrers": referrers_json,
171                    })),
172                )
173            }
174            MemHasIncomingRefs { mem, referrers } => {
175                let referrers_json: Vec<_> = referrers
176                    .iter()
177                    .map(|r| {
178                        serde_json::json!({
179                            "from_id": r.from_id,
180                            "rel_types": r.rel_types,
181                            "mem": r.mem,
182                        })
183                    })
184                    .collect();
185                (
186                    ExitKind::Validation,
187                    Some(serde_json::json!({
188                        "mem": mem,
189                        "referrers": referrers_json,
190                    })),
191                )
192            }
193            WikiLinkWithoutRelation { from_id, missing } => (
194                ExitKind::Validation,
195                Some(serde_json::json!({
196                    "from_id": from_id,
197                    "missing": missing,
198                })),
199            ),
200            // Block-tier declared-constraint refusals — validation
201            // errors with the same recovery payload the MCP envelope
202            // carries (`EngineError::details`).
203            ConstraintUnsatisfied { .. }
204            | RequiredOutgoingUnsatisfied { .. }
205            | SectionFormatRefused { .. } => (ExitKind::Validation, Some(e.details())),
206            RelationHasBodyLinks {
207                from_id,
208                to_id,
209                rel_type,
210                body_links,
211            } => (
212                ExitKind::Validation,
213                Some(serde_json::json!({
214                    "from_id": from_id,
215                    "to_id": to_id,
216                    "rel_type": rel_type,
217                    "body_links": body_links,
218                })),
219            ),
220            InvalidEntityId { id, reason } => (
221                ExitKind::Validation,
222                Some(serde_json::json!({ "id": id, "reason": reason })),
223            ),
224            InvalidWikiLinkTarget {
225                raw,
226                suggested,
227                section,
228                link_source,
229                reason,
230            } => (
231                ExitKind::Validation,
232                Some(serde_json::json!({
233                    "raw": raw,
234                    "suggested": suggested,
235                    "section": section,
236                    "source": link_source,
237                    "reason": reason,
238                })),
239            ),
240            InvalidWikiLinkMem {
241                raw,
242                section,
243                reason,
244            } => (
245                ExitKind::Validation,
246                Some(serde_json::json!({
247                    "raw": raw,
248                    "section": section,
249                    "reason": reason,
250                })),
251            ),
252            CrossMemLinkNotAllowed { from_mem, to_mem } => (
253                ExitKind::Validation,
254                Some(serde_json::json!({
255                    "from_mem": from_mem,
256                    "to_mem": to_mem,
257                })),
258            ),
259            CrossMemTargetNotFound {
260                target_id,
261                target_mem,
262            } => (
263                ExitKind::Validation,
264                Some(serde_json::json!({
265                    "target_id": target_id,
266                    "target_mem": target_mem,
267                })),
268            ),
269            RenameNoOp { id, new_title } => (
270                ExitKind::Validation,
271                Some(serde_json::json!({ "id": id, "new_title": new_title })),
272            ),
273            StubCannotRelate { id } | StubNotUpdatable { id } | StubNotRenamable { id } => {
274                (ExitKind::Validation, Some(serde_json::json!({ "id": id })))
275            }
276            AlreadyExists {
277                id,
278                existing_title,
279                existing_is_stub,
280            } => (
281                ExitKind::Validation,
282                Some(serde_json::json!({
283                    "id": id,
284                    "existing_title": existing_title,
285                    "existing_is_stub": existing_is_stub,
286                })),
287            ),
288            UnknownType {
289                name,
290                schema_ref,
291                declared,
292                suggestion,
293            } => (
294                ExitKind::Validation,
295                Some(serde_json::json!({
296                    "name": name,
297                    "schema_ref": schema_ref,
298                    "declared": declared,
299                    "suggestion": suggestion,
300                })),
301            ),
302            Validation(v) => (ExitKind::Validation, Some(v.details())),
303            MemConfigIncomplete {
304                mem,
305                missing_fields,
306            } => (
307                ExitKind::Validation,
308                Some(serde_json::json!({
309                    "mem": mem,
310                    "missing_fields": missing_fields,
311                    "set_via": format!("memstead mem set-version {mem} <version>"),
312                })),
313            ),
314            InvalidTitle(slug_err) => {
315                use memstead_base::SlugError;
316                let reason = slug_err.reason();
317                let details = match slug_err {
318                    SlugError::IdTooLong { input, length, max } => serde_json::json!({
319                        "reason": reason,
320                        "input": input,
321                        "length": length,
322                        "max": max,
323                    }),
324                    SlugError::TitleEmpty { input } => serde_json::json!({
325                        "reason": reason,
326                        "input": input,
327                    }),
328                    SlugError::TitleHasControlChars {
329                        input,
330                        control_chars,
331                        proposed_slug,
332                    } => {
333                        let control_chars_str: Vec<String> = control_chars
334                            .iter()
335                            .map(|c| c.escape_default().to_string())
336                            .collect();
337                        serde_json::json!({
338                            "reason": reason,
339                            "input": input,
340                            "control_chars": control_chars_str,
341                            "proposed_slug": proposed_slug,
342                        })
343                    }
344                };
345                (ExitKind::Validation, Some(details))
346            }
347            // Exhaustiveness: the
348            // arms below replace a pre-existing `_ => (Generic, None)`
349            // wildcard that silently swallowed `DescriptionNotPermitted`,
350            // `MissingRequiredDescription`, and the rename-policy /
351            // partial-failure variants — trained CLI agents to treat
352            // these as Generic (exit 1) without structured details. The
353            // exhaustive match forces every new `EngineError` variant to
354            // declare its CLI shape before it can land. Compiler is the
355            // forcing function.
356            DescriptionNotPermitted {
357                rel_type,
358                from_id,
359                to_id,
360            } => (
361                ExitKind::Validation,
362                Some(serde_json::json!({
363                    "rel_type": rel_type,
364                    "from_id": from_id,
365                    "to_id": to_id,
366                })),
367            ),
368            MissingRequiredDescription {
369                rel_type,
370                from_id,
371                to_id,
372            } => (
373                ExitKind::Validation,
374                Some(serde_json::json!({
375                    "rel_type": rel_type,
376                    "from_id": from_id,
377                    "to_id": to_id,
378                })),
379            ),
380            RelationManualAuthoringForbidden {
381                rel_type,
382                from_id,
383                to_id,
384                guidance,
385            } => (
386                ExitKind::Validation,
387                Some(serde_json::json!({
388                    "rel_type": rel_type,
389                    "from_id": from_id,
390                    "to_id": to_id,
391                    "guidance": guidance,
392                })),
393            ),
394            CrossMemEdgeNotDeclared {
395                source_schema,
396                target_schema,
397                rel_type,
398                from_id,
399                to_id,
400            } => (
401                ExitKind::Validation,
402                Some(serde_json::json!({
403                    "source_schema": source_schema,
404                    "target_schema": target_schema,
405                    "rel_type": rel_type,
406                    "from_id": from_id,
407                    "to_id": to_id,
408                })),
409            ),
410            RepairNotNeeded { id, recovery } => (
411                ExitKind::Validation,
412                Some(serde_json::json!({ "id": id, "recovery": recovery })),
413            ),
414            ConflictingSectionModes { section, modes } => (
415                ExitKind::Validation,
416                Some(serde_json::json!({ "section": section, "modes": modes })),
417            ),
418            RelationshipCycle {
419                rel_type,
420                from,
421                to,
422                existing_path,
423                path_truncated,
424            } => {
425                let existing_path_json: Vec<String> =
426                    existing_path.iter().map(|id| id.to_string()).collect();
427                (
428                    ExitKind::Validation,
429                    Some(serde_json::json!({
430                        "rel_type": rel_type,
431                        "from": from.to_string(),
432                        "to": to.to_string(),
433                        "existing_path": existing_path_json,
434                        "path_truncated": path_truncated,
435                    })),
436                )
437            }
438            SetAndUnsetConflict { keys } => (
439                ExitKind::Validation,
440                Some(serde_json::json!({ "keys": keys })),
441            ),
442            RequiredFieldUnset {
443                field,
444                entity_type,
445                field_description,
446                enum_values,
447                type_write_rules,
448                // `on_create` is a prose-dispatch discriminator only;
449                // the structured details payload is identical on both
450                // call sites.
451                on_create: _,
452                missing,
453            } => {
454                // `details.missing[]` carries every required-no-
455                // default field unset on the create path. Each
456                // entry echoes the type-level `write_rules`.
457                let missing_json: Vec<_> = missing
458                    .iter()
459                    .map(|m| {
460                        serde_json::json!({
461                            "field": m.key,
462                            "description": m.description,
463                            "enum_values": m.enum_values,
464                            "write_rules": type_write_rules,
465                        })
466                    })
467                    .collect();
468                (
469                    ExitKind::Validation,
470                    Some(serde_json::json!({
471                        "field": field,
472                        "entity_type": entity_type,
473                        "field_description": field_description,
474                        "enum_values": enum_values,
475                        "type_write_rules": type_write_rules,
476                        "missing": missing_json,
477                    })),
478                )
479            }
480            MissingRequiredSection {
481                entity_type,
482                missing_count,
483                sections,
484                type_guidance,
485            } => {
486                let sections_json: Vec<_> = sections
487                    .iter()
488                    .map(|s| {
489                        serde_json::json!({
490                            "entity_type": s.entity_type,
491                            "key": s.key,
492                            "heading": s.heading,
493                            "write_rules": s.write_rules,
494                        })
495                    })
496                    .collect();
497                (
498                    ExitKind::Validation,
499                    Some(serde_json::json!({
500                        "entity_type": entity_type,
501                        "missing_count": missing_count,
502                        "sections": sections_json,
503                        "type_guidance": type_guidance,
504                    })),
505                )
506            }
507            PatchSectionEmpty { section } => (
508                ExitKind::Validation,
509                Some(serde_json::json!({ "section": section })),
510            ),
511            PatchOldNotFound {
512                section,
513                current_content,
514                truncated,
515            } => (
516                ExitKind::Validation,
517                Some(serde_json::json!({
518                    "section": section,
519                    "current_content": current_content,
520                    "truncated": truncated,
521                })),
522            ),
523            RenameBlockedByCrossMemPolicy {
524                from_mem,
525                blocked_referrers,
526            } => {
527                let entries: Vec<_> = blocked_referrers
528                    .iter()
529                    .map(|r| {
530                        serde_json::json!({
531                            "from_mem": r.from_mem,
532                            "to_mem": r.to_mem,
533                            "count": r.count,
534                        })
535                    })
536                    .collect();
537                (
538                    ExitKind::Validation,
539                    Some(serde_json::json!({
540                        "from_mem": from_mem,
541                        "blocked_referrers": entries,
542                    })),
543                )
544            }
545            RenamePartialFailure {
546                committed_mems,
547                failed_mem,
548                failure_cause,
549            } => (
550                ExitKind::Validation,
551                Some(serde_json::json!({
552                    "committed_mems": committed_mems,
553                    "failed_mem": failed_mem,
554                    "failure_cause": failure_cause,
555                })),
556            ),
557            MemQuarantined {
558                mem,
559                reason_code,
560                reason_message,
561            } => (
562                ExitKind::Generic,
563                Some(serde_json::json!({
564                    "mem": mem,
565                    "reason_code": reason_code,
566                    "reason_message": reason_message,
567                })),
568            ),
569            UnknownMem(name) => (
570                // A missing/unmatched mem is a not-found condition, the
571                // same category as `ENTITY_NOT_FOUND` (exit 3) — not a
572                // validation refusal. This central engine-error path covers
573                // `reload --mem nope` and every command that surfaces the
574                // engine's `UnknownMem` rather than constructing the code
575                // itself.
576                ExitKind::NotFound,
577                Some(serde_json::json!({ "name": name })),
578            ),
579            UnknownRef(raw) => (
580                ExitKind::Validation,
581                Some(serde_json::json!({ "ref": raw })),
582            ),
583            BranchResetHeadMoved {
584                mem,
585                expected,
586                current,
587            } => (
588                ExitKind::Validation,
589                Some(serde_json::json!({
590                    "mem": mem,
591                    "expected": expected,
592                    "current": current,
593                })),
594            ),
595            PushedCommitsProtected {
596                mem,
597                target_sha,
598                pushed_shas,
599            } => (
600                ExitKind::Validation,
601                Some(serde_json::json!({
602                    "mem": mem,
603                    "target_sha": target_sha,
604                    "pushed_shas": pushed_shas,
605                })),
606            ),
607            UnknownRemote(name) => (
608                ExitKind::Validation,
609                Some(serde_json::json!({ "remote": name })),
610            ),
611            LocalDivergence { mem, remote_ref } => (
612                ExitKind::Validation,
613                Some(serde_json::json!({
614                    "mem": mem,
615                    "remote_ref": remote_ref,
616                })),
617            ),
618            NonFastForward { mem, remote } => (
619                ExitKind::Validation,
620                Some(serde_json::json!({
621                    "mem": mem,
622                    "remote": remote,
623                })),
624            ),
625            LocalInvalidState {
626                mem,
627                remote,
628                detail,
629            } => (
630                ExitKind::Validation,
631                Some(serde_json::json!({
632                    "mem": mem,
633                    "remote": remote,
634                    "detail": detail,
635                })),
636            ),
637            SchemaViolationInFetch {
638                mem,
639                ref_name,
640                violations,
641            } => (
642                ExitKind::Validation,
643                Some(serde_json::json!({
644                    "mem": mem,
645                    "ref": ref_name,
646                    "violations": violations,
647                })),
648            ),
649            ReadOnlyMount(mem) => (
650                ExitKind::Validation,
651                Some(serde_json::json!({ "mem": mem })),
652            ),
653            CheckNotRecorded { reason } => (
654                ExitKind::Generic,
655                Some(serde_json::json!({ "reason": reason })),
656            ),
657            MemNameCollision {
658                name,
659                source_origin,
660            } => (
661                ExitKind::Validation,
662                Some(serde_json::json!({
663                    "name": name,
664                    "source": source_origin,
665                })),
666            ),
667            e @ SchemaNotFound { .. } => (ExitKind::Validation, Some(e.details())),
668            EmbeddedSchemaInvalid { mem, pin, reason } => (
669                ExitKind::Validation,
670                Some(serde_json::json!({
671                    "mem": mem,
672                    "schema": pin,
673                    "error": reason,
674                })),
675            ),
676            SchemaPackageInvalid {
677                name,
678                version,
679                message,
680            } => (
681                ExitKind::Validation,
682                Some(serde_json::json!({
683                    "schema": format!("{name}@{version}"),
684                    "error": message,
685                })),
686            ),
687            InvalidInput(msg) => (
688                ExitKind::Validation,
689                Some(serde_json::json!({ "message": msg })),
690            ),
691            RenameSimilarityOutOfRange {
692                requested,
693                allowed_min,
694                allowed_max,
695            } => (
696                ExitKind::Validation,
697                Some(serde_json::json!({
698                    "field": "rename_similarity",
699                    "requested": requested,
700                    "allowed_range": [allowed_min, allowed_max],
701                })),
702            ),
703            // Engine-internal / boundary errors: no user-recoverable
704            // structured payload. Code + message are sufficient — the
705            // CLI surfaces the typed code via `e.code()` (already set
706            // at the top of this fn) and the message text describes
707            // the underlying cause.
708            DuplicateMem(name) => (ExitKind::Generic, Some(serde_json::json!({ "name": name }))),
709            SchemaResolverInit(detail) => (
710                ExitKind::Generic,
711                Some(serde_json::json!({ "detail": detail })),
712            ),
713            Mem(detail) => (
714                ExitKind::Generic,
715                Some(serde_json::json!({ "detail": detail })),
716            ),
717            ParseAfterWrite(detail) => (
718                ExitKind::Generic,
719                Some(serde_json::json!({ "detail": detail })),
720            ),
721            Parse(inner) => (
722                ExitKind::Generic,
723                Some(serde_json::json!({ "detail": inner.to_string() })),
724            ),
725            Backend(inner) => (
726                ExitKind::Generic,
727                Some(serde_json::json!({ "detail": inner.to_string() })),
728            ),
729            SearchUnavailable => (ExitKind::Generic, Some(serde_json::json!({}))),
730            // Typed refusal
731            // when `memstead export --format markdown --mem-name <V>`
732            // targets a backend that doesn't support markdown
733            // regeneration. Validation-class exit code matches other
734            // backend-incompatibility refusals.
735            MarkdownExportUnsupportedBackend {
736                mem,
737                active_backend,
738                supported_backends,
739            } => (
740                ExitKind::Validation,
741                Some(serde_json::json!({
742                    "mem": mem,
743                    "active_backend": active_backend,
744                    "supported_backends": supported_backends,
745                })),
746            ),
747            EmptyUpdate { id } => (
748                ExitKind::Validation,
749                Some(serde_json::json!({
750                    "id": id,
751                    "recognised_keys": [
752                        "sections", "append_sections", "patch_sections",
753                        "metadata", "metadata_unset", "declare_relations",
754                    ],
755                })),
756            ),
757            // A bad `--since` cursor surfaces the typed `INVALID_CURSOR` (via
758            // `e.code()`) with the untruncated SHA — rather than leaking it
759            // as the `MEM_ERROR` catch-all.
760            InvalidChangesCursor { mem, since } => (
761                ExitKind::Validation,
762                Some(serde_json::json!({ "mem": mem, "since": since })),
763            ),
764            // Review-mark diff on a markless mem (code REVIEW_MARK_NOT_SET
765            // via `e.code()`).
766            ReviewMarkNotSet { mem } => (
767                ExitKind::Validation,
768                Some(serde_json::json!({ "mem": mem })),
769            ),
770            // A malformed `anchors[]` element on create/update — typed
771            // `INVALID_ANCHOR` (via `e.code()`) with the wrapped anchor
772            // error's recovery detail (offending field, bad value, allowed
773            // set).
774            InvalidAnchor(anchor_err) => (
775                ExitKind::Validation,
776                Some(serde_json::Value::Object(
777                    anchor_err.detail().into_iter().collect(),
778                )),
779            ),
780        };
781        // Route the CLI message through the rich-prose renderer so markdown-
782        // default mode and `--json --message` consumers see the same
783        // fully-inlined recovery prose the MCP text channel emits.
784        // The `details` channel is unchanged.
785        let message = e.prose_render();
786        Self {
787            kind,
788            code,
789            message,
790            details,
791        }
792    }
793}
794
795#[cfg(test)]
796mod tests {
797    use super::*;
798    use crate::output::ExitKind;
799    use memstead_base::EngineError;
800    use memstead_base::engine::MissingWikiLink;
801
802    /// `DescriptionNotPermitted` must reach the CLI wire as
803    /// `code: DESCRIPTION_NOT_PERMITTED` with `ExitKind::Validation`
804    /// (exit code 5) and structured details — not `Generic` (exit code
805    /// 1) with `details: None`.
806    #[test]
807    fn from_engine_op_description_not_permitted_carries_validation_and_details() {
808        let err = EngineError::DescriptionNotPermitted {
809            rel_type: "REFERENCES".to_string(),
810            from_id: "demo--source".to_string(),
811            to_id: "demo--target".to_string(),
812        };
813        let cli = CliError::from_engine_op(err);
814        assert_eq!(cli.kind, ExitKind::Validation);
815        assert_eq!(cli.code, "DESCRIPTION_NOT_PERMITTED");
816        let details = cli.details.expect("details must carry structured payload");
817        assert_eq!(
818            details.get("rel_type").and_then(|v| v.as_str()),
819            Some("REFERENCES")
820        );
821        assert_eq!(
822            details.get("from_id").and_then(|v| v.as_str()),
823            Some("demo--source")
824        );
825        assert_eq!(
826            details.get("to_id").and_then(|v| v.as_str()),
827            Some("demo--target")
828        );
829    }
830
831    /// `MissingRequiredDescription` shares the same
832    /// envelope shape so the agent's branch logic is symmetric.
833    #[test]
834    fn from_engine_op_missing_required_description_carries_validation_and_details() {
835        let err = EngineError::MissingRequiredDescription {
836            rel_type: "CHOSEN".to_string(),
837            from_id: "decisions--example".to_string(),
838            to_id: "specs--target".to_string(),
839        };
840        let cli = CliError::from_engine_op(err);
841        assert_eq!(cli.kind, ExitKind::Validation);
842        assert_eq!(cli.code, "MISSING_REQUIRED_DESCRIPTION");
843        let details = cli.details.expect("details must carry structured payload");
844        assert_eq!(
845            details.get("rel_type").and_then(|v| v.as_str()),
846            Some("CHOSEN")
847        );
848    }
849
850    /// `WikiLinkWithoutRelation` already had a typed CLI arm
851    /// before the exhaustive-match work (the regression class was
852    /// MCP-only), but a smoke test pins the contract so a future
853    /// refactor doesn't drop it back into the wildcard.
854    #[test]
855    fn from_engine_op_wikilink_without_relation_carries_validation_and_missing_list() {
856        let err = EngineError::WikiLinkWithoutRelation {
857            from_id: "demo--source".to_string(),
858            missing: vec![MissingWikiLink {
859                section_key: "identity".to_string(),
860                target_id: "demo--target".to_string(),
861            }],
862        };
863        let cli = CliError::from_engine_op(err);
864        assert_eq!(cli.kind, ExitKind::Validation);
865        assert_eq!(cli.code, "WIKILINK_WITHOUT_RELATION");
866        let details = cli.details.expect("details must carry structured payload");
867        let missing = details
868            .get("missing")
869            .and_then(|v| v.as_array())
870            .expect("details.missing[] must be an array");
871        assert_eq!(missing.len(), 1);
872        let first = &missing[0];
873        assert_eq!(
874            first.get("section_key").and_then(|v| v.as_str()),
875            Some("identity")
876        );
877        assert_eq!(
878            first.get("target_id").and_then(|v| v.as_str()),
879            Some("demo--target")
880        );
881    }
882}