Skip to main content

wyvern/error/
mod.rs

1//! Load/validation/run-stage errors and JSON emission helpers.
2
3mod emit;
4
5use wyvern_schema::{ErrorCode, FieldName, SerializeError};
6
7#[doc(inline)]
8pub use emit::{
9    emit_extension_error, emit_fatal_internal, emit_host_error, emit_io_error, emit_parse_error,
10    emit_stdout, emit_usage_error, emit_usage_message, emit_validation_error,
11};
12
13/// Built-in CLI family that owns subcommands (`browsers`, `extensions`).
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum BuiltinDomain {
16    /// `wyvern browsers …`
17    Browsers,
18    /// `wyvern extensions …`
19    Extensions,
20}
21
22impl BuiltinDomain {
23    /// Stable CLI family name.
24    #[must_use]
25    pub const fn as_str(self) -> &'static str {
26        match self {
27            Self::Browsers => "browsers",
28            Self::Extensions => "extensions",
29        }
30    }
31}
32
33impl std::fmt::Display for BuiltinDomain {
34    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35        f.write_str(self.as_str())
36    }
37}
38
39/// Host-flag / env usage failure kind for structured stderr recovery (RBP-F009).
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub enum UsageErrorKind {
42    /// Generic argv/input usage (empty stdin, too many args, etc.).
43    Generic,
44    /// `--bind` value failed to parse as a socket address.
45    InvalidBind {
46        /// Raw `--bind` token from argv.
47        value: String,
48    },
49    /// A host flag was given without a following value.
50    MissingFlagValue {
51        /// Flag name (e.g. `--bind`).
52        flag: String,
53    },
54    /// `--viewer` value was not a known viewer mode.
55    InvalidViewer {
56        /// Raw `--viewer` token from argv.
57        value: String,
58    },
59    /// `WYVERN_VIEWER` is set but not a valid viewer mode (RSH-010).
60    InvalidWyvernViewerEnv {
61        /// Raw env var value.
62        value: String,
63    },
64    /// `WYVERN_VIEWER` is not valid Unicode.
65    InvalidWyvernViewerUnicode,
66    /// Unknown subcommand on a built-in family (`browsers`, `extensions`).
67    UnknownSubcommand {
68        /// Built-in family that rejected the token.
69        domain: BuiltinDomain,
70        /// Offending subcommand token.
71        token: String,
72    },
73    /// `extensions show` was invoked without a usable extension id.
74    MissingExtensionId,
75}
76
77/// Failure while loading command input from argv or stdin.
78#[derive(Debug)]
79pub enum LoadError {
80    /// JSON text could not be parsed.
81    Parse { message: String },
82    /// A file or stdin read failed.
83    Io {
84        field: FieldName,
85        message: String,
86        /// Original I/O error if available.
87        source: Option<Box<dyn std::error::Error + Send + Sync + 'static>>,
88    },
89    /// Invalid argv shape or host-flag value.
90    Usage {
91        kind: UsageErrorKind,
92        message: String,
93    },
94}
95
96impl std::fmt::Display for LoadError {
97    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
98        match self {
99            Self::Parse { message } => write!(f, "parse error: {message}"),
100            Self::Io { field, message, .. } => write!(f, "io error ({field}): {message}"),
101            Self::Usage { message, .. } => write!(f, "{message}"),
102        }
103    }
104}
105
106impl std::error::Error for LoadError {
107    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
108        match self {
109            Self::Io { source, .. } => source.as_deref().map(|e| e as _),
110            _ => None,
111        }
112    }
113}
114
115impl LoadError {
116    /// Stable exit code for this load failure.
117    pub fn exit_code(&self) -> i32 {
118        match self {
119            Self::Parse { .. } => ErrorCode::ParseError.exit_code(),
120            Self::Io { .. } => ErrorCode::IoError.exit_code(),
121            Self::Usage { .. } => ErrorCode::ParseError.exit_code(),
122        }
123    }
124}
125
126/// Failure serializing stdout or structured stderr JSON at the CLI emit boundary.
127#[derive(Debug)]
128pub enum EmitError {
129    /// `serde_json` could not serialize the envelope or result.
130    Serialize(SerializeError),
131}
132
133impl std::fmt::Display for EmitError {
134    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
135        match self {
136            Self::Serialize(e) => write!(f, "{e}"),
137        }
138    }
139}
140
141impl std::error::Error for EmitError {
142    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
143        match self {
144            Self::Serialize(e) => Some(e),
145        }
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152    use wyvern_schema::{ButtonLabel, ChromeResult, CommandResult, FieldName, MessageResult};
153
154    #[test]
155    fn emit_parse_error_with_quotes_is_valid_json() {
156        let err = LoadError::Parse {
157            message: r#"expected value at line 1: "bad""#.to_string(),
158        };
159        let out = emit_parse_error(&err).expect("emit");
160        let value: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
161        assert_eq!(value["error"], "parse");
162        assert_eq!(value["code"], "PARSE_ERROR");
163        assert!(value["message"].as_str().unwrap().contains('"'));
164        assert!(!value["recovery"].as_array().unwrap().is_empty());
165        assert!(value.get("cause").is_some());
166    }
167
168    #[test]
169    fn emit_io_error_with_quotes_is_valid_json() {
170        let err = LoadError::Io {
171            field: FieldName::new("file"),
172            message: r#"could not read path 'say "hi".json'"#.to_string(),
173            source: None,
174        };
175        let out = emit_io_error(&err).expect("emit");
176        let value: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
177        assert_eq!(value["error"], "io");
178        assert_eq!(value["code"], "IO_ERROR");
179        assert_eq!(value["field"], "file");
180        assert!(value["message"].as_str().unwrap().contains('"'));
181        assert!(!value["recovery"].as_array().unwrap().is_empty());
182    }
183
184    #[test]
185    fn emit_validation_error_message_with_quotes_is_valid_json() {
186        let err = wyvern_schema::ValidationError::Validation {
187            field: FieldName::new("title"),
188            message: r#"field 'title' expected string, got "oops""#.to_string(),
189        };
190        let out = emit_validation_error(&err).expect("emit");
191        let value: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
192        assert_eq!(value["error"], "validation");
193        assert_eq!(value["code"], "VALIDATION_ERROR");
194        assert_eq!(value["field"], "title");
195        assert!(value["message"].as_str().unwrap().contains('"'));
196        assert!(!value["recovery"].as_array().unwrap().is_empty());
197    }
198
199    #[test]
200    fn emit_validation_error_missing_title_has_actionable_recovery() {
201        let err = wyvern_schema::ValidationError::Validation {
202            field: FieldName::new("title"),
203            message: "missing required field 'title'".to_string(),
204        };
205        let out = emit_validation_error(&err).expect("emit");
206        let value: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
207        let recovery = value["recovery"].as_array().unwrap();
208        assert!(recovery
209            .iter()
210            .any(|s| s.as_str().unwrap().contains("title")));
211    }
212
213    #[test]
214    fn emit_validation_error_state() {
215        let err = wyvern_schema::ValidationError::State {
216            field: FieldName::new("action"),
217            message: "show is only valid in --interactive mode".to_string(),
218        };
219        let out = emit_validation_error(&err).expect("emit");
220        let value: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
221        assert_eq!(value["error"], "state");
222        assert_eq!(value["code"], "STATE_ERROR");
223        assert_eq!(value["field"], "action");
224        assert!(!value["recovery"].as_array().unwrap().is_empty());
225    }
226
227    #[test]
228    fn emit_stdout_chrome_wire_shape() {
229        let result = CommandResult::Chrome(ChromeResult {
230            button: ButtonLabel::dismissed(),
231        });
232        assert_eq!(
233            emit_stdout(&result).expect("emit"),
234            r#"{"button":"dismissed"}"#
235        );
236    }
237
238    #[test]
239    fn emit_stdout_message_wire_shape() {
240        let result = CommandResult::Message(MessageResult {
241            button: ButtonLabel::new("ok"),
242        });
243        assert_eq!(emit_stdout(&result).expect("emit"), r#"{"button":"ok"}"#);
244    }
245
246    #[test]
247    fn emit_stdout_forced_fail() {
248        let _guard = emit::ForceEmitStdoutFailGuard::arm();
249        let result = CommandResult::Message(MessageResult {
250            button: ButtonLabel::new("ok"),
251        });
252        assert!(emit_stdout(&result).is_err());
253    }
254
255    #[test]
256    fn load_error_io_preserves_source_chain() {
257        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "missing");
258        let err = LoadError::Io {
259            field: FieldName::new("file"),
260            message: "could not read path".into(),
261            source: Some(Box::new(io_err)),
262        };
263        assert_eq!(err.to_string(), "io error (file): could not read path");
264        let source = std::error::Error::source(&err).expect("source chain");
265        assert!(source.to_string().contains("missing"));
266        assert!(std::error::Error::source(&LoadError::Parse {
267            message: "x".into()
268        })
269        .is_none());
270    }
271
272    #[test]
273    fn load_error_exit_codes() {
274        assert_eq!(
275            LoadError::Parse {
276                message: "x".into()
277            }
278            .exit_code(),
279            2
280        );
281        assert_eq!(
282            LoadError::Io {
283                field: FieldName::new("file"),
284                message: "x".into(),
285                source: None,
286            }
287            .exit_code(),
288            3
289        );
290        assert_eq!(
291            LoadError::Usage {
292                kind: UsageErrorKind::Generic,
293                message: "usage".into()
294            }
295            .exit_code(),
296            2
297        );
298    }
299
300    #[test]
301    fn validation_error_exit_codes() {
302        assert_eq!(
303            wyvern_schema::ValidationError::Validation {
304                field: FieldName::new("title"),
305                message: "bad".into(),
306            }
307            .exit_code(),
308            4
309        );
310        assert_eq!(
311            wyvern_schema::ValidationError::State {
312                field: FieldName::new("action"),
313                message: "bad".into(),
314            }
315            .exit_code(),
316            5
317        );
318    }
319
320    #[test]
321    fn emit_usage_error_is_structured_json() {
322        let err = LoadError::Usage {
323            kind: UsageErrorKind::Generic,
324            message: "unknown subcommand".into(),
325        };
326        let out = emit_usage_error(&err).expect("emit");
327        let value: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
328        assert_eq!(value["error"], "parse");
329        assert_eq!(value["code"], "PARSE_ERROR");
330        assert!(value["message"].as_str().unwrap().contains("unknown"));
331        assert!(!value["recovery"].as_array().unwrap().is_empty());
332    }
333
334    #[test]
335    fn emit_invalid_bind_has_flag_specific_recovery() {
336        let err = LoadError::Usage {
337            kind: UsageErrorKind::InvalidBind {
338                value: "not-an-addr".into(),
339            },
340            message: "invalid --bind 'not-an-addr': invalid socket address".into(),
341        };
342        let out = emit_usage_error(&err).expect("emit");
343        let value: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
344        let recovery = value["recovery"].as_array().unwrap();
345        assert!(recovery
346            .iter()
347            .any(|s| s.as_str().unwrap().contains("--allow-non-loopback")));
348        assert!(!value["message"].as_str().unwrap().contains("Recovery:"));
349    }
350
351    #[test]
352    fn emit_invalid_wyvern_viewer_env_has_flag_specific_recovery() {
353        let err = LoadError::Usage {
354            kind: UsageErrorKind::InvalidWyvernViewerEnv {
355                value: "not-a-viewer-mode".into(),
356            },
357            message: "invalid WYVERN_VIEWER=\"not-a-viewer-mode\"; expected embedded, none, system, or a named viewer path".into(),
358        };
359        let out = emit_usage_error(&err).expect("emit");
360        let value: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
361        assert!(value["cause"].as_str().unwrap().contains("WYVERN_VIEWER"));
362        let recovery = value["recovery"].as_array().unwrap();
363        assert!(recovery
364            .iter()
365            .any(|s| s.as_str().unwrap().contains("Unset WYVERN_VIEWER")));
366    }
367
368    #[test]
369    fn emit_unknown_subcommand_has_domain_specific_recovery() {
370        let browsers = LoadError::Usage {
371            kind: UsageErrorKind::UnknownSubcommand {
372                domain: BuiltinDomain::Browsers,
373                token: "nope".into(),
374            },
375            message: "unknown browsers subcommand 'nope'".into(),
376        };
377        let out = emit_usage_error(&browsers).expect("emit");
378        let value: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
379        assert!(value["cause"].as_str().unwrap().contains("nope"));
380        let recovery = value["recovery"].as_array().unwrap();
381        assert!(recovery
382            .iter()
383            .any(|s| s.as_str().unwrap().contains("browsers list")));
384
385        let extensions = LoadError::Usage {
386            kind: UsageErrorKind::UnknownSubcommand {
387                domain: BuiltinDomain::Extensions,
388                token: "show".into(),
389            },
390            message: "unknown extensions subcommand 'show'".into(),
391        };
392        let out = emit_usage_error(&extensions).expect("emit");
393        let value: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
394        let recovery = value["recovery"].as_array().unwrap();
395        assert!(recovery
396            .iter()
397            .any(|s| s.as_str().unwrap().contains("extensions list")));
398    }
399
400    #[test]
401    fn emit_missing_extension_id_has_show_recovery() {
402        let err = LoadError::Usage {
403            kind: UsageErrorKind::MissingExtensionId,
404            message: "extensions show requires an extension id".into(),
405        };
406        let out = emit_usage_error(&err).expect("emit");
407        let value: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
408        assert!(value["cause"].as_str().unwrap().contains("extension id"));
409        let recovery = value["recovery"].as_array().unwrap();
410        assert!(recovery
411            .iter()
412            .any(|s| s.as_str().unwrap().contains("extensions show <id>")));
413        assert!(recovery
414            .iter()
415            .any(|s| s.as_str().unwrap().contains("extensions list")));
416    }
417
418    #[test]
419    fn emit_missing_args_lists_flags() {
420        use crate::extensions::{ExtensionError, ExtensionId};
421        let err = ExtensionError::MissingArgs {
422            missing: vec!["--root".into(), "--file".into()],
423            declared: ["root".into(), "file".into()].into_iter().collect(),
424            extension_id: ExtensionId::try_from(String::from("compose-render")).expect("id"),
425            example: "wyvern compose render --root DIR --file FILE.j2".into(),
426            help_command: "wyvern compose render --help".into(),
427        };
428        let out = emit_extension_error(&err).expect("emit");
429        let value: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
430        let text = out.to_ascii_lowercase();
431        assert!(value["message"].as_str().unwrap().contains("--root"));
432        assert!(value["message"].as_str().unwrap().contains("--file"));
433        assert!(value["recovery"]
434            .as_array()
435            .unwrap()
436            .iter()
437            .any(|s| s.as_str().unwrap().contains("--root")));
438        assert!(value["recovery"]
439            .as_array()
440            .unwrap()
441            .iter()
442            .any(|s| s.as_str() == Some("Run wyvern compose render --help")));
443        assert!(
444            !out.contains("wyvern compose-render --help"),
445            "recovery must use the invocation prefix, not the extension id: {out}"
446        );
447        assert!(!text.contains("declare them as {arg:"));
448    }
449
450    #[test]
451    fn emit_unexpected_arg_is_caller_facing() {
452        use crate::extensions::{ExtensionError, ExtensionId};
453        let err = ExtensionError::UnexpectedArg {
454            token: "--undeclared".into(),
455            declared: ["root".into(), "file".into()].into_iter().collect(),
456            extension_id: ExtensionId::try_from(String::from("compose-render")).expect("id"),
457            help_command: "wyvern compose render --help".into(),
458        };
459        let out = emit_extension_error(&err).expect("emit");
460        let value: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
461        assert!(value["cause"].as_str().unwrap().contains("--undeclared"));
462        assert!(value["recovery"]
463            .as_array()
464            .unwrap()
465            .iter()
466            .any(|s| s.as_str().unwrap().contains("--root")));
467        assert!(value["recovery"]
468            .as_array()
469            .unwrap()
470            .iter()
471            .any(|s| s.as_str() == Some("Run wyvern compose render --help")));
472        assert!(!out.contains("wyvern compose-render --help"), "{out}");
473        assert!(
474            !out.contains("declare them as {arg:name}") && !out.contains("{arg:"),
475            "{out}"
476        );
477    }
478
479    #[test]
480    fn emit_preexec_timeout_mentions_env_var() {
481        use crate::extensions::{ExtensionError, PreexecFailureKind};
482        let err = ExtensionError::Preexec {
483            kind: Some(PreexecFailureKind::Timeout {
484                cmd: "slow".into(),
485                timeout_secs: 30,
486            }),
487            message: "slow timed out after 30s".into(),
488            source: None,
489        };
490        let out = emit_extension_error(&err).expect("emit");
491        assert!(
492            out.contains("WYVERN_PREEXEC_TIMEOUT_SECS"),
493            "timeout recovery must name the env var: {out}"
494        );
495        assert!(out.contains("30"), "{out}");
496    }
497}