Skip to main content

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}