1use wyvern_schema::{ErrorCode, SerializeError, StderrError, ValidationError};
4
5use super::{EmitError, LoadError, UsageErrorKind};
6
7#[cfg(test)]
8thread_local! {
9 static FORCE_EMIT_STDOUT_FAIL: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
11}
12
13#[cfg(test)]
15pub(super) struct ForceEmitStdoutFailGuard;
16
17#[cfg(test)]
18impl ForceEmitStdoutFailGuard {
19 pub(super) fn arm() -> Self {
20 FORCE_EMIT_STDOUT_FAIL.with(|f| f.set(true));
21 Self
22 }
23}
24
25#[cfg(test)]
26impl Drop for ForceEmitStdoutFailGuard {
27 fn drop(&mut self) {
28 FORCE_EMIT_STDOUT_FAIL.with(|f| f.set(false));
29 }
30}
31
32pub fn emit_extension_error(err: &crate::extensions::ExtensionError) -> Result<String, EmitError> {
38 use crate::extensions::ExtensionError;
39 let (code, message, cause, recovery) = match err {
40 ExtensionError::InvalidRegistry { message } => (
41 ErrorCode::ParseError,
42 message.clone(),
43 "Extension registry JSON could not be loaded".to_string(),
44 vec![
45 "Fix share/wyvern/extensions.json or .wyvern/extensions.json".into(),
46 "Registry must be version 1 JSON with an extensions array".into(),
47 ],
48 ),
49 ExtensionError::MissingArgs {
50 missing,
51 extension_id,
52 example,
53 help_command,
54 ..
55 } => (
56 ErrorCode::ValidationError,
57 format!(
58 "missing required arguments {} for '{extension_id}'",
59 missing.join(", ")
60 ),
61 format!("'{extension_id}' requires {}", missing.join(" and ")),
62 vec![
63 format!("Pass {} after the extension prefix", missing.join(" ")),
64 format!("Example: {example}"),
65 format!("Run {help_command}"),
66 "Run wyvern --help to list skills".into(),
67 ],
68 ),
69 ExtensionError::UnexpectedArg {
70 token,
71 declared,
72 extension_id,
73 help_command,
74 } => {
75 let accepted = if declared.is_empty() {
76 format!("Run {help_command}")
77 } else {
78 format!(
79 "Accepted flags: {}",
80 declared
81 .iter()
82 .map(|name| format!("--{name}"))
83 .collect::<Vec<_>>()
84 .join(", ")
85 )
86 };
87 (
88 ErrorCode::ValidationError,
89 format!("unexpected argument after extension match: {token}"),
90 format!("'{extension_id}' does not accept leftover token '{token}'"),
91 vec![
92 format!("Remove unexpected argument `{token}`"),
93 accepted,
94 format!("Run {help_command}"),
95 "Run wyvern --help to list skills".into(),
96 ],
97 )
98 }
99 ExtensionError::PathVarWithoutPath { var } => (
100 ErrorCode::ValidationError,
101 format!("template {{{var}}} requires a matched file path"),
102 "This extension is prefix-only and has no {{path}}".to_string(),
103 vec!["Use a suffix or prefix+suffix match when expanding path variables".into()],
104 ),
105 ExtensionError::Template { kind, message, .. } => {
106 let (cause, recovery) = match kind {
107 crate::extensions::TemplateErrorKind::UnclosedBrace => (
108 "Template contains an unclosed `{` brace".to_string(),
109 vec!["Close every `{variable}` in the registry expand/preexec templates".into()],
110 ),
111 crate::extensions::TemplateErrorKind::UnknownVariable => (
112 "Template references an unknown `{variable}`".to_string(),
113 vec!["Use only documented template variables from cli-extensions-contract.md".into()],
114 ),
115 crate::extensions::TemplateErrorKind::PhaseRestricted => (
116 "Template variable is not allowed in this expansion phase".to_string(),
117 vec!["Move path-only variables to expand phase-1; use phase-2 for preexec stdout vars".into()],
118 ),
119 crate::extensions::TemplateErrorKind::Unavailable => (
120 "Template variable is not available in this match context".to_string(),
121 vec!["Ensure the match provides path/tmpdir/preexec stdout before using this variable".into()],
122 ),
123 crate::extensions::TemplateErrorKind::InvalidSpec => (
124 "Expand/preexec spec is incomplete or contradictory".to_string(),
125 vec!["Check command_from_file, preexec cmd/args, and host overrides in the registry".into()],
126 ),
127 };
128 (ErrorCode::ValidationError, message.clone(), cause, recovery)
129 }
130 ExtensionError::Preexec { kind, message, .. } => {
131 use crate::extensions::PreexecFailureKind;
132 let (cause, recovery) = match kind {
133 Some(PreexecFailureKind::SpawnNotFound { cmd }) => (
134 format!("Could not spawn preexec helper '{cmd}'"),
135 vec![
136 format!("Install '{cmd}' or add it to PATH"),
137 "Run wyvern extensions list to see requires".into(),
138 "Run wyvern --help to list skills".into(),
139 ],
140 ),
141 Some(PreexecFailureKind::NonZeroExit { stderr_tail, code }) => {
142 let cause = if stderr_tail.is_empty() {
143 format!("Preexec helper exited with status {code}")
144 } else {
145 stderr_tail.clone()
146 };
147 (
148 cause,
149 vec![
150 "Inspect the helper stderr in cause and fix the input path or flags"
151 .into(),
152 "Retry after correcting the file or arguments".into(),
153 "Run wyvern --help to list skills".into(),
154 ],
155 )
156 }
157 Some(PreexecFailureKind::Timeout { cmd, timeout_secs }) => (
158 format!("Preexec helper '{cmd}' timed out after {timeout_secs}s"),
159 vec![
160 format!(
161 "Increase WYVERN_PREEXEC_TIMEOUT_SECS (current {timeout_secs}) if the helper needs more time"
162 ),
163 "Or fix a hung helper or blocked input path".into(),
164 "Run wyvern --help to list skills".into(),
165 ],
166 ),
167 None => (
168 "Extension preexec subprocess failed".to_string(),
169 vec![
170 "Inspect the helper output in the error message".into(),
171 "Retry after correcting the input path or flags".into(),
172 "Run wyvern --help to list skills".into(),
173 ],
174 ),
175 };
176 (ErrorCode::IoError, message.clone(), cause, recovery)
177 }
178 ExtensionError::InvalidCommand { source } => {
179 return emit_validation_error(source);
180 }
181 ExtensionError::Io { message, .. } => (
182 ErrorCode::IoError,
183 message.clone(),
184 "Extension engine filesystem operation failed".to_string(),
185 vec!["Check paths in the registry and working directory permissions".into()],
186 ),
187 };
188 let mut envelope = StderrError::new(code, message)
189 .cause(cause)
190 .docs("docs/plans/phase-F/cli-extensions-contract.md");
191 for step in recovery {
192 envelope = envelope.recovery(step);
193 }
194 envelope.to_json_string().map_err(EmitError::Serialize)
195}
196
197pub fn emit_usage_message(message: &str) -> Result<String, EmitError> {
203 StderrError::new(ErrorCode::ParseError, message.to_string())
204 .cause("CLI argv was not a valid command or extension invocation")
205 .recovery("Pass a JSON command, a .json file, or a path handled by an extension")
206 .recovery("Run wyvern extensions list to see file-type and prefix extensions")
207 .recovery("Run wyvern --help for host flags")
208 .docs("docs/wyvern/requirements.md (REQ-0130)")
209 .to_json_string()
210 .map_err(EmitError::Serialize)
211}
212
213pub fn emit_usage_error(err: &LoadError) -> Result<String, EmitError> {
220 let LoadError::Usage { kind, message } = err else {
221 debug_assert!(matches!(err, LoadError::Usage { .. }));
222 return Err(EmitError::Serialize(SerializeError {
223 message: "emit_usage_error: expected Usage".into(),
224 }));
225 };
226 let (cause, recovery, docs) = match kind {
227 UsageErrorKind::Generic => (
228 "CLI argv was not a valid command or extension invocation".to_string(),
229 vec![
230 "Pass a JSON command, a .json file, or a path handled by an extension".into(),
231 "Run wyvern extensions list to see file-type and prefix extensions".into(),
232 "Run wyvern --help for host flags".into(),
233 ],
234 "docs/wyvern/requirements.md (REQ-0130)",
235 ),
236 UsageErrorKind::InvalidBind { .. } => (
237 "The --bind value is not a valid socket address".to_string(),
238 vec![
239 "Use host:port form (example: 127.0.0.1:0 for an ephemeral loopback port)".into(),
240 "For 0.0.0.0 / LAN binds, also pass --allow-non-loopback".into(),
241 "Check the address is a valid IPv4/IPv6 socket address".into(),
242 ],
243 "docs/plans/phase-F/README.md",
244 ),
245 UsageErrorKind::MissingFlagValue { flag } => (
246 format!("Host flag {flag} requires a value"),
247 vec![
248 format!("Pass {flag} VALUE on the command line"),
249 "Use --bind=ADDR:PORT or --viewer=MODE inline forms when preferred".into(),
250 ],
251 "docs/plans/phase-F/README.md",
252 ),
253 UsageErrorKind::InvalidViewer { .. } => (
254 "The --viewer value is not a supported viewer mode".to_string(),
255 vec![
256 "Use one of: embedded, none, system, chrome, safari, edge, firefox".into(),
257 "Omit --viewer to use embedded (default)".into(),
258 "Set WYVERN_VIEWER=none for headless / CI".into(),
259 ],
260 "docs/plans/phase-C/http-viewer-contract.md",
261 ),
262 UsageErrorKind::InvalidWyvernViewerEnv { .. } => (
263 "WYVERN_VIEWER is set but not a valid viewer mode".to_string(),
264 vec![
265 "Use one of: embedded, none, system, chrome, safari, edge, firefox".into(),
266 "Unset WYVERN_VIEWER to use embedded (default)".into(),
267 "Use WYVERN_VIEWER=none for headless / CI".into(),
268 ],
269 "docs/plans/phase-C/http-viewer-contract.md",
270 ),
271 UsageErrorKind::InvalidWyvernViewerUnicode => (
272 "WYVERN_VIEWER is not valid Unicode".to_string(),
273 vec![
274 "Set WYVERN_VIEWER to ASCII viewer mode names only".into(),
275 "Unset WYVERN_VIEWER to use embedded (default)".into(),
276 ],
277 "docs/plans/phase-C/http-viewer-contract.md",
278 ),
279 UsageErrorKind::UnknownSubcommand { domain, token } => (
280 format!("'{token}' is not a valid {domain} subcommand"),
281 match domain {
282 super::BuiltinDomain::Browsers => vec![
283 "Use wyvern browsers list or wyvern browsers refresh".into(),
284 "Run wyvern browsers --help".into(),
285 ],
286 super::BuiltinDomain::Extensions => vec![
287 "Use wyvern extensions list or wyvern extensions show <id>".into(),
288 "Run wyvern extensions --help".into(),
289 ],
290 super::BuiltinDomain::Examples => vec![
291 "Use wyvern examples list or wyvern examples list --json".into(),
292 "Run wyvern examples --help".into(),
293 ],
294 super::BuiltinDomain::Wizard => vec![
295 "Use wyvern wizard lint <path>".into(),
296 "Run wyvern wizard --help".into(),
297 ],
298 },
299 "docs/wyvern/requirements.md (REQ-0134)",
300 ),
301 UsageErrorKind::MissingExtensionId => (
302 "extensions show requires a shipped or project extension id".to_string(),
303 vec![
304 "Pass an id: wyvern extensions show <id>".into(),
305 "Run wyvern extensions list to see available ids".into(),
306 "Run wyvern extensions list --json for machine-readable ids".into(),
307 "Run wyvern extensions --help".into(),
308 ],
309 "docs/wyvern/requirements.md (REQ-0132)",
310 ),
311 };
312 let mut envelope = StderrError::new(ErrorCode::ParseError, message.clone())
313 .cause(cause)
314 .docs(docs);
315 for step in recovery {
316 envelope = envelope.recovery(step);
317 }
318 envelope.to_json_string().map_err(EmitError::Serialize)
319}
320
321pub fn emit_parse_error(err: &LoadError) -> Result<String, EmitError> {
328 let LoadError::Parse { message } = err else {
329 debug_assert!(matches!(err, LoadError::Parse { .. }));
330 return Err(EmitError::Serialize(SerializeError {
331 message: "emit_parse_error: expected Parse".into(),
332 }));
333 };
334 StderrError::new(ErrorCode::ParseError, message.clone())
335 .cause("Input was not valid JSON")
336 .recovery("Ensure input is valid JSON")
337 .recovery("Check for trailing commas, unquoted keys, or truncated input")
338 .docs("docs/wyvern-schema/requirements.md (REQ-0069)")
339 .to_json_string()
340 .map_err(EmitError::Serialize)
341}
342
343pub fn emit_io_error(err: &LoadError) -> Result<String, EmitError> {
350 let LoadError::Io { field, message, .. } = err else {
351 debug_assert!(matches!(err, LoadError::Io { .. }));
352 return Err(EmitError::Serialize(SerializeError {
353 message: "emit_io_error: expected Io".into(),
354 }));
355 };
356 StderrError::new(ErrorCode::IoError, message.clone())
357 .field(field.clone())
358 .cause(format!("Failed to read input from '{}'", field.as_str()))
359 .recovery("Verify the file path exists and is readable")
360 .recovery("Pass JSON inline as an argv string or via stdin")
361 .docs("docs/wyvern-schema/requirements.md (REQ-0071)")
362 .to_json_string()
363 .map_err(EmitError::Serialize)
364}
365
366pub fn emit_validation_error(err: &ValidationError) -> Result<String, EmitError> {
372 let envelope = match err {
373 ValidationError::Validation { field, message } => {
374 let mut envelope = StderrError::new(ErrorCode::ValidationError, message.clone())
375 .field(field.clone())
376 .cause(format!("Command JSON failed schema checks on '{field}'"))
377 .docs("docs/wyvern-schema/requirements.md (REQ-0051, REQ-0070)");
378 for step in validation_recovery(field.as_str(), message) {
379 envelope = envelope.recovery(step);
380 }
381 envelope
382 }
383 ValidationError::State { field, message } => {
384 StderrError::new(ErrorCode::StateError, message.clone())
385 .field(field.clone())
386 .cause("Lifecycle action used outside interactive mode")
387 .recovery("Run with --interactive to use lifecycle actions (show/hide/exit)")
388 .recovery("Omit the action field for one-shot chrome commands")
389 .docs("docs/wyvern-schema/requirements.md (REQ-0072)")
390 }
391 };
392 envelope.to_json_string().map_err(EmitError::Serialize)
393}
394
395fn validation_recovery(field: &str, message: &str) -> Vec<String> {
396 if field == "title" && message.contains("missing required field") {
397 return vec![
398 "Add required field \"title\" with a string value".into(),
399 "Example: {\"type\":\"chrome\",\"title\":\"Foundation\"}".into(),
400 ];
401 }
402 if field == "type" && message.contains("missing required field") {
403 return vec![
404 "Add required field \"type\" with value \"chrome\"".into(),
405 "Example: {\"type\":\"chrome\",\"title\":\"Foundation\"}".into(),
406 ];
407 }
408 if field == "type" && message.contains("expected one of") {
409 return vec![
410 "Set \"type\" to one of: chrome, message, input, markdown, question, wizard, report"
411 .into(),
412 "Example: {\"type\":\"report\",\"title\":\"Panel\",\"page\":\"pages/view.xhtml\",\"mode\":\"view\"}"
413 .into(),
414 "Example: {\"type\":\"wizard\",\"page\":{\"id\":\"start\",\"title\":\"Start\",\"html\":\"pages/start.html\"}}"
415 .into(),
416 ];
417 }
418 if field == "page" && message.contains("missing required field") {
419 return vec![
420 "For report commands, set \"page\" to a .html or .xhtml path string relative to --ui-root"
421 .into(),
422 "Example: {\"type\":\"report\",\"title\":\"Panel\",\"page\":\"pages/view.xhtml\",\"mode\":\"view\"}"
423 .into(),
424 "For wizard commands, add required object field \"page\" with id, title, and html"
425 .into(),
426 "Example: {\"type\":\"wizard\",\"page\":{\"id\":\"start\",\"title\":\"Start\",\"html\":\"pages/start.html\"}}"
427 .into(),
428 ];
429 }
430 if field == "page"
431 && (message.contains("must end with")
432 || message.contains("expected string")
433 || message.contains("non-empty string"))
434 {
435 return vec![
436 "Set report \"page\" to a non-empty .html or .xhtml path string relative to --ui-root"
437 .into(),
438 "Example: {\"type\":\"report\",\"title\":\"Panel\",\"page\":\"pages/view.xhtml\",\"mode\":\"view\"}"
439 .into(),
440 ];
441 }
442 if field == "page" && message.contains("expected object") {
443 return vec!["Provide \"page\" as a JSON object with id, title, and html".into()];
444 }
445 if field == "page.id" {
446 return vec![
447 "Set \"page.id\" to a non-empty string page identity".into(),
448 "Example: \"page\":{\"id\":\"start\",\"title\":\"Start\",\"html\":\"pages/start.html\"}"
449 .into(),
450 ];
451 }
452 if field == "page.title" {
453 return vec![
454 "Set \"page.title\" to a non-empty string display title".into(),
455 "Example: \"page\":{\"id\":\"start\",\"title\":\"Start\",\"html\":\"pages/start.html\"}"
456 .into(),
457 ];
458 }
459 if field == "page.html" {
460 return vec![
461 "Set \"page.html\" to a non-empty path relative to --ui-root".into(),
462 "Example: \"page\":{\"id\":\"start\",\"title\":\"Start\",\"html\":\"pages/start.html\"}"
463 .into(),
464 ];
465 }
466 if field == "page.layout" {
467 return vec!["Set \"page.layout\" to one of: dialog, workspace (or omit the field)".into()];
468 }
469 if field.starts_with("page.") && message.contains("unknown field") {
470 return vec![format!(
471 "Remove unknown field \"{field}\"; page allows only id, title, html, and layout"
472 )];
473 }
474 if field == "buttons" {
475 return vec![
476 "Set \"buttons\" to one of: ok, ok_cancel, yes_no, yes_no_cancel, retry_cancel, custom"
477 .into(),
478 ];
479 }
480 if field == "level" {
481 return vec!["Set \"level\" to one of: info, warning, error, question".into()];
482 }
483 if field == "custom_buttons" {
484 return vec![
485 "Provide \"custom_buttons\" as a string array only when \"buttons\" is \"custom\""
486 .into(),
487 ];
488 }
489 if field == "default_button" {
490 return vec![
491 "Set \"default_button\" to a 0-based index within the active button list".into(),
492 ];
493 }
494 if field == "markdown" {
495 return vec!["Provide \"markdown\" as a JSON boolean (true or false)".into()];
496 }
497 if field == "file" && message.contains("exactly one of") {
498 return vec![
499 "Provide exactly one of \"file\" or \"content\" for markdown commands".into(),
500 "Example: {\"type\":\"markdown\",\"file\":\"doc.md\"}".into(),
501 "Example: {\"type\":\"markdown\",\"content\":\"# Hello\"}".into(),
502 ];
503 }
504 if message.contains("expected string") {
505 return vec![format!("Provide field \"{field}\" as a JSON string")];
506 }
507 if message.contains("unknown field") {
508 return vec![format!(
509 "Remove unknown field \"{field}\"; check the schema for this command type"
510 )];
511 }
512 if message.contains("expected JSON object") {
513 return vec!["Pass a single JSON object as the command payload".into()];
514 }
515 vec![format!(
516 "Fix field \"{field}\" to match the current phase command schema"
517 )]
518}
519
520pub fn emit_stdout(result: &wyvern_schema::CommandResult) -> Result<String, EmitError> {
526 #[cfg(test)]
527 {
528 if FORCE_EMIT_STDOUT_FAIL.with(std::cell::Cell::get) {
529 return Err(EmitError::Serialize(SerializeError {
530 message: "forced".into(),
531 }));
532 }
533 }
534 serde_json::to_string(result).map_err(|e| {
535 EmitError::Serialize(SerializeError {
536 message: e.to_string(),
537 })
538 })
539}
540
541pub fn emit_host_error(err: &wyvern_host::HostError) -> Result<String, EmitError> {
547 use wyvern_host::HostError;
548 let (code, message, cause, recovery, docs) = match err {
549 HostError::Bind { message, source } => {
550 let message = match source {
551 Some(err) => format!("{message}: {err}"),
552 None => message.clone(),
553 };
554 (
555 ErrorCode::HostBindError,
556 message,
557 "Failed to bind the dialog HTTP server".to_string(),
558 vec![
559 "Check that --bind is a valid address".into(),
560 "Try --bind 127.0.0.1:0 for an ephemeral port".into(),
561 ],
562 "docs/wyvern-host/requirements.md (REQ-0091)",
563 )
564 }
565 HostError::UiNotFound { path, source } => {
566 let message = match source {
567 Some(err) => format!("UI not found at '{}': {err}", path.display()),
568 None => format!("UI not found at '{}'", path.display()),
569 };
570 (
571 ErrorCode::UiNotFound,
572 message,
573 "Packaged UI root, dialog template, wizard page HTML, or report page is missing".to_string(),
574 vec![
575 "Pass --ui-root pointing at a directory with message/, input/, markdown/, question/, and chrome/ templates".into(),
576 "For wizard commands, ensure page.html exists relative to --ui-root (served under /wizard/**)".into(),
577 "For report commands, ensure page exists relative to --ui-root (served under /report/**)".into(),
578 "Ensure ui/{message,input,markdown,question,chrome}/ exist in the workspace for development".into(),
579 ],
580 "docs/wyvern-host/requirements.md (REQ-0093, REQ-0100)",
581 )
582 }
583 HostError::UnsupportedType { type_name } => (
584 ErrorCode::UnsupportedType,
585 format!("dialog type '{type_name}' is not implemented on the HTTP host yet"),
586 "Schema validation passed; host matrix supports chrome, message, input, markdown, question, wizard, and report".to_string(),
587 vec![
588 "Use one of: chrome, message, input, markdown, question, wizard, report".into(),
589 ],
590 "docs/plans/phase-C/http-dialog-contract.md",
591 ),
592 HostError::InvalidResult { message } => (
593 ErrorCode::HostError,
594 message.clone(),
595 "POST /api/result body was invalid for the active dialog".to_string(),
596 vec!["Submit a body matching the dialog CommandResult wire shape".into()],
597 "docs/plans/phase-C/http-post-schema.md",
598 ),
599 HostError::ViewerNotFound { id, hint } => (
600 ErrorCode::HostViewerError,
601 format!("viewer '{id}' not found"),
602 hint.clone(),
603 vec![
604 format!("Install {id} or use --viewer system"),
605 "Use --viewer none for headless / CI".into(),
606 ],
607 "docs/plans/phase-C/http-viewer-contract.md",
608 ),
609 HostError::ViewerUnsupported { mode } => (
610 ErrorCode::HostViewerError,
611 format!(
612 "viewer mode '{}' is not supported by host::run",
613 mode.as_str()
614 ),
615 "Embedded one-shot must use begin + wyvern-viewer spawn (CLI pipeline)".to_string(),
616 vec![
617 "Omit --viewer or use --viewer embedded (CLI default)".into(),
618 "Use --viewer none for headless / CI".into(),
619 ],
620 "docs/plans/phase-C/http-viewer-contract.md",
621 ),
622 HostError::Registry { message } => (
623 ErrorCode::HostError,
624 message.clone(),
625 "Browser registry cache read/write failed".to_string(),
626 vec![
627 "Run `wyvern browsers refresh` to rebuild the cache".into(),
628 "Check WYVERN_BROWSERS_FILE path and cache directory permissions".into(),
629 "Delete a corrupt browsers.json and retry".into(),
630 ],
631 "docs/plans/phase-C/http-viewer-contract.md",
632 ),
633 HostError::Internal { message } => (
634 ErrorCode::HostError,
635 message.clone(),
636 "Internal HTTP host failure".to_string(),
637 vec![
638 "Retry the command".into(),
639 "Report a bug if it persists".into(),
640 ],
641 "docs/wyvern-host/architecture.md",
642 ),
643 HostError::Wizard { source } => {
644 let subcode = source.subcode();
645 (
646 ErrorCode::HostError,
647 format!("{subcode}: {source}"),
648 format!("{subcode}: wizard session failed during host setup or state access"),
649 vec![
650 format!("See wizard error sub-code {subcode} for the specific failure"),
651 "Ensure the command is type: wizard with a validated page object".into(),
652 "Retry the command; report a bug if a validated wizard has no session".into(),
653 ],
654 "docs/plans/phase-C/http-wizard-contract.md",
655 )
656 }
657 };
658
659 let mut envelope = StderrError::new(code, message).cause(cause).docs(docs);
660 if let HostError::Wizard { source } = err {
661 envelope = envelope.subcode(source.subcode());
662 }
663 for step in recovery {
664 envelope = envelope.recovery(step);
665 }
666 envelope.to_json_string().map_err(EmitError::Serialize)
667}
668
669pub fn emit_wizard_lint_stage_error(
678 err: &crate::wizard_cmd::WizardLintStageError,
679) -> Result<String, EmitError> {
680 use crate::wizard_cmd::WizardLintStageError;
681 const DOCS: &str =
682 ".claude/skills/creating-wyvern-wizard/references/core/validation-and-lint.md";
683 let (code, message, cause, recovery, field) = match err {
684 WizardLintStageError::Io { path, message } => (
685 ErrorCode::IoError,
686 message.clone(),
687 format!("wyvern wizard lint could not read '{}'", path.display()),
688 vec![
689 "Verify the path contains wizard.json and all referenced pages exist".into(),
690 "Run `wyvern wizard lint --help` for usage".into(),
691 ],
692 None,
693 ),
694 WizardLintStageError::Parse { path, message } => (
695 ErrorCode::ParseError,
696 message.clone(),
697 format!("wizard.json at '{}' is not valid JSON", path.display()),
698 vec![
699 "Ensure wizard.json is valid JSON".into(),
700 "Check for trailing commas, unquoted keys, or truncated input".into(),
701 "Run `wyvern wizard lint --help` for usage".into(),
702 ],
703 None,
704 ),
705 WizardLintStageError::Validation {
706 path,
707 field,
708 message,
709 } => (
710 ErrorCode::ValidationError,
711 message.clone(),
712 format!(
713 "wizard.json at '{}' failed field checks on '{field}'",
714 path.display()
715 ),
716 vec![
717 format!("Fix field '{field}' to a non-empty string"),
718 "page.id and page.html must be non-empty".into(),
719 "Run `wyvern wizard lint --help` for usage".into(),
720 ],
721 Some(field.clone()),
722 ),
723 };
724 let mut envelope = StderrError::new(code, message)
725 .subcode(err.subcode())
726 .cause(cause)
727 .docs(DOCS);
728 if let Some(field) = field {
729 envelope = envelope.field(field);
730 }
731 for step in recovery {
732 envelope = envelope.recovery(step);
733 }
734 envelope.to_json_string().map_err(EmitError::Serialize)
735}
736
737pub fn emit_workflow_error(err: &crate::workflow::WorkflowError) -> Result<String, EmitError> {
745 let mut envelope = StderrError::new(ErrorCode::WorkflowError, err.to_string())
746 .cause(err.cause())
747 .subcode(err.subcode())
748 .docs("docs/plans/phase-G/wizard-workflow-architecture.md");
749 for step in err.recovery() {
750 envelope = envelope.recovery(step);
751 }
752 envelope.to_json_string().map_err(EmitError::Serialize)
753}
754
755pub fn emit_fatal_internal(err: &EmitError) -> ! {
760 let EmitError::Serialize(e) = err;
761 let msg_json =
762 serde_json::to_string(&e.message).unwrap_or_else(|_| "\"serialization failed\"".into());
763 eprintln!(
764 r#"{{"error":"internal","code":"INTERNAL_ERROR","message":{msg_json},"cause":"Stdout or stderr JSON serialization failed at the CLI emit boundary","recovery":["Retry the command","Report a bug if the payload is valid JSON but emit still fails"],"docs":"docs/wyvern-schema/requirements.md (REQ-0078)"}}"#
765 );
766 std::process::exit(ErrorCode::InternalError.exit_code());
767}