1mod 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum BuiltinDomain {
17 Browsers,
19 Extensions,
21 Examples,
23 Wizard,
25}
26
27impl BuiltinDomain {
28 #[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#[derive(Debug, Clone, PartialEq, Eq)]
48pub enum UsageErrorKind {
49 Generic,
51 InvalidBind {
53 value: String,
55 },
56 MissingFlagValue {
58 flag: String,
60 },
61 InvalidViewer {
63 value: String,
65 },
66 InvalidWyvernViewerEnv {
68 value: String,
70 },
71 InvalidWyvernViewerUnicode,
73 UnknownSubcommand {
75 domain: BuiltinDomain,
77 token: String,
79 },
80 MissingExtensionId,
82}
83
84#[derive(Debug)]
86pub enum LoadError {
87 Parse { message: String },
89 Io {
91 field: FieldName,
92 message: String,
93 source: Option<Box<dyn std::error::Error + Send + Sync + 'static>>,
95 },
96 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 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#[derive(Debug)]
135pub enum EmitError {
136 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}