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}