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 } => (
549 ExitKind::Validation,
550 Some(serde_json::json!({
551 "section": section,
552 "current_content": current_content,
553 "truncated": truncated,
554 })),
555 ),
556 RenameBlockedByCrossMemPolicy {
557 from_mem,
558 blocked_referrers,
559 } => {
560 let entries: Vec<_> = blocked_referrers
561 .iter()
562 .map(|r| {
563 serde_json::json!({
564 "from_mem": r.from_mem,
565 "to_mem": r.to_mem,
566 "count": r.count,
567 })
568 })
569 .collect();
570 (
571 ExitKind::Validation,
572 Some(serde_json::json!({
573 "from_mem": from_mem,
574 "blocked_referrers": entries,
575 })),
576 )
577 }
578 RenamePartialFailure {
579 committed_mems,
580 failed_mem,
581 failure_cause,
582 } => (
583 ExitKind::Validation,
584 Some(serde_json::json!({
585 "committed_mems": committed_mems,
586 "failed_mem": failed_mem,
587 "failure_cause": failure_cause,
588 })),
589 ),
590 MemQuarantined {
591 mem,
592 reason_code,
593 reason_message,
594 } => (
595 ExitKind::Generic,
596 Some(serde_json::json!({
597 "mem": mem,
598 "reason_code": reason_code,
599 "reason_message": reason_message,
600 })),
601 ),
602 UnknownMem(name) => (
603 // A missing/unmatched mem is a not-found condition, the
604 // same category as `ENTITY_NOT_FOUND` (exit 3) — not a
605 // validation refusal. This central engine-error path covers
606 // `reload --mem nope` and every command that surfaces the
607 // engine's `UnknownMem` rather than constructing the code
608 // itself.
609 ExitKind::NotFound,
610 Some(serde_json::json!({ "name": name })),
611 ),
612 UnknownRef(raw) => (
613 ExitKind::Validation,
614 Some(serde_json::json!({ "ref": raw })),
615 ),
616 BranchResetHeadMoved {
617 mem,
618 expected,
619 current,
620 } => (
621 ExitKind::Validation,
622 Some(serde_json::json!({
623 "mem": mem,
624 "expected": expected,
625 "current": current,
626 })),
627 ),
628 PushedCommitsProtected {
629 mem,
630 target_sha,
631 pushed_shas,
632 } => (
633 ExitKind::Validation,
634 Some(serde_json::json!({
635 "mem": mem,
636 "target_sha": target_sha,
637 "pushed_shas": pushed_shas,
638 })),
639 ),
640 UnknownRemote(name) => (
641 ExitKind::Validation,
642 Some(serde_json::json!({ "remote": name })),
643 ),
644 LocalDivergence { mem, remote_ref } => (
645 ExitKind::Validation,
646 Some(serde_json::json!({
647 "mem": mem,
648 "remote_ref": remote_ref,
649 })),
650 ),
651 NonFastForward { mem, remote } => (
652 ExitKind::Validation,
653 Some(serde_json::json!({
654 "mem": mem,
655 "remote": remote,
656 })),
657 ),
658 LocalInvalidState {
659 mem,
660 remote,
661 detail,
662 } => (
663 ExitKind::Validation,
664 Some(serde_json::json!({
665 "mem": mem,
666 "remote": remote,
667 "detail": detail,
668 })),
669 ),
670 SchemaViolationInFetch {
671 mem,
672 ref_name,
673 violations,
674 } => (
675 ExitKind::Validation,
676 Some(serde_json::json!({
677 "mem": mem,
678 "ref": ref_name,
679 "violations": violations,
680 })),
681 ),
682 ReadOnlyMount(mem) => (
683 ExitKind::Validation,
684 Some(serde_json::json!({ "mem": mem })),
685 ),
686 CheckNotRecorded { reason } => (
687 ExitKind::Generic,
688 Some(serde_json::json!({ "reason": reason })),
689 ),
690 MemNameCollision {
691 name,
692 source_origin,
693 } => (
694 ExitKind::Validation,
695 Some(serde_json::json!({
696 "name": name,
697 "source": source_origin,
698 })),
699 ),
700 e @ SchemaNotFound { .. } => (ExitKind::Validation, Some(e.details())),
701 EmbeddedSchemaInvalid { mem, pin, reason } => (
702 ExitKind::Validation,
703 Some(serde_json::json!({
704 "mem": mem,
705 "schema": pin,
706 "error": reason,
707 })),
708 ),
709 SchemaPackageInvalid {
710 name,
711 version,
712 message,
713 } => (
714 ExitKind::Validation,
715 Some(serde_json::json!({
716 "schema": format!("{name}@{version}"),
717 "error": message,
718 })),
719 ),
720 InvalidInput(msg) => (
721 ExitKind::Validation,
722 Some(serde_json::json!({ "message": msg })),
723 ),
724 RenameSimilarityOutOfRange {
725 requested,
726 allowed_min,
727 allowed_max,
728 } => (
729 ExitKind::Validation,
730 Some(serde_json::json!({
731 "field": "rename_similarity",
732 "requested": requested,
733 "allowed_range": [allowed_min, allowed_max],
734 })),
735 ),
736 // Engine-internal / boundary errors: no user-recoverable
737 // structured payload. Code + message are sufficient — the
738 // CLI surfaces the typed code via `e.code()` (already set
739 // at the top of this fn) and the message text describes
740 // the underlying cause.
741 DuplicateMem(name) => (ExitKind::Generic, Some(serde_json::json!({ "name": name }))),
742 SchemaResolverInit(detail) => (
743 ExitKind::Generic,
744 Some(serde_json::json!({ "detail": detail })),
745 ),
746 Mem(detail) => (
747 ExitKind::Generic,
748 Some(serde_json::json!({ "detail": detail })),
749 ),
750 ParseAfterWrite(detail) => (
751 ExitKind::Generic,
752 Some(serde_json::json!({ "detail": detail })),
753 ),
754 Parse(inner) => (
755 ExitKind::Generic,
756 Some(serde_json::json!({ "detail": inner.to_string() })),
757 ),
758 Backend(inner) => (
759 ExitKind::Generic,
760 Some(serde_json::json!({ "detail": inner.to_string() })),
761 ),
762 SearchUnavailable => (ExitKind::Generic, Some(serde_json::json!({}))),
763 // Typed refusal
764 // when `memstead export --format markdown --mem-name <V>`
765 // targets a backend that doesn't support markdown
766 // regeneration. Validation-class exit code matches other
767 // backend-incompatibility refusals.
768 MarkdownExportUnsupportedBackend {
769 mem,
770 active_backend,
771 supported_backends,
772 } => (
773 ExitKind::Validation,
774 Some(serde_json::json!({
775 "mem": mem,
776 "active_backend": active_backend,
777 "supported_backends": supported_backends,
778 })),
779 ),
780 EmptyUpdate { id } => (
781 ExitKind::Validation,
782 Some(serde_json::json!({
783 "id": id,
784 // The engine's own list, not a copy: four hand-copied
785 // copies disagreed (consistency-sweep 03/04).
786 "recognised_keys":
787 memstead_base::engine::error::RECOGNISED_MUTATION_KEYS,
788 })),
789 ),
790 // A bad `--since` cursor surfaces the typed `INVALID_CURSOR` (via
791 // `e.code()`) with the untruncated cursor — rather than leaking
792 // it as the `MEM_ERROR` catch-all. Both variants share the code:
793 // git-backed mems refuse an unknown commit, timestamp-backed
794 // mems refuse a non-RFC3339 `since` (a `write_id` above all).
795 InvalidChangesCursor { mem, since } | InvalidTimestampCursor { mem, since } => (
796 ExitKind::Validation,
797 Some(serde_json::json!({ "mem": mem, "since": since })),
798 ),
799 // Review-mark diff on a markless mem (code REVIEW_MARK_NOT_SET
800 // via `e.code()`).
801 ReviewMarkNotSet { mem } => (
802 ExitKind::Validation,
803 Some(serde_json::json!({ "mem": mem })),
804 ),
805 // A malformed `anchors[]` element on create/update — typed
806 // `INVALID_ANCHOR` (via `e.code()`) with the wrapped anchor
807 // error's recovery detail (offending field, bad value, allowed
808 // set).
809 InvalidAnchor(anchor_err) => (
810 ExitKind::Validation,
811 Some(serde_json::Value::Object(
812 anchor_err.detail().into_iter().collect(),
813 )),
814 ),
815 // The stored body already ends inside an open fence and this
816 // write does not resolve it (04/02, criterion 5). The detail
817 // carries the sections it buried, which is what tells the
818 // operator what a corrected body has to put back.
819 UnterminatedFenceInStoredBody {
820 id,
821 section,
822 fence,
823 swallowed,
824 } => (
825 ExitKind::Validation,
826 Some(serde_json::json!({
827 "id": id,
828 "section": section,
829 "fence": fence,
830 "swallowed_sections": swallowed,
831 })),
832 ),
833 };
834 // Route the CLI message through the rich-prose renderer so markdown-
835 // default mode and `--json --message` consumers see the same
836 // fully-inlined recovery prose the MCP text channel emits.
837 // The `details` channel is unchanged.
838 let message = e.prose_render();
839 Self {
840 kind,
841 code,
842 message,
843 details,
844 }
845 }
846}
847
848#[cfg(test)]
849mod tests {
850 use super::*;
851 use crate::output::ExitKind;
852 use memstead_base::EngineError;
853 use memstead_base::engine::MissingWikiLink;
854
855 /// `DescriptionNotPermitted` must reach the CLI wire as
856 /// `code: DESCRIPTION_NOT_PERMITTED` with `ExitKind::Validation`
857 /// (exit code 5) and structured details — not `Generic` (exit code
858 /// 1) with `details: None`.
859 #[test]
860 fn from_engine_op_description_not_permitted_carries_validation_and_details() {
861 let err = EngineError::DescriptionNotPermitted {
862 rel_type: "REFERENCES".to_string(),
863 from_id: "demo--source".to_string(),
864 to_id: "demo--target".to_string(),
865 };
866 let cli = CliError::from_engine_op(err);
867 assert_eq!(cli.kind, ExitKind::Validation);
868 assert_eq!(cli.code, "DESCRIPTION_NOT_PERMITTED");
869 let details = cli.details.expect("details must carry structured payload");
870 assert_eq!(
871 details.get("rel_type").and_then(|v| v.as_str()),
872 Some("REFERENCES")
873 );
874 assert_eq!(
875 details.get("from_id").and_then(|v| v.as_str()),
876 Some("demo--source")
877 );
878 assert_eq!(
879 details.get("to_id").and_then(|v| v.as_str()),
880 Some("demo--target")
881 );
882 }
883
884 /// `MissingRequiredDescription` shares the same
885 /// envelope shape so the agent's branch logic is symmetric.
886 #[test]
887 fn from_engine_op_missing_required_description_carries_validation_and_details() {
888 let err = EngineError::MissingRequiredDescription {
889 rel_type: "CHOSEN".to_string(),
890 from_id: "decisions--example".to_string(),
891 to_id: "specs--target".to_string(),
892 };
893 let cli = CliError::from_engine_op(err);
894 assert_eq!(cli.kind, ExitKind::Validation);
895 assert_eq!(cli.code, "MISSING_REQUIRED_DESCRIPTION");
896 let details = cli.details.expect("details must carry structured payload");
897 assert_eq!(
898 details.get("rel_type").and_then(|v| v.as_str()),
899 Some("CHOSEN")
900 );
901 }
902
903 /// `WikiLinkWithoutRelation` already had a typed CLI arm
904 /// before the exhaustive-match work (the regression class was
905 /// MCP-only), but a smoke test pins the contract so a future
906 /// refactor doesn't drop it back into the wildcard.
907 #[test]
908 fn from_engine_op_wikilink_without_relation_carries_validation_and_missing_list() {
909 let err = EngineError::WikiLinkWithoutRelation {
910 from_id: "demo--source".to_string(),
911 missing: vec![MissingWikiLink {
912 section_key: "identity".to_string(),
913 target_id: "demo--target".to_string(),
914 }],
915 };
916 let cli = CliError::from_engine_op(err);
917 assert_eq!(cli.kind, ExitKind::Validation);
918 assert_eq!(cli.code, "WIKILINK_WITHOUT_RELATION");
919 let details = cli.details.expect("details must carry structured payload");
920 let missing = details
921 .get("missing")
922 .and_then(|v| v.as_array())
923 .expect("details.missing[] must be an array");
924 assert_eq!(missing.len(), 1);
925 let first = &missing[0];
926 assert_eq!(
927 first.get("section_key").and_then(|v| v.as_str()),
928 Some("identity")
929 );
930 assert_eq!(
931 first.get("target_id").and_then(|v| v.as_str()),
932 Some("demo--target")
933 );
934 }
935}