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