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            BranchResetHeadMoved {
569                mem,
570                expected,
571                current,
572            } => (
573                ExitKind::Validation,
574                Some(serde_json::json!({
575                    "mem": mem,
576                    "expected": expected,
577                    "current": current,
578                })),
579            ),
580            PushedCommitsProtected {
581                mem,
582                target_sha,
583                pushed_shas,
584            } => (
585                ExitKind::Validation,
586                Some(serde_json::json!({
587                    "mem": mem,
588                    "target_sha": target_sha,
589                    "pushed_shas": pushed_shas,
590                })),
591            ),
592            UnknownRemote(name) => (
593                ExitKind::Validation,
594                Some(serde_json::json!({ "remote": name })),
595            ),
596            LocalDivergence { mem, remote_ref } => (
597                ExitKind::Validation,
598                Some(serde_json::json!({
599                    "mem": mem,
600                    "remote_ref": remote_ref,
601                })),
602            ),
603            NonFastForward { mem, remote } => (
604                ExitKind::Validation,
605                Some(serde_json::json!({
606                    "mem": mem,
607                    "remote": remote,
608                })),
609            ),
610            LocalInvalidState {
611                mem,
612                remote,
613                detail,
614            } => (
615                ExitKind::Validation,
616                Some(serde_json::json!({
617                    "mem": mem,
618                    "remote": remote,
619                    "detail": detail,
620                })),
621            ),
622            SchemaViolationInFetch {
623                mem,
624                ref_name,
625                violations,
626            } => (
627                ExitKind::Validation,
628                Some(serde_json::json!({
629                    "mem": mem,
630                    "ref": ref_name,
631                    "violations": violations,
632                })),
633            ),
634            ReadOnlyMount(mem) => (
635                ExitKind::Validation,
636                Some(serde_json::json!({ "mem": mem })),
637            ),
638            MemNameCollision {
639                name,
640                source_origin,
641            } => (
642                ExitKind::Validation,
643                Some(serde_json::json!({
644                    "name": name,
645                    "source": source_origin,
646                })),
647            ),
648            SchemaNotFound { mem, pin, sources } => (
649                ExitKind::Validation,
650                Some(serde_json::json!({ "mem": mem, "pin": pin, "sources": sources })),
651            ),
652            InvalidInput(msg) => (
653                ExitKind::Validation,
654                Some(serde_json::json!({ "message": msg })),
655            ),
656            RenameSimilarityOutOfRange {
657                requested,
658                allowed_min,
659                allowed_max,
660            } => (
661                ExitKind::Validation,
662                Some(serde_json::json!({
663                    "field": "rename_similarity",
664                    "requested": requested,
665                    "allowed_range": [allowed_min, allowed_max],
666                })),
667            ),
668            // Engine-internal / boundary errors: no user-recoverable
669            // structured payload. Code + message are sufficient — the
670            // CLI surfaces the typed code via `e.code()` (already set
671            // at the top of this fn) and the message text describes
672            // the underlying cause.
673            DuplicateMem(name) => (ExitKind::Generic, Some(serde_json::json!({ "name": name }))),
674            SchemaResolverInit(detail) => (
675                ExitKind::Generic,
676                Some(serde_json::json!({ "detail": detail })),
677            ),
678            Mem(detail) => (
679                ExitKind::Generic,
680                Some(serde_json::json!({ "detail": detail })),
681            ),
682            ParseAfterWrite(detail) => (
683                ExitKind::Generic,
684                Some(serde_json::json!({ "detail": detail })),
685            ),
686            Parse(inner) => (
687                ExitKind::Generic,
688                Some(serde_json::json!({ "detail": inner.to_string() })),
689            ),
690            Backend(inner) => (
691                ExitKind::Generic,
692                Some(serde_json::json!({ "detail": inner.to_string() })),
693            ),
694            SearchUnavailable => (ExitKind::Generic, Some(serde_json::json!({}))),
695            // Typed refusal
696            // when `memstead export --format markdown --mem-name <V>`
697            // targets a backend that doesn't support markdown
698            // regeneration. Validation-class exit code matches other
699            // backend-incompatibility refusals.
700            MarkdownExportUnsupportedBackend {
701                mem,
702                active_backend,
703                supported_backends,
704            } => (
705                ExitKind::Validation,
706                Some(serde_json::json!({
707                    "mem": mem,
708                    "active_backend": active_backend,
709                    "supported_backends": supported_backends,
710                })),
711            ),
712            EmptyUpdate { id } => (
713                ExitKind::Validation,
714                Some(serde_json::json!({
715                    "id": id,
716                    "recognised_keys": [
717                        "sections", "append_sections", "patch_sections",
718                        "metadata", "metadata_unset", "declare_relations",
719                    ],
720                })),
721            ),
722            // A bad `--since` cursor surfaces the typed `INVALID_CURSOR` (via
723            // `e.code()`) with the untruncated SHA — rather than leaking it
724            // as the `MEM_ERROR` catch-all.
725            InvalidChangesCursor { mem, since } => (
726                ExitKind::Validation,
727                Some(serde_json::json!({ "mem": mem, "since": since })),
728            ),
729            // A malformed `anchors[]` element on create/update — typed
730            // `INVALID_ANCHOR` (via `e.code()`) with the wrapped anchor
731            // error's recovery detail (offending field, bad value, allowed
732            // set).
733            InvalidAnchor(anchor_err) => (
734                ExitKind::Validation,
735                Some(serde_json::Value::Object(
736                    anchor_err.detail().into_iter().collect(),
737                )),
738            ),
739        };
740        // Route the CLI message through the rich-prose renderer so markdown-
741        // default mode and `--json --message` consumers see the same
742        // fully-inlined recovery prose the MCP text channel emits.
743        // The `details` channel is unchanged.
744        let message = e.prose_render();
745        Self {
746            kind,
747            code,
748            message,
749            details,
750        }
751    }
752}
753
754#[cfg(test)]
755mod tests {
756    use super::*;
757    use crate::output::ExitKind;
758    use memstead_base::EngineError;
759    use memstead_base::engine::MissingWikiLink;
760
761    /// `DescriptionNotPermitted` must reach the CLI wire as
762    /// `code: DESCRIPTION_NOT_PERMITTED` with `ExitKind::Validation`
763    /// (exit code 5) and structured details — not `Generic` (exit code
764    /// 1) with `details: None`.
765    #[test]
766    fn from_engine_op_description_not_permitted_carries_validation_and_details() {
767        let err = EngineError::DescriptionNotPermitted {
768            rel_type: "REFERENCES".to_string(),
769            from_id: "demo--source".to_string(),
770            to_id: "demo--target".to_string(),
771        };
772        let cli = CliError::from_engine_op(err);
773        assert_eq!(cli.kind, ExitKind::Validation);
774        assert_eq!(cli.code, "DESCRIPTION_NOT_PERMITTED");
775        let details = cli.details.expect("details must carry structured payload");
776        assert_eq!(
777            details.get("rel_type").and_then(|v| v.as_str()),
778            Some("REFERENCES")
779        );
780        assert_eq!(
781            details.get("from_id").and_then(|v| v.as_str()),
782            Some("demo--source")
783        );
784        assert_eq!(
785            details.get("to_id").and_then(|v| v.as_str()),
786            Some("demo--target")
787        );
788    }
789
790    /// `MissingRequiredDescription` shares the same
791    /// envelope shape so the agent's branch logic is symmetric.
792    #[test]
793    fn from_engine_op_missing_required_description_carries_validation_and_details() {
794        let err = EngineError::MissingRequiredDescription {
795            rel_type: "CHOSEN".to_string(),
796            from_id: "decisions--example".to_string(),
797            to_id: "specs--target".to_string(),
798        };
799        let cli = CliError::from_engine_op(err);
800        assert_eq!(cli.kind, ExitKind::Validation);
801        assert_eq!(cli.code, "MISSING_REQUIRED_DESCRIPTION");
802        let details = cli.details.expect("details must carry structured payload");
803        assert_eq!(
804            details.get("rel_type").and_then(|v| v.as_str()),
805            Some("CHOSEN")
806        );
807    }
808
809    /// `WikiLinkWithoutRelation` already had a typed CLI arm
810    /// before the exhaustive-match work (the regression class was
811    /// MCP-only), but a smoke test pins the contract so a future
812    /// refactor doesn't drop it back into the wildcard.
813    #[test]
814    fn from_engine_op_wikilink_without_relation_carries_validation_and_missing_list() {
815        let err = EngineError::WikiLinkWithoutRelation {
816            from_id: "demo--source".to_string(),
817            missing: vec![MissingWikiLink {
818                section_key: "identity".to_string(),
819                target_id: "demo--target".to_string(),
820            }],
821        };
822        let cli = CliError::from_engine_op(err);
823        assert_eq!(cli.kind, ExitKind::Validation);
824        assert_eq!(cli.code, "WIKILINK_WITHOUT_RELATION");
825        let details = cli.details.expect("details must carry structured payload");
826        let missing = details
827            .get("missing")
828            .and_then(|v| v.as_array())
829            .expect("details.missing[] must be an array");
830        assert_eq!(missing.len(), 1);
831        let first = &missing[0];
832        assert_eq!(
833            first.get("section_key").and_then(|v| v.as_str()),
834            Some("identity")
835        );
836        assert_eq!(
837            first.get("target_id").and_then(|v| v.as_str()),
838            Some("demo--target")
839        );
840    }
841}