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 guest filesystem operation was refused (see [`crate::FileSystem`]).
41 #[error("{message}")]
42 FileTransfer {
43 /// The path that could not be transferred.
44 path: String,
45 message: String,
46 },
47
48 /// A GraphQL- or transport-level failure talking to a remote `bsdkrund`.
49 ///
50 /// `code` carries the response's `extensions.code` when the daemon set one
51 /// (e.g. `"INVALID_ARGUMENT"`, `"FAILED"`); it is `None` for a transport
52 /// failure (the daemon was unreachable) or a malformed response.
53 #[error("{message}")]
54 GraphQL {
55 message: String,
56 code: Option<String>,
57 },
58
59 /// The daemon rejected the bearer token: an HTTP 401, a GraphQL error with
60 /// `extensions.code == "UNAUTHENTICATED"`, or the WebSocket closing before
61 /// `connection_ack` was ever received.
62 #[error("{message}")]
63 Auth { message: String },
64
65 /// An option combination the SDK refuses before running anything — a
66 /// missing required builder field, a URL configured without a token.
67 #[error("{0}")]
68 InvalidInput(String),
69
70 /// A host-side I/O failure spawning or driving the `bsdkrun` process.
71 #[error(transparent)]
72 Io(#[from] std::io::Error),
73
74 /// Output that should have been JSON was not.
75 #[error(transparent)]
76 Json(#[from] serde_json::Error),
77}
78
79impl Error {
80 /// The daemon's `extensions.code`, when there is one. [`Error::Auth`]
81 /// always answers `UNAUTHENTICATED`, matching the Python SDK where
82 /// `AuthError` is a `GraphQLError` with that code baked in.
83 pub fn code(&self) -> Option<&str> {
84 match self {
85 Error::GraphQL { code, .. } => code.as_deref(),
86 Error::Auth { .. } => Some("UNAUTHENTICATED"),
87 _ => None,
88 }
89 }
90
91 /// The default auth failure, shared by the HTTP 401 and closed-before-ack
92 /// paths.
93 pub(crate) fn auth_default() -> Error {
94 Error::Auth {
95 message: "the daemon rejected this token".to_string(),
96 }
97 }
98}
99
100fn command_failed_message(exit_code: i32, command: &str, stderr: &str) -> String {
101 let mut message = format!("command failed (exit {exit_code}): {command}");
102 let trimmed = stderr.trim();
103 if !trimmed.is_empty() {
104 message.push('\n');
105 message.push_str(trimmed);
106 }
107 message
108}
109
110#[cfg(test)]
111mod tests {
112 use super::*;
113
114 #[test]
115 fn command_failed_appends_stderr_only_when_present() {
116 let quiet = Error::CommandFailed {
117 exit_code: 2,
118 stdout: String::new(),
119 stderr: " ".into(),
120 command: "bsdkrun stop".into(),
121 };
122 assert_eq!(quiet.to_string(), "command failed (exit 2): bsdkrun stop");
123
124 let loud = Error::CommandFailed {
125 exit_code: 1,
126 stdout: String::new(),
127 stderr: "boom\n".into(),
128 command: "bsdkrun rm".into(),
129 };
130 assert_eq!(
131 loud.to_string(),
132 "command failed (exit 1): bsdkrun rm\nboom"
133 );
134 }
135
136 #[test]
137 fn auth_reports_the_unauthenticated_code() {
138 assert_eq!(Error::auth_default().code(), Some("UNAUTHENTICATED"));
139 let gql = Error::GraphQL {
140 message: "no".into(),
141 code: Some("FAILED".into()),
142 };
143 assert_eq!(gql.code(), Some("FAILED"));
144 }
145}