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