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 one of: chrome, message, input, markdown, question, wizard".into(),
177 "Example: {\"type\":\"wizard\",\"page\":{\"id\":\"start\",\"title\":\"Start\",\"html\":\"pages/start.html\"}}"
178 .into(),
179 ];
180 }
181 if field == "page" && message.contains("missing required field") {
182 return vec![
183 "Add required object field \"page\" with id, title, and html".into(),
184 "Example: {\"type\":\"wizard\",\"page\":{\"id\":\"start\",\"title\":\"Start\",\"html\":\"pages/start.html\"}}"
185 .into(),
186 ];
187 }
188 if field == "page" && message.contains("expected object") {
189 return vec!["Provide \"page\" as a JSON object with id, title, and html".into()];
190 }
191 if field == "page.id" {
192 return vec![
193 "Set \"page.id\" to a non-empty string page identity".into(),
194 "Example: \"page\":{\"id\":\"start\",\"title\":\"Start\",\"html\":\"pages/start.html\"}"
195 .into(),
196 ];
197 }
198 if field == "page.title" {
199 return vec![
200 "Set \"page.title\" to a non-empty string display title".into(),
201 "Example: \"page\":{\"id\":\"start\",\"title\":\"Start\",\"html\":\"pages/start.html\"}"
202 .into(),
203 ];
204 }
205 if field == "page.html" {
206 return vec![
207 "Set \"page.html\" to a non-empty path relative to --ui-root".into(),
208 "Example: \"page\":{\"id\":\"start\",\"title\":\"Start\",\"html\":\"pages/start.html\"}"
209 .into(),
210 ];
211 }
212 if field == "page.layout" {
213 return vec!["Set \"page.layout\" to one of: dialog, workspace (or omit the field)".into()];
214 }
215 if field.starts_with("page.") && message.contains("unknown field") {
216 return vec![format!(
217 "Remove unknown field \"{field}\"; page allows only id, title, html, and layout"
218 )];
219 }
220 if field == "buttons" {
221 return vec![
222 "Set \"buttons\" to one of: ok, ok_cancel, yes_no, yes_no_cancel, retry_cancel, custom"
223 .into(),
224 ];
225 }
226 if field == "level" {
227 return vec!["Set \"level\" to one of: info, warning, error, question".into()];
228 }
229 if field == "custom_buttons" {
230 return vec![
231 "Provide \"custom_buttons\" as a string array only when \"buttons\" is \"custom\""
232 .into(),
233 ];
234 }
235 if field == "default_button" {
236 return vec![
237 "Set \"default_button\" to a 0-based index within the active button list".into(),
238 ];
239 }
240 if field == "markdown" {
241 return vec!["Provide \"markdown\" as a JSON boolean (true or false)".into()];
242 }
243 if field == "file" && message.contains("exactly one of") {
244 return vec![
245 "Provide exactly one of \"file\" or \"content\" for markdown commands".into(),
246 "Example: {\"type\":\"markdown\",\"file\":\"doc.md\"}".into(),
247 "Example: {\"type\":\"markdown\",\"content\":\"# Hello\"}".into(),
248 ];
249 }
250 if message.contains("expected string") {
251 return vec![format!("Provide field \"{field}\" as a JSON string")];
252 }
253 if message.contains("unknown field") {
254 return vec![format!(
255 "Remove unknown field \"{field}\"; check the schema for this command type"
256 )];
257 }
258 if message.contains("expected JSON object") {
259 return vec!["Pass a single JSON object as the command payload".into()];
260 }
261 vec![format!(
262 "Fix field \"{field}\" to match the current phase command schema"
263 )]
264}
265
266pub fn emit_stdout(result: &wyvern_schema::CommandResult) -> Result<String, EmitError> {
272 #[cfg(test)]
273 {
274 if FORCE_EMIT_STDOUT_FAIL.with(std::cell::Cell::get) {
275 return Err(EmitError::Serialize(SerializeError {
276 message: "forced".into(),
277 }));
278 }
279 }
280 serde_json::to_string(result).map_err(|e| {
281 EmitError::Serialize(SerializeError {
282 message: e.to_string(),
283 })
284 })
285}
286
287pub fn emit_host_error(err: &wyvern_host::HostError) -> Result<String, EmitError> {
293 use wyvern_host::HostError;
294 let (code, message, cause, recovery, docs) = match err {
295 HostError::Bind { message, source } => {
296 let message = match source {
297 Some(err) => format!("{message}: {err}"),
298 None => message.clone(),
299 };
300 (
301 ErrorCode::HostBindError,
302 message,
303 "Failed to bind the dialog HTTP server".to_string(),
304 vec![
305 "Check that --bind is a valid address".into(),
306 "Try --bind 127.0.0.1:0 for an ephemeral port".into(),
307 ],
308 "docs/wyvern-host/requirements.md (REQ-0091)",
309 )
310 }
311 HostError::UiNotFound { path, source } => {
312 let message = match source {
313 Some(err) => format!("UI not found at '{}': {err}", path.display()),
314 None => format!("UI not found at '{}'", path.display()),
315 };
316 (
317 ErrorCode::UiNotFound,
318 message,
319 "Packaged UI root, dialog template, or wizard page HTML is missing".to_string(),
320 vec![
321 "Pass --ui-root pointing at a directory with message/, input/, markdown/, question/, and chrome/ templates".into(),
322 "For wizard commands, ensure page.html exists relative to --ui-root (served under /wizard/**)".into(),
323 "Ensure ui/{message,input,markdown,question,chrome}/ exist in the workspace for development".into(),
324 ],
325 "docs/wyvern-host/requirements.md (REQ-0093, REQ-0100)",
326 )
327 }
328 HostError::UnsupportedType { type_name } => (
329 ErrorCode::UnsupportedType,
330 format!("dialog type '{type_name}' is not implemented on the HTTP host yet"),
331 "Schema validation passed; host matrix supports chrome, message, input, markdown, question, and wizard".to_string(),
332 vec![
333 "Use one of: chrome, message, input, markdown, question, wizard".into(),
334 ],
335 "docs/plans/phase-C/http-dialog-contract.md",
336 ),
337 HostError::InvalidResult { message } => (
338 ErrorCode::HostError,
339 message.clone(),
340 "POST /api/result body was invalid for the active dialog".to_string(),
341 vec!["Submit a body matching the dialog CommandResult wire shape".into()],
342 "docs/plans/phase-C/http-post-schema.md",
343 ),
344 HostError::ViewerNotFound { id, hint } => (
345 ErrorCode::HostViewerError,
346 format!("viewer '{id}' not found"),
347 hint.clone(),
348 vec![
349 format!("Install {id} or use --viewer system"),
350 "Use --viewer none for headless / CI".into(),
351 ],
352 "docs/plans/phase-C/http-viewer-contract.md",
353 ),
354 HostError::ViewerUnsupported { mode } => (
355 ErrorCode::HostViewerError,
356 format!(
357 "viewer mode '{}' is not supported by host::run",
358 mode.as_str()
359 ),
360 "Embedded one-shot must use begin + wyvern-viewer spawn (CLI pipeline)".to_string(),
361 vec![
362 "Omit --viewer or use --viewer embedded (CLI default)".into(),
363 "Use --viewer none for headless / CI".into(),
364 ],
365 "docs/plans/phase-C/http-viewer-contract.md",
366 ),
367 HostError::Registry { message } => (
368 ErrorCode::HostError,
369 message.clone(),
370 "Browser registry cache read/write failed".to_string(),
371 vec![
372 "Run `wyvern browsers refresh` to rebuild the cache".into(),
373 "Check WYVERN_BROWSERS_FILE path and cache directory permissions".into(),
374 "Delete a corrupt browsers.json and retry".into(),
375 ],
376 "docs/plans/phase-C/http-viewer-contract.md",
377 ),
378 HostError::Internal { message } => (
379 ErrorCode::HostError,
380 message.clone(),
381 "Internal HTTP host failure".to_string(),
382 vec![
383 "Retry the command".into(),
384 "Report a bug if it persists".into(),
385 ],
386 "docs/wyvern-host/architecture.md",
387 ),
388 HostError::Wizard { source } => {
389 let subcode = source.subcode();
390 (
391 ErrorCode::HostError,
392 format!("{subcode}: {source}"),
393 format!("{subcode}: wizard session failed during host setup or state access"),
394 vec![
395 format!("See wizard error sub-code {subcode} for the specific failure"),
396 "Ensure the command is type: wizard with a validated page object".into(),
397 "Retry the command; report a bug if a validated wizard has no session".into(),
398 ],
399 "docs/plans/phase-C/http-wizard-contract.md",
400 )
401 }
402 };
403
404 let mut envelope = StderrError::new(code, message).cause(cause).docs(docs);
405 for step in recovery {
406 envelope = envelope.recovery(step);
407 }
408 envelope.to_json_string().map_err(EmitError::Serialize)
409}
410
411pub fn emit_fatal_internal(err: &EmitError) -> ! {
416 let EmitError::Serialize(e) = err;
417 let msg_json =
418 serde_json::to_string(&e.message).unwrap_or_else(|_| "\"serialization failed\"".into());
419 eprintln!(
420 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)"}}"#
421 );
422 std::process::exit(ErrorCode::InternalError.exit_code());
423}
424
425#[cfg(test)]
426mod tests {
427 use super::*;
428 use wyvern_schema::{ButtonLabel, ChromeResult, CommandResult, FieldName, MessageResult};
429
430 #[test]
431 fn emit_parse_error_with_quotes_is_valid_json() {
432 let err = LoadError::Parse {
433 message: r#"expected value at line 1: "bad""#.to_string(),
434 };
435 let out = emit_parse_error(&err).expect("emit");
436 let value: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
437 assert_eq!(value["error"], "parse");
438 assert_eq!(value["code"], "PARSE_ERROR");
439 assert!(value["message"].as_str().unwrap().contains('"'));
440 assert!(!value["recovery"].as_array().unwrap().is_empty());
441 assert!(value.get("cause").is_some());
442 }
443
444 #[test]
445 fn emit_io_error_with_quotes_is_valid_json() {
446 let err = LoadError::Io {
447 field: FieldName::new("file"),
448 message: r#"could not read path 'say "hi".json'"#.to_string(),
449 };
450 let out = emit_io_error(&err).expect("emit");
451 let value: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
452 assert_eq!(value["error"], "io");
453 assert_eq!(value["code"], "IO_ERROR");
454 assert_eq!(value["field"], "file");
455 assert!(value["message"].as_str().unwrap().contains('"'));
456 assert!(!value["recovery"].as_array().unwrap().is_empty());
457 }
458
459 #[test]
460 fn emit_validation_error_message_with_quotes_is_valid_json() {
461 let err = ValidationError::Validation {
462 field: FieldName::new("title"),
463 message: r#"field 'title' expected string, got "oops""#.to_string(),
464 };
465 let out = emit_validation_error(&err).expect("emit");
466 let value: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
467 assert_eq!(value["error"], "validation");
468 assert_eq!(value["code"], "VALIDATION_ERROR");
469 assert_eq!(value["field"], "title");
470 assert!(value["message"].as_str().unwrap().contains('"'));
471 assert!(!value["recovery"].as_array().unwrap().is_empty());
472 }
473
474 #[test]
475 fn emit_validation_error_missing_title_has_actionable_recovery() {
476 let err = ValidationError::Validation {
477 field: FieldName::new("title"),
478 message: "missing required field 'title'".to_string(),
479 };
480 let out = emit_validation_error(&err).expect("emit");
481 let value: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
482 let recovery = value["recovery"].as_array().unwrap();
483 assert!(recovery
484 .iter()
485 .any(|s| s.as_str().unwrap().contains("title")));
486 }
487
488 #[test]
489 fn emit_validation_error_state() {
490 let err = ValidationError::State {
491 field: FieldName::new("action"),
492 message: "show is only valid in --interactive mode".to_string(),
493 };
494 let out = emit_validation_error(&err).expect("emit");
495 let value: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
496 assert_eq!(value["error"], "state");
497 assert_eq!(value["code"], "STATE_ERROR");
498 assert_eq!(value["field"], "action");
499 assert!(!value["recovery"].as_array().unwrap().is_empty());
500 }
501
502 #[test]
503 fn emit_stdout_chrome_wire_shape() {
504 let result = CommandResult::Chrome(ChromeResult {
505 button: ButtonLabel::dismissed(),
506 });
507 assert_eq!(
508 emit_stdout(&result).expect("emit"),
509 r#"{"button":"dismissed"}"#
510 );
511 }
512
513 #[test]
514 fn emit_stdout_message_wire_shape() {
515 let result = CommandResult::Message(MessageResult {
516 button: ButtonLabel::new("ok"),
517 });
518 assert_eq!(emit_stdout(&result).expect("emit"), r#"{"button":"ok"}"#);
519 }
520
521 #[test]
522 fn emit_stdout_forced_fail() {
523 let _guard = ForceEmitStdoutFailGuard::arm();
524 let result = CommandResult::Message(MessageResult {
525 button: ButtonLabel::new("ok"),
526 });
527 assert!(emit_stdout(&result).is_err());
528 }
529
530 #[test]
531 fn load_error_exit_codes() {
532 assert_eq!(
533 LoadError::Parse {
534 message: "x".into()
535 }
536 .exit_code(),
537 2
538 );
539 assert_eq!(
540 LoadError::Io {
541 field: FieldName::new("file"),
542 message: "x".into()
543 }
544 .exit_code(),
545 3
546 );
547 assert_eq!(
548 LoadError::Usage {
549 message: "usage".into()
550 }
551 .exit_code(),
552 1
553 );
554 }
555
556 #[test]
557 fn validation_error_exit_codes() {
558 assert_eq!(
559 ValidationError::Validation {
560 field: FieldName::new("title"),
561 message: "bad".into(),
562 }
563 .exit_code(),
564 4
565 );
566 assert_eq!(
567 ValidationError::State {
568 field: FieldName::new("action"),
569 message: "bad".into(),
570 }
571 .exit_code(),
572 5
573 );
574 }
575}