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