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