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};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum BuiltinDomain {
16 Browsers,
18 Extensions,
20}
21
22impl BuiltinDomain {
23 #[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#[derive(Debug, Clone, PartialEq, Eq)]
41pub enum UsageErrorKind {
42 Generic,
44 InvalidBind {
46 value: String,
48 },
49 MissingFlagValue {
51 flag: String,
53 },
54 InvalidViewer {
56 value: String,
58 },
59 InvalidWyvernViewerEnv {
61 value: String,
63 },
64 InvalidWyvernViewerUnicode,
66 UnknownSubcommand {
68 domain: BuiltinDomain,
70 token: String,
72 },
73 MissingExtensionId,
75}
76
77#[derive(Debug)]
79pub enum LoadError {
80 Parse { message: String },
82 Io {
84 field: FieldName,
85 message: String,
86 source: Option<Box<dyn std::error::Error + Send + Sync + 'static>>,
88 },
89 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 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#[derive(Debug)]
128pub enum EmitError {
129 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}