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            SchemaPackageInvalid {
669                name,
670                version,
671                message,
672            } => (
673                ExitKind::Validation,
674                Some(serde_json::json!({
675                    "schema": format!("{name}@{version}"),
676                    "error": message,
677                })),
678            ),
679            InvalidInput(msg) => (
680                ExitKind::Validation,
681                Some(serde_json::json!({ "message": msg })),
682            ),
683            RenameSimilarityOutOfRange {
684                requested,
685                allowed_min,
686                allowed_max,
687            } => (
688                ExitKind::Validation,
689                Some(serde_json::json!({
690                    "field": "rename_similarity",
691                    "requested": requested,
692                    "allowed_range": [allowed_min, allowed_max],
693                })),
694            ),
695            // Engine-internal / boundary errors: no user-recoverable
696            // structured payload. Code + message are sufficient — the
697            // CLI surfaces the typed code via `e.code()` (already set
698            // at the top of this fn) and the message text describes
699            // the underlying cause.
700            DuplicateMem(name) => (ExitKind::Generic, Some(serde_json::json!({ "name": name }))),
701            SchemaResolverInit(detail) => (
702                ExitKind::Generic,
703                Some(serde_json::json!({ "detail": detail })),
704            ),
705            Mem(detail) => (
706                ExitKind::Generic,
707                Some(serde_json::json!({ "detail": detail })),
708            ),
709            ParseAfterWrite(detail) => (
710                ExitKind::Generic,
711                Some(serde_json::json!({ "detail": detail })),
712            ),
713            Parse(inner) => (
714                ExitKind::Generic,
715                Some(serde_json::json!({ "detail": inner.to_string() })),
716            ),
717            Backend(inner) => (
718                ExitKind::Generic,
719                Some(serde_json::json!({ "detail": inner.to_string() })),
720            ),
721            SearchUnavailable => (ExitKind::Generic, Some(serde_json::json!({}))),
722            // Typed refusal
723            // when `memstead export --format markdown --mem-name <V>`
724            // targets a backend that doesn't support markdown
725            // regeneration. Validation-class exit code matches other
726            // backend-incompatibility refusals.
727            MarkdownExportUnsupportedBackend {
728                mem,
729                active_backend,
730                supported_backends,
731            } => (
732                ExitKind::Validation,
733                Some(serde_json::json!({
734                    "mem": mem,
735                    "active_backend": active_backend,
736                    "supported_backends": supported_backends,
737                })),
738            ),
739            EmptyUpdate { id } => (
740                ExitKind::Validation,
741                Some(serde_json::json!({
742                    "id": id,
743                    "recognised_keys": [
744                        "sections", "append_sections", "patch_sections",
745                        "metadata", "metadata_unset", "declare_relations",
746                    ],
747                })),
748            ),
749            // A bad `--since` cursor surfaces the typed `INVALID_CURSOR` (via
750            // `e.code()`) with the untruncated SHA — rather than leaking it
751            // as the `MEM_ERROR` catch-all.
752            InvalidChangesCursor { mem, since } => (
753                ExitKind::Validation,
754                Some(serde_json::json!({ "mem": mem, "since": since })),
755            ),
756            // Review-mark diff on a markless mem (code REVIEW_MARK_NOT_SET
757            // via `e.code()`).
758            ReviewMarkNotSet { mem } => (
759                ExitKind::Validation,
760                Some(serde_json::json!({ "mem": mem })),
761            ),
762            // A malformed `anchors[]` element on create/update — typed
763            // `INVALID_ANCHOR` (via `e.code()`) with the wrapped anchor
764            // error's recovery detail (offending field, bad value, allowed
765            // set).
766            InvalidAnchor(anchor_err) => (
767                ExitKind::Validation,
768                Some(serde_json::Value::Object(
769                    anchor_err.detail().into_iter().collect(),
770                )),
771            ),
772        };
773        // Route the CLI message through the rich-prose renderer so markdown-
774        // default mode and `--json --message` consumers see the same
775        // fully-inlined recovery prose the MCP text channel emits.
776        // The `details` channel is unchanged.
777        let message = e.prose_render();
778        Self {
779            kind,
780            code,
781            message,
782            details,
783        }
784    }
785}
786
787#[cfg(test)]
788mod tests {
789    use super::*;
790    use crate::output::ExitKind;
791    use memstead_base::EngineError;
792    use memstead_base::engine::MissingWikiLink;
793
794    /// `DescriptionNotPermitted` must reach the CLI wire as
795    /// `code: DESCRIPTION_NOT_PERMITTED` with `ExitKind::Validation`
796    /// (exit code 5) and structured details — not `Generic` (exit code
797    /// 1) with `details: None`.
798    #[test]
799    fn from_engine_op_description_not_permitted_carries_validation_and_details() {
800        let err = EngineError::DescriptionNotPermitted {
801            rel_type: "REFERENCES".to_string(),
802            from_id: "demo--source".to_string(),
803            to_id: "demo--target".to_string(),
804        };
805        let cli = CliError::from_engine_op(err);
806        assert_eq!(cli.kind, ExitKind::Validation);
807        assert_eq!(cli.code, "DESCRIPTION_NOT_PERMITTED");
808        let details = cli.details.expect("details must carry structured payload");
809        assert_eq!(
810            details.get("rel_type").and_then(|v| v.as_str()),
811            Some("REFERENCES")
812        );
813        assert_eq!(
814            details.get("from_id").and_then(|v| v.as_str()),
815            Some("demo--source")
816        );
817        assert_eq!(
818            details.get("to_id").and_then(|v| v.as_str()),
819            Some("demo--target")
820        );
821    }
822
823    /// `MissingRequiredDescription` shares the same
824    /// envelope shape so the agent's branch logic is symmetric.
825    #[test]
826    fn from_engine_op_missing_required_description_carries_validation_and_details() {
827        let err = EngineError::MissingRequiredDescription {
828            rel_type: "CHOSEN".to_string(),
829            from_id: "decisions--example".to_string(),
830            to_id: "specs--target".to_string(),
831        };
832        let cli = CliError::from_engine_op(err);
833        assert_eq!(cli.kind, ExitKind::Validation);
834        assert_eq!(cli.code, "MISSING_REQUIRED_DESCRIPTION");
835        let details = cli.details.expect("details must carry structured payload");
836        assert_eq!(
837            details.get("rel_type").and_then(|v| v.as_str()),
838            Some("CHOSEN")
839        );
840    }
841
842    /// `WikiLinkWithoutRelation` already had a typed CLI arm
843    /// before the exhaustive-match work (the regression class was
844    /// MCP-only), but a smoke test pins the contract so a future
845    /// refactor doesn't drop it back into the wildcard.
846    #[test]
847    fn from_engine_op_wikilink_without_relation_carries_validation_and_missing_list() {
848        let err = EngineError::WikiLinkWithoutRelation {
849            from_id: "demo--source".to_string(),
850            missing: vec![MissingWikiLink {
851                section_key: "identity".to_string(),
852                target_id: "demo--target".to_string(),
853            }],
854        };
855        let cli = CliError::from_engine_op(err);
856        assert_eq!(cli.kind, ExitKind::Validation);
857        assert_eq!(cli.code, "WIKILINK_WITHOUT_RELATION");
858        let details = cli.details.expect("details must carry structured payload");
859        let missing = details
860            .get("missing")
861            .and_then(|v| v.as_array())
862            .expect("details.missing[] must be an array");
863        assert_eq!(missing.len(), 1);
864        let first = &missing[0];
865        assert_eq!(
866            first.get("section_key").and_then(|v| v.as_str()),
867            Some("identity")
868        );
869        assert_eq!(
870            first.get("target_id").and_then(|v| v.as_str()),
871            Some("demo--target")
872        );
873    }
874}