bsdkrun_sdk/error.rs
1//! The one error type every fallible call in this SDK returns.
2//!
3//! Mirrors the Python SDK's exception hierarchy, flattened into an enum:
4//! `BsdkrunError` becomes [`Error`] itself, and `AuthError` — a subclass of
5//! `GraphQLError` over there — becomes its own variant here, with [`Error::code`]
6//! preserving the "an auth failure always carries `UNAUTHENTICATED`" contract.
7
8/// `Result` alias used across the SDK.
9pub type Result<T> = std::result::Result<T, Error>;
10
11/// Every error the SDK raises.
12#[derive(Debug, thiserror::Error)]
13pub enum Error {
14 /// The `bsdkrun` binary could not be located on the host.
15 #[error(
16 "could not find the \"bsdkrun\" binary. Set BSDKRUN_BIN, add it to PATH, \
17 or call set_binary_path(). Looked in: {}",
18 searched.join(", ")
19 )]
20 BinaryNotFound {
21 /// Every candidate location, in the order it was tried.
22 searched: Vec<String>,
23 },
24
25 /// A `bsdkrun` invocation (or a guest command run through it) exited
26 /// non-zero.
27 #[error("{}", command_failed_message(*exit_code, command, stderr))]
28 CommandFailed {
29 exit_code: i32,
30 stdout: String,
31 stderr: String,
32 /// A short label for what was run, e.g. `"bsdkrun stop"`.
33 command: String,
34 },
35
36 /// No machine matched the given id / prefix.
37 #[error("no sandbox found matching id {id:?}")]
38 SandboxNotFound { id: String },
39
40 /// A GraphQL- or transport-level failure talking to a remote `bsdkrund`.
41 ///
42 /// `code` carries the response's `extensions.code` when the daemon set one
43 /// (e.g. `"INVALID_ARGUMENT"`, `"FAILED"`); it is `None` for a transport
44 /// failure (the daemon was unreachable) or a malformed response.
45 #[error("{message}")]
46 GraphQL {
47 message: String,
48 code: Option<String>,
49 },
50
51 /// The daemon rejected the bearer token: an HTTP 401, a GraphQL error with
52 /// `extensions.code == "UNAUTHENTICATED"`, or the WebSocket closing before
53 /// `connection_ack` was ever received.
54 #[error("{message}")]
55 Auth { message: String },
56
57 /// An option combination the SDK refuses before running anything — a
58 /// missing required builder field, a URL configured without a token.
59 #[error("{0}")]
60 InvalidInput(String),
61
62 /// A host-side I/O failure spawning or driving the `bsdkrun` process.
63 #[error(transparent)]
64 Io(#[from] std::io::Error),
65
66 /// Output that should have been JSON was not.
67 #[error(transparent)]
68 Json(#[from] serde_json::Error),
69}
70
71impl Error {
72 /// The daemon's `extensions.code`, when there is one. [`Error::Auth`]
73 /// always answers `UNAUTHENTICATED`, matching the Python SDK where
74 /// `AuthError` is a `GraphQLError` with that code baked in.
75 pub fn code(&self) -> Option<&str> {
76 match self {
77 Error::GraphQL { code, .. } => code.as_deref(),
78 Error::Auth { .. } => Some("UNAUTHENTICATED"),
79 _ => None,
80 }
81 }
82
83 /// The default auth failure, shared by the HTTP 401 and closed-before-ack
84 /// paths.
85 pub(crate) fn auth_default() -> Error {
86 Error::Auth {
87 message: "the daemon rejected this token".to_string(),
88 }
89 }
90}
91
92fn command_failed_message(exit_code: i32, command: &str, stderr: &str) -> String {
93 let mut message = format!("command failed (exit {exit_code}): {command}");
94 let trimmed = stderr.trim();
95 if !trimmed.is_empty() {
96 message.push('\n');
97 message.push_str(trimmed);
98 }
99 message
100}
101
102#[cfg(test)]
103mod tests {
104 use super::*;
105
106 #[test]
107 fn command_failed_appends_stderr_only_when_present() {
108 let quiet = Error::CommandFailed {
109 exit_code: 2,
110 stdout: String::new(),
111 stderr: " ".into(),
112 command: "bsdkrun stop".into(),
113 };
114 assert_eq!(quiet.to_string(), "command failed (exit 2): bsdkrun stop");
115
116 let loud = Error::CommandFailed {
117 exit_code: 1,
118 stdout: String::new(),
119 stderr: "boom\n".into(),
120 command: "bsdkrun rm".into(),
121 };
122 assert_eq!(
123 loud.to_string(),
124 "command failed (exit 1): bsdkrun rm\nboom"
125 );
126 }
127
128 #[test]
129 fn auth_reports_the_unauthenticated_code() {
130 assert_eq!(Error::auth_default().code(), Some("UNAUTHENTICATED"));
131 let gql = Error::GraphQL {
132 message: "no".into(),
133 code: Some("FAILED".into()),
134 };
135 assert_eq!(gql.code(), Some("FAILED"));
136 }
137}