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 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
227/// Serialize a successful [`wyvern_schema::CommandResult`] for stdout.
228///
229/// # Errors
230///
231/// Returns [`EmitError::Serialize`] when `result` cannot be serialized.
232pub 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
248/// Serialize a [`wyvern_host::HostError`] as stderr JSON (REQ-0073).
249///
250/// # Errors
251///
252/// Returns [`EmitError::Serialize`] when the envelope cannot be serialized.
253pub 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
358/// Emit static internal stderr JSON and exit with code 8 (REQ-0078).
359///
360/// Uses a hand-built JSON string so a serialize failure cannot recurse.
361/// Includes `cause` / `recovery` / `docs` per the stderr contract (RBP-F004).
362pub 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}