Skip to main content

wyvern/
error.rs

1//! Load/validation/run-stage errors and JSON emission helpers.
2
3use wyvern_schema::{ErrorCode, FieldName, SerializeError, StderrError, ValidationError};
4
5/// Failure while loading command input from argv or stdin.
6#[derive(Debug)]
7pub enum LoadError {
8    /// JSON text could not be parsed.
9    Parse { message: String },
10    /// A file or stdin read failed.
11    Io { field: FieldName, message: String },
12    /// Invalid argv shape; caller prints plain usage text (not JSON).
13    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    /// Stable exit code for this load failure.
30    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/// Failure serializing stdout or structured stderr JSON at the CLI emit boundary.
40#[derive(Debug)]
41pub enum EmitError {
42    /// `serde_json` could not serialize the envelope or result.
43    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    /// Scoped test seam: only the arming thread sees forced stdout emit failures.
65    static FORCE_EMIT_STDOUT_FAIL: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
66}
67
68/// RAII guard that forces [`emit_stdout`] to fail on this thread.
69#[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
87/// Serialize a parse load error as stderr JSON.
88///
89/// # Errors
90///
91/// Returns [`EmitError::Serialize`] when the envelope cannot be serialized, or
92/// when `err` is not [`LoadError::Parse`] (miswire).
93pub 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
109/// Serialize an I/O load error as stderr JSON.
110///
111/// # Errors
112///
113/// Returns [`EmitError::Serialize`] when the envelope cannot be serialized, or
114/// when `err` is not [`LoadError::Io`] (miswire).
115pub 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
132/// Serialize a validation/state error as stderr JSON.
133///
134/// # Errors
135///
136/// Returns [`EmitError::Serialize`] when the envelope cannot be serialized.
137pub 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
266/// Serialize a successful [`wyvern_schema::CommandResult`] for stdout.
267///
268/// # Errors
269///
270/// Returns [`EmitError::Serialize`] when `result` cannot be serialized.
271pub 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
287/// Serialize a [`wyvern_host::HostError`] as stderr JSON (REQ-0073).
288///
289/// # Errors
290///
291/// Returns [`EmitError::Serialize`] when the envelope cannot be serialized.
292pub 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
411/// Emit static internal stderr JSON and exit with code 8 (REQ-0078).
412///
413/// Uses a hand-built JSON string so a serialize failure cannot recurse.
414/// Includes `cause` / `recovery` / `docs` per the stderr contract (RBP-F004).
415pub 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}