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