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