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