1use wyvern_schema::{ErrorCode, FieldName, SerializeError, StderrError, ValidationError};
4
5#[derive(Debug)]
7pub enum LoadError {
8 Parse { message: String },
10 Io { field: FieldName, message: String },
12 Usage { message: String },
14}
15
16impl std::fmt::Display for LoadError {
17 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
18 match self {
19 Self::Parse { message } => write!(f, "parse error: {message}"),
20 Self::Io { field, message } => write!(f, "io error ({field}): {message}"),
21 Self::Usage { message } => write!(f, "{message}"),
22 }
23 }
24}
25
26impl std::error::Error for LoadError {}
27
28impl LoadError {
29 pub fn exit_code(&self) -> i32 {
31 match self {
32 Self::Parse { .. } => ErrorCode::ParseError.exit_code(),
33 Self::Io { .. } => ErrorCode::IoError.exit_code(),
34 Self::Usage { .. } => 1,
35 }
36 }
37}
38
39#[derive(Debug)]
41pub enum EmitError {
42 Serialize(SerializeError),
44}
45
46impl std::fmt::Display for EmitError {
47 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48 match self {
49 Self::Serialize(e) => write!(f, "{e}"),
50 }
51 }
52}
53
54impl std::error::Error for EmitError {
55 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
56 match self {
57 Self::Serialize(e) => Some(e),
58 }
59 }
60}
61
62#[cfg(test)]
63thread_local! {
64 static FORCE_EMIT_STDOUT_FAIL: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
66}
67
68#[cfg(test)]
70struct ForceEmitStdoutFailGuard;
71
72#[cfg(test)]
73impl ForceEmitStdoutFailGuard {
74 fn arm() -> Self {
75 FORCE_EMIT_STDOUT_FAIL.with(|f| f.set(true));
76 Self
77 }
78}
79
80#[cfg(test)]
81impl Drop for ForceEmitStdoutFailGuard {
82 fn drop(&mut self) {
83 FORCE_EMIT_STDOUT_FAIL.with(|f| f.set(false));
84 }
85}
86
87pub fn emit_parse_error(err: &LoadError) -> Result<String, EmitError> {
94 let LoadError::Parse { message } = err else {
95 debug_assert!(matches!(err, LoadError::Parse { .. }));
96 return Err(EmitError::Serialize(SerializeError {
97 message: "emit_parse_error: expected Parse".into(),
98 }));
99 };
100 StderrError::new(ErrorCode::ParseError, message.clone())
101 .cause("Input was not valid JSON")
102 .recovery("Ensure input is valid JSON")
103 .recovery("Check for trailing commas, unquoted keys, or truncated input")
104 .docs("docs/wyvern-schema/requirements.md (REQ-0069)")
105 .to_json_string()
106 .map_err(EmitError::Serialize)
107}
108
109pub fn emit_io_error(err: &LoadError) -> Result<String, EmitError> {
116 let LoadError::Io { field, message } = err else {
117 debug_assert!(matches!(err, LoadError::Io { .. }));
118 return Err(EmitError::Serialize(SerializeError {
119 message: "emit_io_error: expected Io".into(),
120 }));
121 };
122 StderrError::new(ErrorCode::IoError, message.clone())
123 .field(field.clone())
124 .cause(format!("Failed to read input from '{}'", field.as_str()))
125 .recovery("Verify the file path exists and is readable")
126 .recovery("Pass JSON inline as an argv string or via stdin")
127 .docs("docs/wyvern-schema/requirements.md (REQ-0071)")
128 .to_json_string()
129 .map_err(EmitError::Serialize)
130}
131
132pub fn emit_validation_error(err: &ValidationError) -> Result<String, EmitError> {
138 let envelope = match err {
139 ValidationError::Validation { field, message } => {
140 let mut envelope = StderrError::new(ErrorCode::ValidationError, message.clone())
141 .field(field.clone())
142 .cause(format!("Command JSON failed schema checks on '{field}'"))
143 .docs("docs/wyvern-schema/requirements.md (REQ-0051, REQ-0070)");
144 for step in validation_recovery(field.as_str(), message) {
145 envelope = envelope.recovery(step);
146 }
147 envelope
148 }
149 ValidationError::State { field, message } => {
150 StderrError::new(ErrorCode::StateError, message.clone())
151 .field(field.clone())
152 .cause("Lifecycle action used outside interactive mode")
153 .recovery("Run with --interactive to use lifecycle actions (show/hide/exit)")
154 .recovery("Omit the action field for one-shot chrome commands")
155 .docs("docs/wyvern-schema/requirements.md (REQ-0072)")
156 }
157 };
158 envelope.to_json_string().map_err(EmitError::Serialize)
159}
160
161fn validation_recovery(field: &str, message: &str) -> Vec<String> {
162 if field == "title" && message.contains("missing required field") {
163 return vec![
164 "Add required field \"title\" with a string value".into(),
165 "Example: {\"type\":\"chrome\",\"title\":\"Foundation\"}".into(),
166 ];
167 }
168 if field == "type" && message.contains("missing required field") {
169 return vec![
170 "Add required field \"type\" with value \"chrome\"".into(),
171 "Example: {\"type\":\"chrome\",\"title\":\"Foundation\"}".into(),
172 ];
173 }
174 if field == "type" && message.contains("expected one of") {
175 return vec![
176 "Set \"type\" to an executable value for this phase (chrome or message)".into(),
177 "Example: {\"type\":\"message\",\"title\":\"T\",\"message\":\"Hi\",\"buttons\":\"ok\"}"
178 .into(),
179 ];
180 }
181 if field == "buttons" {
182 return vec![
183 "Set \"buttons\" to one of: ok, ok_cancel, yes_no, yes_no_cancel, retry_cancel, custom"
184 .into(),
185 ];
186 }
187 if field == "level" {
188 return vec!["Set \"level\" to one of: info, warning, error, question".into()];
189 }
190 if field == "custom_buttons" {
191 return vec![
192 "Provide \"custom_buttons\" as a string array only when \"buttons\" is \"custom\""
193 .into(),
194 ];
195 }
196 if field == "default_button" {
197 return vec![
198 "Set \"default_button\" to a 0-based index within the active button list".into(),
199 ];
200 }
201 if field == "markdown" {
202 return vec!["Provide \"markdown\" as a JSON boolean (true or false)".into()];
203 }
204 if field == "file" && message.contains("exactly one of") {
205 return vec![
206 "Provide exactly one of \"file\" or \"content\" for markdown commands".into(),
207 "Example: {\"type\":\"markdown\",\"file\":\"doc.md\"}".into(),
208 "Example: {\"type\":\"markdown\",\"content\":\"# Hello\"}".into(),
209 ];
210 }
211 if message.contains("expected string") {
212 return vec![format!("Provide field \"{field}\" as a JSON string")];
213 }
214 if message.contains("unknown field") {
215 return vec![format!(
216 "Remove unknown field \"{field}\"; check the schema for this command type"
217 )];
218 }
219 if message.contains("expected JSON object") {
220 return vec!["Pass a single JSON object as the command payload".into()];
221 }
222 vec![format!(
223 "Fix field \"{field}\" to match the current phase command schema"
224 )]
225}
226
227pub fn emit_stdout(result: &wyvern_schema::CommandResult) -> Result<String, EmitError> {
233 #[cfg(test)]
234 {
235 if FORCE_EMIT_STDOUT_FAIL.with(std::cell::Cell::get) {
236 return Err(EmitError::Serialize(SerializeError {
237 message: "forced".into(),
238 }));
239 }
240 }
241 serde_json::to_string(result).map_err(|e| {
242 EmitError::Serialize(SerializeError {
243 message: e.to_string(),
244 })
245 })
246}
247
248pub fn emit_host_error(err: &wyvern_host::HostError) -> Result<String, EmitError> {
254 use wyvern_host::HostError;
255 let (code, message, cause, recovery, docs) = match err {
256 HostError::Bind { message, source } => {
257 let message = match source {
258 Some(err) => format!("{message}: {err}"),
259 None => message.clone(),
260 };
261 (
262 ErrorCode::HostBindError,
263 message,
264 "Failed to bind the dialog HTTP server".to_string(),
265 vec![
266 "Check that --bind is a valid address".into(),
267 "Try --bind 127.0.0.1:0 for an ephemeral port".into(),
268 ],
269 "docs/wyvern-host/requirements.md (REQ-0091)",
270 )
271 }
272 HostError::UiNotFound { path, source } => {
273 let message = match source {
274 Some(err) => format!("UI not found at '{}': {err}", path.display()),
275 None => format!("UI not found at '{}'", path.display()),
276 };
277 (
278 ErrorCode::UiNotFound,
279 message,
280 "Packaged UI root or dialog template is missing".to_string(),
281 vec![
282 "Pass --ui-root pointing at a directory with message/, input/, markdown/, question/, and chrome/ templates".into(),
283 "Ensure ui/{message,input,markdown,question,chrome}/ exist in the workspace for development".into(),
284 ],
285 "docs/wyvern-host/requirements.md (REQ-0093, REQ-0100)",
286 )
287 }
288 HostError::UnsupportedType { type_name } => (
289 ErrorCode::UnsupportedType,
290 format!("dialog type '{type_name}' is not implemented on the HTTP host yet"),
291 "Schema validation passed; host matrix supports message, input, markdown, question, and chrome only".to_string(),
292 vec![
293 "Use one of: message, input, markdown, question, chrome".into(),
294 "wizard lands in Phase D (docs/plans/phase-D/)".into(),
295 ],
296 "docs/plans/phase-C/c14-host-chrome.md",
297 ),
298 HostError::InvalidResult { message } => (
299 ErrorCode::HostError,
300 message.clone(),
301 "POST /api/result body was invalid for the active dialog".to_string(),
302 vec!["Submit a body matching the dialog CommandResult wire shape".into()],
303 "docs/plans/phase-C/http-post-schema.md",
304 ),
305 HostError::ViewerNotFound { id, hint } => (
306 ErrorCode::HostViewerError,
307 format!("viewer '{id}' not found"),
308 hint.clone(),
309 vec![
310 format!("Install {id} or use --viewer system"),
311 "Use --viewer none for headless / CI".into(),
312 ],
313 "docs/plans/phase-C/http-viewer-contract.md",
314 ),
315 HostError::ViewerUnsupported { mode } => (
316 ErrorCode::HostViewerError,
317 format!(
318 "viewer mode '{}' is not supported by host::run",
319 mode.as_str()
320 ),
321 "Embedded one-shot must use begin + wyvern-viewer spawn (CLI pipeline)".to_string(),
322 vec![
323 "Omit --viewer or use --viewer embedded (CLI default)".into(),
324 "Use --viewer none for headless / CI".into(),
325 ],
326 "docs/plans/phase-C/http-viewer-contract.md",
327 ),
328 HostError::Registry { message } => (
329 ErrorCode::HostError,
330 message.clone(),
331 "Browser registry cache read/write failed".to_string(),
332 vec![
333 "Run `wyvern browsers refresh` to rebuild the cache".into(),
334 "Check WYVERN_BROWSERS_FILE path and cache directory permissions".into(),
335 "Delete a corrupt browsers.json and retry".into(),
336 ],
337 "docs/plans/phase-C/http-viewer-contract.md",
338 ),
339 HostError::Internal { message } => (
340 ErrorCode::HostError,
341 message.clone(),
342 "Internal HTTP host failure".to_string(),
343 vec![
344 "Retry the command".into(),
345 "Report a bug if it persists".into(),
346 ],
347 "docs/wyvern-host/architecture.md",
348 ),
349 };
350
351 let mut envelope = StderrError::new(code, message).cause(cause).docs(docs);
352 for step in recovery {
353 envelope = envelope.recovery(step);
354 }
355 envelope.to_json_string().map_err(EmitError::Serialize)
356}
357
358pub fn emit_fatal_internal(err: &EmitError) -> ! {
363 let EmitError::Serialize(e) = err;
364 let msg_json =
365 serde_json::to_string(&e.message).unwrap_or_else(|_| "\"serialization failed\"".into());
366 eprintln!(
367 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)"}}"#
368 );
369 std::process::exit(ErrorCode::InternalError.exit_code());
370}
371
372#[cfg(test)]
373mod tests {
374 use super::*;
375 use wyvern_schema::{ButtonLabel, ChromeResult, CommandResult, FieldName, MessageResult};
376
377 #[test]
378 fn emit_parse_error_with_quotes_is_valid_json() {
379 let err = LoadError::Parse {
380 message: r#"expected value at line 1: "bad""#.to_string(),
381 };
382 let out = emit_parse_error(&err).expect("emit");
383 let value: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
384 assert_eq!(value["error"], "parse");
385 assert_eq!(value["code"], "PARSE_ERROR");
386 assert!(value["message"].as_str().unwrap().contains('"'));
387 assert!(!value["recovery"].as_array().unwrap().is_empty());
388 assert!(value.get("cause").is_some());
389 }
390
391 #[test]
392 fn emit_io_error_with_quotes_is_valid_json() {
393 let err = LoadError::Io {
394 field: FieldName::new("file"),
395 message: r#"could not read path 'say "hi".json'"#.to_string(),
396 };
397 let out = emit_io_error(&err).expect("emit");
398 let value: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
399 assert_eq!(value["error"], "io");
400 assert_eq!(value["code"], "IO_ERROR");
401 assert_eq!(value["field"], "file");
402 assert!(value["message"].as_str().unwrap().contains('"'));
403 assert!(!value["recovery"].as_array().unwrap().is_empty());
404 }
405
406 #[test]
407 fn emit_validation_error_message_with_quotes_is_valid_json() {
408 let err = ValidationError::Validation {
409 field: FieldName::new("title"),
410 message: r#"field 'title' expected string, got "oops""#.to_string(),
411 };
412 let out = emit_validation_error(&err).expect("emit");
413 let value: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
414 assert_eq!(value["error"], "validation");
415 assert_eq!(value["code"], "VALIDATION_ERROR");
416 assert_eq!(value["field"], "title");
417 assert!(value["message"].as_str().unwrap().contains('"'));
418 assert!(!value["recovery"].as_array().unwrap().is_empty());
419 }
420
421 #[test]
422 fn emit_validation_error_missing_title_has_actionable_recovery() {
423 let err = ValidationError::Validation {
424 field: FieldName::new("title"),
425 message: "missing required field 'title'".to_string(),
426 };
427 let out = emit_validation_error(&err).expect("emit");
428 let value: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
429 let recovery = value["recovery"].as_array().unwrap();
430 assert!(recovery
431 .iter()
432 .any(|s| s.as_str().unwrap().contains("title")));
433 }
434
435 #[test]
436 fn emit_validation_error_state() {
437 let err = ValidationError::State {
438 field: FieldName::new("action"),
439 message: "show is only valid in --interactive mode".to_string(),
440 };
441 let out = emit_validation_error(&err).expect("emit");
442 let value: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
443 assert_eq!(value["error"], "state");
444 assert_eq!(value["code"], "STATE_ERROR");
445 assert_eq!(value["field"], "action");
446 assert!(!value["recovery"].as_array().unwrap().is_empty());
447 }
448
449 #[test]
450 fn emit_stdout_chrome_wire_shape() {
451 let result = CommandResult::Chrome(ChromeResult {
452 button: ButtonLabel::dismissed(),
453 });
454 assert_eq!(
455 emit_stdout(&result).expect("emit"),
456 r#"{"button":"dismissed"}"#
457 );
458 }
459
460 #[test]
461 fn emit_stdout_message_wire_shape() {
462 let result = CommandResult::Message(MessageResult {
463 button: ButtonLabel::new("ok"),
464 });
465 assert_eq!(emit_stdout(&result).expect("emit"), r#"{"button":"ok"}"#);
466 }
467
468 #[test]
469 fn emit_stdout_forced_fail() {
470 let _guard = ForceEmitStdoutFailGuard::arm();
471 let result = CommandResult::Message(MessageResult {
472 button: ButtonLabel::new("ok"),
473 });
474 assert!(emit_stdout(&result).is_err());
475 }
476
477 #[test]
478 fn load_error_exit_codes() {
479 assert_eq!(
480 LoadError::Parse {
481 message: "x".into()
482 }
483 .exit_code(),
484 2
485 );
486 assert_eq!(
487 LoadError::Io {
488 field: FieldName::new("file"),
489 message: "x".into()
490 }
491 .exit_code(),
492 3
493 );
494 assert_eq!(
495 LoadError::Usage {
496 message: "usage".into()
497 }
498 .exit_code(),
499 1
500 );
501 }
502
503 #[test]
504 fn validation_error_exit_codes() {
505 assert_eq!(
506 ValidationError::Validation {
507 field: FieldName::new("title"),
508 message: "bad".into(),
509 }
510 .exit_code(),
511 4
512 );
513 assert_eq!(
514 ValidationError::State {
515 field: FieldName::new("action"),
516 message: "bad".into(),
517 }
518 .exit_code(),
519 5
520 );
521 }
522}