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