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 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}