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    emit_wizard_lint_stage_error, emit_workflow_error,
12};
13
14/// Built-in CLI family that owns subcommands (`browsers`, `extensions`, `examples`, `wizard`).
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum BuiltinDomain {
17    /// `wyvern browsers …`
18    Browsers,
19    /// `wyvern extensions …`
20    Extensions,
21    /// `wyvern examples …`
22    Examples,
23    /// `wyvern wizard …`
24    Wizard,
25}
26
27impl BuiltinDomain {
28    /// Stable CLI family name.
29    #[must_use]
30    pub const fn as_str(self) -> &'static str {
31        match self {
32            Self::Browsers => "browsers",
33            Self::Extensions => "extensions",
34            Self::Examples => "examples",
35            Self::Wizard => "wizard",
36        }
37    }
38}
39
40impl std::fmt::Display for BuiltinDomain {
41    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42        f.write_str(self.as_str())
43    }
44}
45
46/// Host-flag / env usage failure kind for structured stderr recovery (RBP-F009).
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub enum UsageErrorKind {
49    /// Generic argv/input usage (empty stdin, too many args, etc.).
50    Generic,
51    /// `--bind` value failed to parse as a socket address.
52    InvalidBind {
53        /// Raw `--bind` token from argv.
54        value: String,
55    },
56    /// A host flag was given without a following value.
57    MissingFlagValue {
58        /// Flag name (e.g. `--bind`).
59        flag: String,
60    },
61    /// `--viewer` value was not a known viewer mode.
62    InvalidViewer {
63        /// Raw `--viewer` token from argv.
64        value: String,
65    },
66    /// `WYVERN_VIEWER` is set but not a valid viewer mode (RSH-010).
67    InvalidWyvernViewerEnv {
68        /// Raw env var value.
69        value: String,
70    },
71    /// `WYVERN_VIEWER` is not valid Unicode.
72    InvalidWyvernViewerUnicode,
73    /// Unknown subcommand on a built-in family (`browsers`, `extensions`).
74    UnknownSubcommand {
75        /// Built-in family that rejected the token.
76        domain: BuiltinDomain,
77        /// Offending subcommand token.
78        token: String,
79    },
80    /// `extensions show` was invoked without a usable extension id.
81    MissingExtensionId,
82}
83
84/// Failure while loading command input from argv or stdin.
85#[derive(Debug)]
86pub enum LoadError {
87    /// JSON text could not be parsed.
88    Parse { message: String },
89    /// A file or stdin read failed.
90    Io {
91        field: FieldName,
92        message: String,
93        /// Original I/O error if available.
94        source: Option<Box<dyn std::error::Error + Send + Sync + 'static>>,
95    },
96    /// Invalid argv shape or host-flag value.
97    Usage {
98        kind: UsageErrorKind,
99        message: String,
100    },
101}
102
103impl std::fmt::Display for LoadError {
104    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105        match self {
106            Self::Parse { message } => write!(f, "parse error: {message}"),
107            Self::Io { field, message, .. } => write!(f, "io error ({field}): {message}"),
108            Self::Usage { message, .. } => write!(f, "{message}"),
109        }
110    }
111}
112
113impl std::error::Error for LoadError {
114    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
115        match self {
116            Self::Io { source, .. } => source.as_deref().map(|e| e as _),
117            _ => None,
118        }
119    }
120}
121
122impl LoadError {
123    /// Stable exit code for this load failure.
124    pub fn exit_code(&self) -> i32 {
125        match self {
126            Self::Parse { .. } => ErrorCode::ParseError.exit_code(),
127            Self::Io { .. } => ErrorCode::IoError.exit_code(),
128            Self::Usage { .. } => ErrorCode::ParseError.exit_code(),
129        }
130    }
131}
132
133/// Failure serializing stdout or structured stderr JSON at the CLI emit boundary.
134#[derive(Debug)]
135pub enum EmitError {
136    /// `serde_json` could not serialize the envelope or result.
137    Serialize(SerializeError),
138}
139
140impl std::fmt::Display for EmitError {
141    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
142        match self {
143            Self::Serialize(e) => write!(f, "{e}"),
144        }
145    }
146}
147
148impl std::error::Error for EmitError {
149    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
150        match self {
151            Self::Serialize(e) => Some(e),
152        }
153    }
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159    use wyvern_schema::{ButtonLabel, ChromeResult, CommandResult, FieldName, MessageResult};
160
161    #[test]
162    fn emit_parse_error_with_quotes_is_valid_json() {
163        let err = LoadError::Parse {
164            message: r#"expected value at line 1: "bad""#.to_string(),
165        };
166        let out = emit_parse_error(&err).expect("emit");
167        let value: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
168        assert_eq!(value["error"], "parse");
169        assert_eq!(value["code"], "PARSE_ERROR");
170        assert!(value["message"].as_str().unwrap().contains('"'));
171        assert!(!value["recovery"].as_array().unwrap().is_empty());
172        assert!(value.get("cause").is_some());
173    }
174
175    #[test]
176    fn emit_io_error_with_quotes_is_valid_json() {
177        let err = LoadError::Io {
178            field: FieldName::new("file"),
179            message: r#"could not read path 'say "hi".json'"#.to_string(),
180            source: None,
181        };
182        let out = emit_io_error(&err).expect("emit");
183        let value: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
184        assert_eq!(value["error"], "io");
185        assert_eq!(value["code"], "IO_ERROR");
186        assert_eq!(value["field"], "file");
187        assert!(value["message"].as_str().unwrap().contains('"'));
188        assert!(!value["recovery"].as_array().unwrap().is_empty());
189    }
190
191    #[test]
192    fn emit_validation_error_message_with_quotes_is_valid_json() {
193        let err = wyvern_schema::ValidationError::Validation {
194            field: FieldName::new("title"),
195            message: r#"field 'title' expected string, got "oops""#.to_string(),
196        };
197        let out = emit_validation_error(&err).expect("emit");
198        let value: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
199        assert_eq!(value["error"], "validation");
200        assert_eq!(value["code"], "VALIDATION_ERROR");
201        assert_eq!(value["field"], "title");
202        assert!(value["message"].as_str().unwrap().contains('"'));
203        assert!(!value["recovery"].as_array().unwrap().is_empty());
204    }
205
206    #[test]
207    fn emit_validation_error_missing_title_has_actionable_recovery() {
208        let err = wyvern_schema::ValidationError::Validation {
209            field: FieldName::new("title"),
210            message: "missing required field 'title'".to_string(),
211        };
212        let out = emit_validation_error(&err).expect("emit");
213        let value: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
214        let recovery = value["recovery"].as_array().unwrap();
215        assert!(recovery
216            .iter()
217            .any(|s| s.as_str().unwrap().contains("title")));
218    }
219
220    #[test]
221    fn emit_validation_error_state() {
222        let err = wyvern_schema::ValidationError::State {
223            field: FieldName::new("action"),
224            message: "show is only valid in --interactive mode".to_string(),
225        };
226        let out = emit_validation_error(&err).expect("emit");
227        let value: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
228        assert_eq!(value["error"], "state");
229        assert_eq!(value["code"], "STATE_ERROR");
230        assert_eq!(value["field"], "action");
231        assert!(!value["recovery"].as_array().unwrap().is_empty());
232    }
233
234    #[test]
235    fn emit_stdout_chrome_wire_shape() {
236        let result = CommandResult::Chrome(ChromeResult {
237            button: ButtonLabel::dismissed(),
238        });
239        assert_eq!(
240            emit_stdout(&result).expect("emit"),
241            r#"{"button":"dismissed"}"#
242        );
243    }
244
245    #[test]
246    fn emit_stdout_message_wire_shape() {
247        let result = CommandResult::Message(MessageResult {
248            button: ButtonLabel::new("ok"),
249        });
250        assert_eq!(emit_stdout(&result).expect("emit"), r#"{"button":"ok"}"#);
251    }
252
253    #[test]
254    fn emit_stdout_forced_fail() {
255        let _guard = emit::ForceEmitStdoutFailGuard::arm();
256        let result = CommandResult::Message(MessageResult {
257            button: ButtonLabel::new("ok"),
258        });
259        assert!(emit_stdout(&result).is_err());
260    }
261
262    #[test]
263    fn load_error_io_preserves_source_chain() {
264        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "missing");
265        let err = LoadError::Io {
266            field: FieldName::new("file"),
267            message: "could not read path".into(),
268            source: Some(Box::new(io_err)),
269        };
270        assert_eq!(err.to_string(), "io error (file): could not read path");
271        let source = std::error::Error::source(&err).expect("source chain");
272        assert!(source.to_string().contains("missing"));
273        assert!(std::error::Error::source(&LoadError::Parse {
274            message: "x".into()
275        })
276        .is_none());
277    }
278
279    #[test]
280    fn load_error_exit_codes() {
281        assert_eq!(
282            LoadError::Parse {
283                message: "x".into()
284            }
285            .exit_code(),
286            2
287        );
288        assert_eq!(
289            LoadError::Io {
290                field: FieldName::new("file"),
291                message: "x".into(),
292                source: None,
293            }
294            .exit_code(),
295            3
296        );
297        assert_eq!(
298            LoadError::Usage {
299                kind: UsageErrorKind::Generic,
300                message: "usage".into()
301            }
302            .exit_code(),
303            2
304        );
305    }
306
307    #[test]
308    fn validation_error_exit_codes() {
309        assert_eq!(
310            wyvern_schema::ValidationError::Validation {
311                field: FieldName::new("title"),
312                message: "bad".into(),
313            }
314            .exit_code(),
315            4
316        );
317        assert_eq!(
318            wyvern_schema::ValidationError::State {
319                field: FieldName::new("action"),
320                message: "bad".into(),
321            }
322            .exit_code(),
323            5
324        );
325    }
326
327    #[test]
328    fn emit_usage_error_is_structured_json() {
329        let err = LoadError::Usage {
330            kind: UsageErrorKind::Generic,
331            message: "unknown subcommand".into(),
332        };
333        let out = emit_usage_error(&err).expect("emit");
334        let value: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
335        assert_eq!(value["error"], "parse");
336        assert_eq!(value["code"], "PARSE_ERROR");
337        assert!(value["message"].as_str().unwrap().contains("unknown"));
338        assert!(!value["recovery"].as_array().unwrap().is_empty());
339    }
340
341    #[test]
342    fn emit_invalid_bind_has_flag_specific_recovery() {
343        let err = LoadError::Usage {
344            kind: UsageErrorKind::InvalidBind {
345                value: "not-an-addr".into(),
346            },
347            message: "invalid --bind 'not-an-addr': invalid socket address".into(),
348        };
349        let out = emit_usage_error(&err).expect("emit");
350        let value: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
351        let recovery = value["recovery"].as_array().unwrap();
352        assert!(recovery
353            .iter()
354            .any(|s| s.as_str().unwrap().contains("--allow-non-loopback")));
355        assert!(!value["message"].as_str().unwrap().contains("Recovery:"));
356    }
357
358    #[test]
359    fn emit_invalid_wyvern_viewer_env_has_flag_specific_recovery() {
360        let err = LoadError::Usage {
361            kind: UsageErrorKind::InvalidWyvernViewerEnv {
362                value: "not-a-viewer-mode".into(),
363            },
364            message: "invalid WYVERN_VIEWER=\"not-a-viewer-mode\"; expected embedded, none, system, or a named viewer path".into(),
365        };
366        let out = emit_usage_error(&err).expect("emit");
367        let value: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
368        assert!(value["cause"].as_str().unwrap().contains("WYVERN_VIEWER"));
369        let recovery = value["recovery"].as_array().unwrap();
370        assert!(recovery
371            .iter()
372            .any(|s| s.as_str().unwrap().contains("Unset WYVERN_VIEWER")));
373    }
374
375    #[test]
376    fn emit_unknown_subcommand_has_domain_specific_recovery() {
377        let browsers = LoadError::Usage {
378            kind: UsageErrorKind::UnknownSubcommand {
379                domain: BuiltinDomain::Browsers,
380                token: "nope".into(),
381            },
382            message: "unknown browsers subcommand 'nope'".into(),
383        };
384        let out = emit_usage_error(&browsers).expect("emit");
385        let value: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
386        assert!(value["cause"].as_str().unwrap().contains("nope"));
387        let recovery = value["recovery"].as_array().unwrap();
388        assert!(recovery
389            .iter()
390            .any(|s| s.as_str().unwrap().contains("browsers list")));
391
392        let extensions = LoadError::Usage {
393            kind: UsageErrorKind::UnknownSubcommand {
394                domain: BuiltinDomain::Extensions,
395                token: "show".into(),
396            },
397            message: "unknown extensions subcommand 'show'".into(),
398        };
399        let out = emit_usage_error(&extensions).expect("emit");
400        let value: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
401        let recovery = value["recovery"].as_array().unwrap();
402        assert!(recovery
403            .iter()
404            .any(|s| s.as_str().unwrap().contains("extensions list")));
405    }
406
407    #[test]
408    fn emit_missing_extension_id_has_show_recovery() {
409        let err = LoadError::Usage {
410            kind: UsageErrorKind::MissingExtensionId,
411            message: "extensions show requires an extension id".into(),
412        };
413        let out = emit_usage_error(&err).expect("emit");
414        let value: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
415        assert!(value["cause"].as_str().unwrap().contains("extension id"));
416        let recovery = value["recovery"].as_array().unwrap();
417        assert!(recovery
418            .iter()
419            .any(|s| s.as_str().unwrap().contains("extensions show <id>")));
420        assert!(recovery
421            .iter()
422            .any(|s| s.as_str().unwrap().contains("extensions list")));
423    }
424
425    #[test]
426    fn emit_missing_args_lists_flags() {
427        use crate::extensions::{ExtensionError, ExtensionId};
428        let err = ExtensionError::MissingArgs {
429            missing: vec!["--root".into(), "--file".into()],
430            declared: ["root".into(), "file".into()].into_iter().collect(),
431            extension_id: ExtensionId::try_from(String::from("compose-render")).expect("id"),
432            example: "wyvern compose render --root DIR --file FILE.j2".into(),
433            help_command: "wyvern compose render --help".into(),
434        };
435        let out = emit_extension_error(&err).expect("emit");
436        let value: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
437        let text = out.to_ascii_lowercase();
438        assert!(value["message"].as_str().unwrap().contains("--root"));
439        assert!(value["message"].as_str().unwrap().contains("--file"));
440        assert!(value["recovery"]
441            .as_array()
442            .unwrap()
443            .iter()
444            .any(|s| s.as_str().unwrap().contains("--root")));
445        assert!(value["recovery"]
446            .as_array()
447            .unwrap()
448            .iter()
449            .any(|s| s.as_str() == Some("Run wyvern compose render --help")));
450        assert!(
451            !out.contains("wyvern compose-render --help"),
452            "recovery must use the invocation prefix, not the extension id: {out}"
453        );
454        assert!(!text.contains("declare them as {arg:"));
455    }
456
457    #[test]
458    fn emit_unexpected_arg_is_caller_facing() {
459        use crate::extensions::{ExtensionError, ExtensionId};
460        let err = ExtensionError::UnexpectedArg {
461            token: "--undeclared".into(),
462            declared: ["root".into(), "file".into()].into_iter().collect(),
463            extension_id: ExtensionId::try_from(String::from("compose-render")).expect("id"),
464            help_command: "wyvern compose render --help".into(),
465        };
466        let out = emit_extension_error(&err).expect("emit");
467        let value: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
468        assert!(value["cause"].as_str().unwrap().contains("--undeclared"));
469        assert!(value["recovery"]
470            .as_array()
471            .unwrap()
472            .iter()
473            .any(|s| s.as_str().unwrap().contains("--root")));
474        assert!(value["recovery"]
475            .as_array()
476            .unwrap()
477            .iter()
478            .any(|s| s.as_str() == Some("Run wyvern compose render --help")));
479        assert!(!out.contains("wyvern compose-render --help"), "{out}");
480        assert!(
481            !out.contains("declare them as {arg:name}") && !out.contains("{arg:"),
482            "{out}"
483        );
484    }
485
486    #[test]
487    fn emit_preexec_timeout_mentions_env_var() {
488        use crate::extensions::{ExtensionError, PreexecFailureKind};
489        let err = ExtensionError::Preexec {
490            kind: Some(PreexecFailureKind::Timeout {
491                cmd: "slow".into(),
492                timeout_secs: 30,
493            }),
494            message: "slow timed out after 30s".into(),
495            source: None,
496        };
497        let out = emit_extension_error(&err).expect("emit");
498        assert!(
499            out.contains("WYVERN_PREEXEC_TIMEOUT_SECS"),
500            "timeout recovery must name the env var: {out}"
501        );
502        assert!(out.contains("30"), "{out}");
503    }
504
505    #[test]
506    fn emit_wizard_lint_io_maps_to_io_error() {
507        use crate::wizard_cmd::WizardLintStageError;
508        let err = WizardLintStageError::Io {
509            path: std::path::PathBuf::from("/missing/wizard.json"),
510            message: "error: '/missing/wizard.json' not found".into(),
511        };
512        let out = emit_wizard_lint_stage_error(&err).expect("emit");
513        let value: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
514        assert_eq!(value["error"], "io");
515        assert_eq!(value["code"], "IO_ERROR");
516        assert_eq!(value["subcode"], "wizard_lint_io");
517        assert!(!value["recovery"].as_array().unwrap().is_empty());
518    }
519
520    #[test]
521    fn emit_wizard_lint_parse_maps_to_parse_error() {
522        use crate::wizard_cmd::WizardLintStageError;
523        let err = WizardLintStageError::Parse {
524            path: std::path::PathBuf::from("wizard.json"),
525            message: "error: 'wizard.json': invalid JSON".into(),
526        };
527        let out = emit_wizard_lint_stage_error(&err).expect("emit");
528        let value: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
529        assert_eq!(value["error"], "parse");
530        assert_eq!(value["code"], "PARSE_ERROR");
531        assert_eq!(value["subcode"], "wizard_lint_parse");
532        assert!(value["recovery"]
533            .as_array()
534            .unwrap()
535            .iter()
536            .any(|s| s.as_str().unwrap().contains("valid JSON")));
537    }
538
539    #[test]
540    fn emit_wizard_lint_validation_maps_to_validation_error() {
541        use crate::wizard_cmd::WizardLintStageError;
542        let err = WizardLintStageError::Validation {
543            path: std::path::PathBuf::from("wizard.json"),
544            field: FieldName::new("page.id"),
545            message: "error: 'wizard.json': wizard.json page.id: wizard page field must be a non-empty string".into(),
546        };
547        let out = emit_wizard_lint_stage_error(&err).expect("emit");
548        let value: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
549        assert_eq!(value["error"], "validation");
550        assert_eq!(value["code"], "VALIDATION_ERROR");
551        assert_eq!(value["subcode"], "wizard_lint_validation");
552        assert_eq!(value["field"], "page.id");
553        assert!(value["message"]
554            .as_str()
555            .unwrap()
556            .contains("wizard page field must be a non-empty string"));
557    }
558
559    #[test]
560    fn emit_validation_unknown_type_includes_report() {
561        let err = wyvern_schema::ValidationError::Validation {
562            field: FieldName::new("type"),
563            message: "got 'unknown', expected one of: chrome, message, input, markdown, question, wizard, report"
564                .to_string(),
565        };
566        let out = emit_validation_error(&err).expect("emit");
567        let value: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
568        let recovery = value["recovery"].as_array().unwrap();
569        assert!(recovery.iter().any(|s| {
570            s.as_str()
571                .is_some_and(|step| step.contains("wizard, report"))
572        }));
573        assert!(recovery.iter().any(|s| {
574            s.as_str()
575                .is_some_and(|step| step.contains("\"type\":\"report\""))
576        }));
577    }
578
579    #[test]
580    fn emit_validation_report_page_is_string_path_not_wizard_object() {
581        let err = wyvern_schema::ValidationError::Validation {
582            field: FieldName::new("page"),
583            message: "field 'page' must end with .html or .xhtml (got 'pages/view.txt')"
584                .to_string(),
585        };
586        let out = emit_validation_error(&err).expect("emit");
587        let value: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
588        let recovery = value["recovery"].as_array().unwrap();
589        assert!(recovery.iter().any(|s| {
590            s.as_str()
591                .is_some_and(|step| step.contains("pages/view.xhtml"))
592        }));
593        assert!(!recovery.iter().any(|s| {
594            s.as_str()
595                .is_some_and(|step| step.contains("\"id\":\"start\""))
596        }));
597
598        let missing = wyvern_schema::ValidationError::Validation {
599            field: FieldName::new("page"),
600            message: "missing required field 'page'".to_string(),
601        };
602        let missing_out = emit_validation_error(&missing).expect("emit");
603        let missing_value: serde_json::Value =
604            serde_json::from_str(&missing_out).expect("valid JSON");
605        let missing_recovery = missing_value["recovery"].as_array().unwrap();
606        assert!(missing_recovery
607            .iter()
608            .any(|s| { s.as_str().is_some_and(|step| step.contains("path string")) }));
609    }
610
611    #[test]
612    fn emit_host_ui_not_found_mentions_report_page() {
613        let err = wyvern_host::HostError::UiNotFound {
614            path: std::path::PathBuf::from("pages/view.xhtml"),
615            source: None,
616        };
617        let out = emit_host_error(&err).expect("emit");
618        let value: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
619        assert!(value["cause"]
620            .as_str()
621            .is_some_and(|s| s.contains("report page")));
622        let recovery = value["recovery"].as_array().unwrap();
623        assert!(recovery
624            .iter()
625            .any(|s| { s.as_str().is_some_and(|step| step.contains("/report/**")) }));
626    }
627
628    #[test]
629    fn emit_host_unsupported_type_includes_report() {
630        let err = wyvern_host::HostError::UnsupportedType {
631            type_name: wyvern_host::DialogTypeName::Chrome,
632        };
633        let out = emit_host_error(&err).expect("emit");
634        let value: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
635        assert!(value["cause"]
636            .as_str()
637            .is_some_and(|s| s.contains("report")));
638        let recovery = value["recovery"].as_array().unwrap();
639        assert!(recovery.iter().any(|s| {
640            s.as_str()
641                .is_some_and(|step| step.contains("wizard, report"))
642        }));
643    }
644}