Skip to main content

memstead_cli/
lib.rs

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