Skip to main content

bsdkrun_sdk/
types.rs

1//! Typed data structures returned by the SDK.
2//!
3//! The `*Info` structs mirror the JSON rows emitted by the `bsdkrun` CLI's
4//! `--json` output (snake_case field names). The parsers are hand-written over
5//! [`serde_json::Value`] rather than derived, because the same struct is fed
6//! from two wire shapes: the CLI's rows and the daemon's camelCase GraphQL
7//! objects, where timestamps arrive as decimal *strings* (the daemon passes
8//! the CLI's own text through unchanged) and numbers may be widened to Float.
9
10use serde_json::Value;
11
12use crate::error::{Error, Result};
13
14// -- lenient Value accessors -------------------------------------------------
15
16fn get_str(v: &Value, key: &str) -> String {
17    v.get(key)
18        .and_then(Value::as_str)
19        .unwrap_or_default()
20        .to_string()
21}
22
23fn get_opt_str(v: &Value, key: &str) -> Option<String> {
24    v.get(key).and_then(Value::as_str).map(str::to_string)
25}
26
27fn get_bool(v: &Value, key: &str) -> bool {
28    v.get(key).and_then(Value::as_bool).unwrap_or(false)
29}
30
31/// A number, however it arrived: a JSON int, a Float the GraphQL layer widened
32/// it to, or a decimal string.
33fn get_num(v: &Value, key: &str) -> Option<i64> {
34    match v.get(key) {
35        Some(Value::Number(n)) => n.as_i64().or_else(|| n.as_f64().map(|f| f as i64)),
36        Some(Value::String(s)) => s.trim().parse().ok(),
37        _ => None,
38    }
39}
40
41// -- data types ---------------------------------------------------------------
42
43/// A host to guest TCP port forward.
44///
45/// `bind` is the host interface the forward is bound to (`127.0.0.1` by
46/// default, or `0.0.0.0` for a LAN-reachable forward).
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct PortForward {
49    pub host: u16,
50    pub guest: u16,
51    pub bind: String,
52}
53
54impl PortForward {
55    pub fn from_row(row: &Value) -> PortForward {
56        PortForward {
57            host: get_num(row, "host").unwrap_or(0) as u16,
58            guest: get_num(row, "guest").unwrap_or(0) as u16,
59            bind: {
60                let bind = get_str(row, "bind");
61                if bind.is_empty() {
62                    "127.0.0.1".to_string()
63                } else {
64                    bind
65                }
66            },
67        }
68    }
69}
70
71/// A machine as reported by `bsdkrun ps --json` (or the daemon's `machines`
72/// query).
73#[derive(Debug, Clone, PartialEq)]
74pub struct SandboxInfo {
75    pub id: String,
76    pub name: Option<String>,
77    pub image: String,
78    pub kind: String,
79    pub command: String,
80    pub status: String,
81    pub running: bool,
82    pub exit_code: Option<i32>,
83    pub pid: Option<i64>,
84    pub detached: bool,
85    pub cpus: u32,
86    pub mem: u64,
87    pub volume: Option<String>,
88    pub state_dir: String,
89    pub network: Option<String>,
90    pub net_ip: Option<String>,
91    pub ports: Vec<PortForward>,
92    pub created_at: i64,
93    pub finished_at: Option<i64>,
94}
95
96impl SandboxInfo {
97    /// Build from a CLI `ps --json` row (snake_case).
98    pub fn from_row(row: &Value) -> SandboxInfo {
99        let running = get_bool(row, "running");
100        SandboxInfo {
101            id: get_str(row, "id"),
102            name: get_opt_str(row, "name"),
103            image: get_str(row, "image"),
104            kind: get_str(row, "kind"),
105            command: get_str(row, "command"),
106            status: if running { "running" } else { "exited" }.to_string(),
107            running,
108            exit_code: get_num(row, "exit_code").map(|v| v as i32),
109            pid: get_num(row, "pid"),
110            detached: get_bool(row, "detached"),
111            cpus: get_num(row, "cpus").unwrap_or(0) as u32,
112            mem: get_num(row, "mem").unwrap_or(0) as u64,
113            volume: get_opt_str(row, "volume"),
114            state_dir: get_str(row, "state_dir"),
115            network: get_opt_str(row, "network"),
116            net_ip: get_opt_str(row, "net_ip"),
117            ports: ports_from(row.get("ports")),
118            created_at: get_num(row, "created_at").unwrap_or(0),
119            finished_at: get_num(row, "finished_at"),
120        }
121    }
122
123    /// Build from a GraphQL `Machine` (the `MACHINE_FIELDS` selection).
124    pub fn from_graphql(m: &Value) -> SandboxInfo {
125        let running = get_bool(m, "running");
126        let status = {
127            let s = get_str(m, "status");
128            if s.is_empty() {
129                if running { "running" } else { "exited" }.to_string()
130            } else {
131                s
132            }
133        };
134        SandboxInfo {
135            id: get_str(m, "id"),
136            name: get_opt_str(m, "name"),
137            image: get_str(m, "image"),
138            kind: get_str(m, "kind"),
139            command: get_str(m, "command"),
140            status,
141            running,
142            exit_code: get_num(m, "exitCode").map(|v| v as i32),
143            pid: get_num(m, "pid"),
144            detached: get_bool(m, "detached"),
145            cpus: get_num(m, "cpus").unwrap_or(0) as u32,
146            mem: get_num(m, "mem").unwrap_or(0) as u64,
147            volume: get_opt_str(m, "volume"),
148            state_dir: get_str(m, "stateDir"),
149            network: get_opt_str(m, "network"),
150            net_ip: get_opt_str(m, "netIp"),
151            ports: ports_from(m.get("ports")),
152            created_at: get_num(m, "createdAt").unwrap_or(0),
153            finished_at: get_num(m, "finishedAt"),
154        }
155    }
156}
157
158fn ports_from(v: Option<&Value>) -> Vec<PortForward> {
159    v.and_then(Value::as_array)
160        .map(|rows| rows.iter().map(PortForward::from_row).collect())
161        .unwrap_or_default()
162}
163
164/// An image as reported by `bsdkrun images --json`.
165#[derive(Debug, Clone, PartialEq)]
166pub struct ImageInfo {
167    pub id: String,
168    pub reference: String,
169    pub digest: String,
170    pub size: u64,
171    pub rootfs: String,
172    pub created_at: i64,
173}
174
175impl ImageInfo {
176    pub fn from_row(row: &Value) -> ImageInfo {
177        ImageInfo {
178            id: get_str(row, "id"),
179            reference: get_str(row, "reference"),
180            digest: get_str(row, "digest"),
181            size: get_num(row, "size").unwrap_or(0) as u64,
182            rootfs: get_str(row, "rootfs"),
183            created_at: get_num(row, "created_at").unwrap_or(0),
184        }
185    }
186}
187
188/// A volume as reported by `bsdkrun volume ls --json`.
189#[derive(Debug, Clone, PartialEq)]
190pub struct VolumeInfo {
191    pub name: String,
192    pub guest: Option<String>,
193    pub base: Option<String>,
194    pub path: String,
195    pub size: String,
196    pub created_at: Option<i64>,
197    pub tracked: bool,
198}
199
200impl VolumeInfo {
201    pub fn from_row(row: &Value) -> VolumeInfo {
202        VolumeInfo {
203            name: get_str(row, "name"),
204            guest: get_opt_str(row, "guest"),
205            base: get_opt_str(row, "base"),
206            path: get_str(row, "path"),
207            size: get_str(row, "size"),
208            created_at: get_num(row, "created_at"),
209            tracked: get_bool(row, "tracked"),
210        }
211    }
212}
213
214/// A global network as reported by `bsdkrun network ls --json`.
215#[derive(Debug, Clone, PartialEq)]
216pub struct NetworkInfo {
217    pub name: String,
218    pub subnet: String,
219    pub gateway: String,
220    pub members: u32,
221    pub running: u32,
222    pub up: bool,
223    pub created_at: Option<i64>,
224}
225
226impl NetworkInfo {
227    pub fn from_row(row: &Value) -> NetworkInfo {
228        NetworkInfo {
229            name: get_str(row, "name"),
230            subnet: get_str(row, "subnet"),
231            gateway: get_str(row, "gateway"),
232            members: get_num(row, "members").unwrap_or(0) as u32,
233            running: get_num(row, "running").unwrap_or(0) as u32,
234            up: get_bool(row, "up"),
235            created_at: get_num(row, "created_at"),
236        }
237    }
238}
239
240/// The outcome of a remote lifecycle mutation (stop/start/remove/update/commit).
241///
242/// Mirrors the GraphQL `CommandResult` type. A non-zero `exit_code` is
243/// reported rather than raised: for some underlying commands (`ssh status`,
244/// `tailscale status`) it is a legitimate state to display, not a transport
245/// failure.
246#[derive(Debug, Clone, PartialEq, Eq)]
247pub struct CommandResult {
248    pub exit_code: i32,
249    pub stdout: String,
250    pub stderr: String,
251}
252
253impl CommandResult {
254    pub fn from_graphql(r: &Value) -> CommandResult {
255        CommandResult {
256            exit_code: get_num(r, "exitCode").unwrap_or(0) as i32,
257            stdout: get_str(r, "stdout"),
258            stderr: get_str(r, "stderr"),
259        }
260    }
261}
262
263/// A shell session as reported by `openShell` / `shellSessions`.
264#[derive(Debug, Clone, PartialEq, Eq)]
265pub struct ShellSessionInfo {
266    pub id: String,
267    pub machine_id: String,
268    pub finished: bool,
269    pub truncated: bool,
270}
271
272impl ShellSessionInfo {
273    pub fn from_graphql(s: &Value) -> ShellSessionInfo {
274        ShellSessionInfo {
275            id: get_str(s, "id"),
276            machine_id: get_str(s, "machineId"),
277            finished: get_bool(s, "finished"),
278            truncated: get_bool(s, "truncated"),
279        }
280    }
281}
282
283/// The captured result of running a command in a guest through the local CLI.
284///
285/// Returned by [`crate::Sandbox::exec`] and the command builder's `run()`.
286#[derive(Debug, Clone, PartialEq, Eq)]
287pub struct ExecResult {
288    pub stdout: String,
289    pub stderr: String,
290    pub exit_code: i32,
291    /// A short label for what ran, used in [`Error::CommandFailed`].
292    pub command: String,
293}
294
295impl ExecResult {
296    /// Whether the command succeeded (exit 0).
297    pub fn ok(&self) -> bool {
298        self.exit_code == 0
299    }
300
301    /// `stdout` with trailing newlines trimmed — the common case.
302    pub fn text(&self) -> &str {
303        self.stdout.trim_end_matches('\n')
304    }
305
306    /// Parse `stdout` as JSON into any deserializable type.
307    pub fn json<T: serde::de::DeserializeOwned>(&self) -> Result<T> {
308        Ok(serde_json::from_str(&self.stdout)?)
309    }
310
311    /// Non-empty `stdout` lines.
312    pub fn lines(&self) -> Vec<String> {
313        self.stdout
314            .split('\n')
315            .filter(|line| !line.is_empty())
316            .map(str::to_string)
317            .collect()
318    }
319
320    /// A non-zero exit becomes [`Error::CommandFailed`]; exit 0 passes the
321    /// result through, so it chains: `sandbox.exec([...])?.ok_or_err()?`.
322    pub fn ok_or_err(self) -> Result<ExecResult> {
323        if self.exit_code != 0 {
324            return Err(Error::CommandFailed {
325                exit_code: self.exit_code,
326                stdout: self.stdout,
327                stderr: self.stderr,
328                command: self.command,
329            });
330        }
331        Ok(self)
332    }
333}
334
335/// The captured result of [`crate::Client::exec`] against a remote daemon.
336///
337/// Unlike [`ExecResult`] (the local CLI's captured stdout/stderr as text), a
338/// remote exec's output is a single interleaved byte stream — the shell
339/// agent's `shellOutput` subscription does not distinguish stdout from
340/// stderr — so this carries raw bytes instead.
341#[derive(Debug, Clone, PartialEq, Eq)]
342pub struct RemoteExecResult {
343    pub exit_code: i32,
344    pub output: Vec<u8>,
345}
346
347impl RemoteExecResult {
348    /// Whether the command succeeded (exit 0).
349    pub fn ok(&self) -> bool {
350        self.exit_code == 0
351    }
352
353    /// The output as (lossy) UTF-8 text.
354    pub fn text(&self) -> String {
355        String::from_utf8_lossy(&self.output).into_owned()
356    }
357}
358
359#[cfg(test)]
360mod tests {
361    use super::*;
362    use serde_json::json;
363
364    #[test]
365    fn sandbox_info_from_row_running() {
366        let info = SandboxInfo::from_row(&json!({
367            "id": "abc123def456",
368            "name": null,
369            "image": "alpine",
370            "kind": "linux",
371            "command": "sleep 300",
372            "running": true,
373            "exit_code": null,
374            "pid": 4242,
375            "detached": true,
376            "cpus": 2,
377            "mem": 1024,
378            "volume": null,
379            "state_dir": "/var/lib/bsdkrun/abc123",
380            "network": "devnet",
381            "net_ip": "192.168.127.3",
382            "created_at": 1700000000i64,
383            "finished_at": null,
384        }));
385        assert_eq!(info.status, "running");
386        assert!(info.running);
387        assert_eq!(info.exit_code, None);
388        assert_eq!(info.pid, Some(4242));
389        assert_eq!(info.network.as_deref(), Some("devnet"));
390    }
391
392    #[test]
393    fn sandbox_info_from_row_exited_defaults() {
394        let info = SandboxInfo::from_row(&json!({
395            "id": "abc",
396            "image": "alpine",
397            "kind": "linux",
398            "running": false,
399            "exit_code": 0,
400            "detached": true,
401            "cpus": 1,
402            "mem": 512,
403            "state_dir": "/s",
404            "created_at": 1,
405            "finished_at": 2,
406        }));
407        assert_eq!(info.status, "exited");
408        assert_eq!(info.command, "");
409        assert_eq!(info.finished_at, Some(2));
410    }
411
412    #[test]
413    fn sandbox_info_from_graphql_maps_camel_case_and_string_timestamps() {
414        let info = SandboxInfo::from_graphql(&json!({
415            "id": "abc123def456",
416            "name": null,
417            "image": "alpine",
418            "kind": "linux",
419            "command": "sleep 300",
420            "status": "running",
421            "running": true,
422            "exitCode": null,
423            "pid": 4242,
424            "detached": true,
425            "cpus": 2,
426            "mem": 1024,
427            "volume": null,
428            "stateDir": "/var/lib/bsdkrun/abc123",
429            "network": "devnet",
430            "netIp": "192.168.127.3",
431            "ports": [{"bind": "0.0.0.0", "host": 2222, "guest": 22}],
432            "createdAt": "1700000000",
433            "finishedAt": null,
434        }));
435        assert_eq!(info.state_dir, "/var/lib/bsdkrun/abc123");
436        assert_eq!(info.net_ip.as_deref(), Some("192.168.127.3"));
437        assert_eq!(info.created_at, 1700000000);
438        assert_eq!(info.finished_at, None);
439        assert_eq!(
440            info.ports,
441            vec![PortForward {
442                host: 2222,
443                guest: 22,
444                bind: "0.0.0.0".into()
445            }]
446        );
447    }
448
449    #[test]
450    fn graphql_floats_coerce_back_to_ints() {
451        // GraphQL widens pid/mem to Float to dodge 32-bit Int overflow; the
452        // mapping must still land on plain integers.
453        let info = SandboxInfo::from_graphql(&json!({
454            "id": "abc",
455            "image": "alpine",
456            "kind": "linux",
457            "command": "",
458            "status": "exited",
459            "running": false,
460            "exitCode": 0,
461            "pid": 4242.0,
462            "detached": true,
463            "cpus": 1,
464            "mem": 512.0,
465            "stateDir": "/s",
466            "createdAt": "1",
467            "finishedAt": "2",
468        }));
469        assert_eq!(info.pid, Some(4242));
470        assert_eq!(info.mem, 512);
471        assert_eq!(info.finished_at, Some(2));
472    }
473
474    #[test]
475    fn volume_and_network_rows() {
476        let vol = VolumeInfo::from_row(&json!({
477            "name": "web", "path": "/p", "size": "1G", "tracked": true
478        }));
479        assert_eq!(vol.name, "web");
480        assert_eq!(vol.guest, None);
481        assert_eq!(vol.created_at, None);
482
483        let net = NetworkInfo::from_row(&json!({
484            "name": "devnet",
485            "subnet": "192.168.127.0/24",
486            "gateway": "192.168.127.1",
487            "members": 2,
488            "running": 1,
489            "up": true,
490        }));
491        assert_eq!(net.members, 2);
492        assert!(net.up);
493    }
494
495    #[test]
496    fn command_result_defaults_sanely() {
497        let full = CommandResult::from_graphql(&json!({
498            "exitCode": 1, "stdout": "out", "stderr": "err"
499        }));
500        assert_eq!((full.exit_code, full.stdout.as_str()), (1, "out"));
501
502        let empty = CommandResult::from_graphql(&json!({}));
503        assert_eq!(empty.exit_code, 0);
504        assert_eq!(empty.stdout, "");
505    }
506
507    #[test]
508    fn shell_session_info_from_graphql() {
509        let s = ShellSessionInfo::from_graphql(&json!({
510            "id": "sess-1", "machineId": "abc123", "finished": false, "truncated": true
511        }));
512        assert_eq!(s.id, "sess-1");
513        assert_eq!(s.machine_id, "abc123");
514        assert!(!s.finished);
515        assert!(s.truncated);
516    }
517
518    #[test]
519    fn exec_result_helpers() {
520        let ok = ExecResult {
521            stdout: "hello\n\n".into(),
522            stderr: String::new(),
523            exit_code: 0,
524            command: "echo".into(),
525        };
526        assert!(ok.ok());
527        assert_eq!(ok.text(), "hello");
528        assert_eq!(ok.lines(), vec!["hello".to_string()]);
529        assert!(ok.ok_or_err().is_ok());
530
531        let failed = ExecResult {
532            stdout: String::new(),
533            stderr: "boom".into(),
534            exit_code: 1,
535            command: "false".into(),
536        };
537        assert!(!failed.ok());
538        assert!(matches!(
539            failed.ok_or_err(),
540            Err(Error::CommandFailed { exit_code: 1, .. })
541        ));
542    }
543
544    #[test]
545    fn exec_result_json_parses_stdout() {
546        let r = ExecResult {
547            stdout: "{\"a\": 1}".into(),
548            stderr: String::new(),
549            exit_code: 0,
550            command: "cat".into(),
551        };
552        let v: serde_json::Value = r.json().unwrap();
553        assert_eq!(v["a"], 1);
554    }
555}