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    /// The snapshot this machine was branched from, if any.
95    pub origin: Option<String>,
96}
97
98/// A coding agent bsdkrun can sandbox.
99///
100/// Each runs in a disposable microVM with a persistent login, a shared skills
101/// store, and only the folder you choose to share.
102#[derive(Debug, Clone, PartialEq)]
103pub struct AiAgent {
104    /// Stable id — `claude`, `codex`, … Also the CLI alias.
105    pub id: String,
106    pub label: String,
107    /// The catalog flavor that installs it.
108    pub flavor: String,
109    pub description: String,
110    /// Its flavor is provisioned, so a sandbox boots in a second. False means
111    /// the first launch installs a toolchain — minutes.
112    pub installed: bool,
113    pub running: i64,
114}
115
116impl AiAgent {
117    pub fn from_graphql(a: &Value) -> AiAgent {
118        AiAgent {
119            id: get_str(a, "id"),
120            label: get_str(a, "label"),
121            flavor: get_str(a, "flavor"),
122            description: get_str(a, "description"),
123            installed: get_bool(a, "installed"),
124            running: get_num(a, "running").unwrap_or(0),
125        }
126    }
127}
128
129/// One agent sandbox. It is a machine, so `logs`/`stop` work on `id`.
130#[derive(Debug, Clone, PartialEq)]
131pub struct AiSession {
132    pub id: String,
133    pub name: String,
134    pub agent: String,
135    pub running: bool,
136    /// The directory shared into it, on the engine's host.
137    pub workspace: Option<String>,
138    /// Unix epoch seconds.
139    pub created_at: i64,
140}
141
142impl AiSession {
143    pub fn from_graphql(s: &Value) -> AiSession {
144        AiSession {
145            id: get_str(s, "id"),
146            name: get_str(s, "name"),
147            agent: get_str(s, "agent"),
148            running: get_bool(s, "running"),
149            workspace: get_opt_str(s, "workspace"),
150            created_at: get_num(s, "createdAt").unwrap_or(0),
151        }
152    }
153}
154
155/// The Docker engine VM: whether it is up, and how to reach it.
156///
157/// bsdkrun runs one `docker:dind` microVM and serves its API on a host unix
158/// socket, so the host's own `docker` CLI drives the same engine.
159#[derive(Debug, Clone, PartialEq)]
160pub struct DockerStatus {
161    pub running: bool,
162    pub machine_id: Option<String>,
163    pub machine_running: bool,
164    /// The unix socket the `docker` CLI talks to.
165    pub socket: String,
166    pub socket_ready: bool,
167    pub api_port: Option<u16>,
168    pub version: Option<String>,
169    pub containers: Option<i64>,
170    pub images: Option<i64>,
171    /// Host directories shared into the VM, each `HOST:GUEST`.
172    pub mounts: Vec<String>,
173    /// The dedicated image-store disk, when the VM has one.
174    pub disk: Option<String>,
175    /// Its size in bytes — sparse, so the cap rather than the usage.
176    pub disk_size: Option<u64>,
177}
178
179impl DockerStatus {
180    pub fn from_graphql(s: &Value) -> DockerStatus {
181        DockerStatus {
182            running: get_bool(s, "running"),
183            machine_id: get_opt_str(s, "machineId"),
184            machine_running: get_bool(s, "machineRunning"),
185            socket: get_str(s, "socket"),
186            socket_ready: get_bool(s, "socketReady"),
187            api_port: get_num(s, "apiPort").map(|v| v as u16),
188            version: get_opt_str(s, "version"),
189            containers: get_num(s, "containers"),
190            images: get_num(s, "images"),
191            mounts: strings_from(s.get("mounts")),
192            disk: get_opt_str(s, "disk"),
193            disk_size: get_num(s, "diskSize").map(|v| v as u64),
194        }
195    }
196}
197
198/// A container in the Docker engine VM — a trimmed `docker ps` row.
199#[derive(Debug, Clone, PartialEq)]
200pub struct DockerContainer {
201    pub id: String,
202    pub name: String,
203    pub image: String,
204    pub command: String,
205    /// `running` / `exited` / `created` / `paused` / …
206    pub state: String,
207    /// Docker's human status, e.g. "Up 3 minutes".
208    pub status: String,
209    /// Published forwards, each `HOST:GUEST/proto`.
210    pub ports: Vec<String>,
211    /// Unix epoch seconds.
212    pub created: i64,
213}
214
215impl DockerContainer {
216    pub fn from_graphql(c: &Value) -> DockerContainer {
217        DockerContainer {
218            id: get_str(c, "id"),
219            name: get_str(c, "name"),
220            image: get_str(c, "image"),
221            command: get_str(c, "command"),
222            state: get_str(c, "state"),
223            status: get_str(c, "status"),
224            ports: strings_from(c.get("ports")),
225            created: get_num(c, "created").unwrap_or(0),
226        }
227    }
228
229    /// Whether the container is up.
230    pub fn is_running(&self) -> bool {
231        self.state == "running"
232    }
233}
234
235fn strings_from(v: Option<&Value>) -> Vec<String> {
236    v.and_then(Value::as_array)
237        .map(|xs| {
238            xs.iter()
239                .filter_map(Value::as_str)
240                .map(str::to_string)
241                .collect()
242        })
243        .unwrap_or_default()
244}
245
246/// A machine snapshot: one machine's disk state, captured under a name.
247///
248/// A copy-on-write clone rather than a memory image — the files the guest
249/// wrote, not what it was executing. [`crate::Client::branch`] boots a new
250/// machine from one; [`crate::Client::restore`] puts one back.
251#[derive(Debug, Clone, PartialEq)]
252pub struct SnapshotInfo {
253    pub id: String,
254    pub name: String,
255    pub machine_id: String,
256    /// The machine's name when the snapshot was taken; empty if it had none.
257    pub machine_name: String,
258    /// `linux` / `freebsd` / `netbsd` / `unikraft`.
259    pub kind: String,
260    pub image: String,
261    pub path: String,
262    /// The snapshot the source machine was itself branched from, if any.
263    pub parent: Option<String>,
264    pub description: String,
265    pub cpus: u32,
266    pub mem: u64,
267    pub ports: Vec<PortForward>,
268    /// Human-readable, when measured — a CoW clone costs nothing to take.
269    pub size: Option<String>,
270    pub created_at: i64,
271}
272
273impl SnapshotInfo {
274    /// Build from a GraphQL `Snapshot` (camelCase).
275    pub fn from_graphql(s: &Value) -> SnapshotInfo {
276        SnapshotInfo {
277            id: get_str(s, "id"),
278            name: get_str(s, "name"),
279            machine_id: get_str(s, "machineId"),
280            machine_name: get_str(s, "machineName"),
281            kind: get_str(s, "kind"),
282            image: get_str(s, "image"),
283            path: get_str(s, "path"),
284            parent: get_opt_str(s, "parent"),
285            description: get_str(s, "description"),
286            cpus: get_num(s, "cpus").unwrap_or(0) as u32,
287            mem: get_num(s, "mem").unwrap_or(0) as u64,
288            ports: ports_from(s.get("ports")),
289            size: get_opt_str(s, "size"),
290            created_at: get_num(s, "createdAt").unwrap_or(0),
291        }
292    }
293
294    /// Build from a CLI `snapshots --json` row (snake_case).
295    pub fn from_row(row: &Value) -> SnapshotInfo {
296        SnapshotInfo {
297            id: get_str(row, "id"),
298            name: get_str(row, "name"),
299            machine_id: get_str(row, "machine_id"),
300            machine_name: get_str(row, "machine_name"),
301            kind: get_str(row, "kind"),
302            image: get_str(row, "image"),
303            path: get_str(row, "path"),
304            parent: get_opt_str(row, "parent"),
305            description: get_str(row, "description"),
306            cpus: get_num(row, "cpus").unwrap_or(0) as u32,
307            mem: get_num(row, "mem").unwrap_or(0) as u64,
308            ports: ports_from(row.get("ports")),
309            size: get_opt_str(row, "size"),
310            created_at: get_num(row, "created_at").unwrap_or(0),
311        }
312    }
313}
314
315impl SandboxInfo {
316    /// Build from a CLI `ps --json` row (snake_case).
317    pub fn from_row(row: &Value) -> SandboxInfo {
318        let running = get_bool(row, "running");
319        SandboxInfo {
320            id: get_str(row, "id"),
321            name: get_opt_str(row, "name"),
322            image: get_str(row, "image"),
323            kind: get_str(row, "kind"),
324            command: get_str(row, "command"),
325            status: if running { "running" } else { "exited" }.to_string(),
326            running,
327            exit_code: get_num(row, "exit_code").map(|v| v as i32),
328            pid: get_num(row, "pid"),
329            detached: get_bool(row, "detached"),
330            cpus: get_num(row, "cpus").unwrap_or(0) as u32,
331            mem: get_num(row, "mem").unwrap_or(0) as u64,
332            volume: get_opt_str(row, "volume"),
333            state_dir: get_str(row, "state_dir"),
334            network: get_opt_str(row, "network"),
335            net_ip: get_opt_str(row, "net_ip"),
336            ports: ports_from(row.get("ports")),
337            created_at: get_num(row, "created_at").unwrap_or(0),
338            finished_at: get_num(row, "finished_at"),
339            origin: get_opt_str(row, "origin"),
340        }
341    }
342
343    /// Build from a GraphQL `Machine` (the `MACHINE_FIELDS` selection).
344    pub fn from_graphql(m: &Value) -> SandboxInfo {
345        let running = get_bool(m, "running");
346        let status = {
347            let s = get_str(m, "status");
348            if s.is_empty() {
349                if running { "running" } else { "exited" }.to_string()
350            } else {
351                s
352            }
353        };
354        SandboxInfo {
355            id: get_str(m, "id"),
356            name: get_opt_str(m, "name"),
357            image: get_str(m, "image"),
358            kind: get_str(m, "kind"),
359            command: get_str(m, "command"),
360            status,
361            running,
362            exit_code: get_num(m, "exitCode").map(|v| v as i32),
363            pid: get_num(m, "pid"),
364            detached: get_bool(m, "detached"),
365            cpus: get_num(m, "cpus").unwrap_or(0) as u32,
366            mem: get_num(m, "mem").unwrap_or(0) as u64,
367            volume: get_opt_str(m, "volume"),
368            state_dir: get_str(m, "stateDir"),
369            network: get_opt_str(m, "network"),
370            net_ip: get_opt_str(m, "netIp"),
371            ports: ports_from(m.get("ports")),
372            created_at: get_num(m, "createdAt").unwrap_or(0),
373            finished_at: get_num(m, "finishedAt"),
374            origin: get_opt_str(m, "origin"),
375        }
376    }
377}
378
379fn ports_from(v: Option<&Value>) -> Vec<PortForward> {
380    v.and_then(Value::as_array)
381        .map(|rows| rows.iter().map(PortForward::from_row).collect())
382        .unwrap_or_default()
383}
384
385/// An image as reported by `bsdkrun images --json`.
386#[derive(Debug, Clone, PartialEq)]
387pub struct ImageInfo {
388    pub id: String,
389    pub reference: String,
390    pub digest: String,
391    pub size: u64,
392    pub rootfs: String,
393    pub created_at: i64,
394}
395
396impl ImageInfo {
397    pub fn from_row(row: &Value) -> ImageInfo {
398        ImageInfo {
399            id: get_str(row, "id"),
400            reference: get_str(row, "reference"),
401            digest: get_str(row, "digest"),
402            size: get_num(row, "size").unwrap_or(0) as u64,
403            rootfs: get_str(row, "rootfs"),
404            created_at: get_num(row, "created_at").unwrap_or(0),
405        }
406    }
407}
408
409/// A volume as reported by `bsdkrun volume ls --json`.
410#[derive(Debug, Clone, PartialEq)]
411pub struct VolumeInfo {
412    pub name: String,
413    pub guest: Option<String>,
414    pub base: Option<String>,
415    pub path: String,
416    pub size: String,
417    pub created_at: Option<i64>,
418    pub tracked: bool,
419}
420
421impl VolumeInfo {
422    pub fn from_row(row: &Value) -> VolumeInfo {
423        VolumeInfo {
424            name: get_str(row, "name"),
425            guest: get_opt_str(row, "guest"),
426            base: get_opt_str(row, "base"),
427            path: get_str(row, "path"),
428            size: get_str(row, "size"),
429            created_at: get_num(row, "created_at"),
430            tracked: get_bool(row, "tracked"),
431        }
432    }
433}
434
435/// A global network as reported by `bsdkrun network ls --json`.
436#[derive(Debug, Clone, PartialEq)]
437pub struct NetworkInfo {
438    pub name: String,
439    pub subnet: String,
440    pub gateway: String,
441    pub members: u32,
442    pub running: u32,
443    pub up: bool,
444    pub created_at: Option<i64>,
445}
446
447impl NetworkInfo {
448    pub fn from_row(row: &Value) -> NetworkInfo {
449        NetworkInfo {
450            name: get_str(row, "name"),
451            subnet: get_str(row, "subnet"),
452            gateway: get_str(row, "gateway"),
453            members: get_num(row, "members").unwrap_or(0) as u32,
454            running: get_num(row, "running").unwrap_or(0) as u32,
455            up: get_bool(row, "up"),
456            created_at: get_num(row, "created_at"),
457        }
458    }
459}
460
461/// The outcome of a remote lifecycle mutation (stop/start/remove/update/commit).
462///
463/// Mirrors the GraphQL `CommandResult` type. A non-zero `exit_code` is
464/// reported rather than raised: for some underlying commands (`ssh status`,
465/// `tailscale status`) it is a legitimate state to display, not a transport
466/// failure.
467#[derive(Debug, Clone, PartialEq, Eq)]
468pub struct CommandResult {
469    pub exit_code: i32,
470    pub stdout: String,
471    pub stderr: String,
472}
473
474impl CommandResult {
475    pub fn from_graphql(r: &Value) -> CommandResult {
476        CommandResult {
477            exit_code: get_num(r, "exitCode").unwrap_or(0) as i32,
478            stdout: get_str(r, "stdout"),
479            stderr: get_str(r, "stderr"),
480        }
481    }
482}
483
484/// A shell session as reported by `openShell` / `shellSessions`.
485#[derive(Debug, Clone, PartialEq, Eq)]
486pub struct ShellSessionInfo {
487    pub id: String,
488    pub machine_id: String,
489    pub finished: bool,
490    pub truncated: bool,
491}
492
493impl ShellSessionInfo {
494    pub fn from_graphql(s: &Value) -> ShellSessionInfo {
495        ShellSessionInfo {
496            id: get_str(s, "id"),
497            machine_id: get_str(s, "machineId"),
498            finished: get_bool(s, "finished"),
499            truncated: get_bool(s, "truncated"),
500        }
501    }
502}
503
504/// The captured result of running a command in a guest through the local CLI.
505///
506/// Returned by [`crate::Sandbox::exec`] and the command builder's `run()`.
507#[derive(Debug, Clone, PartialEq, Eq)]
508pub struct ExecResult {
509    pub stdout: String,
510    pub stderr: String,
511    pub exit_code: i32,
512    /// A short label for what ran, used in [`Error::CommandFailed`].
513    pub command: String,
514}
515
516impl ExecResult {
517    /// Whether the command succeeded (exit 0).
518    pub fn ok(&self) -> bool {
519        self.exit_code == 0
520    }
521
522    /// `stdout` with trailing newlines trimmed — the common case.
523    pub fn text(&self) -> &str {
524        self.stdout.trim_end_matches('\n')
525    }
526
527    /// Parse `stdout` as JSON into any deserializable type.
528    pub fn json<T: serde::de::DeserializeOwned>(&self) -> Result<T> {
529        Ok(serde_json::from_str(&self.stdout)?)
530    }
531
532    /// Non-empty `stdout` lines.
533    pub fn lines(&self) -> Vec<String> {
534        self.stdout
535            .split('\n')
536            .filter(|line| !line.is_empty())
537            .map(str::to_string)
538            .collect()
539    }
540
541    /// A non-zero exit becomes [`Error::CommandFailed`]; exit 0 passes the
542    /// result through, so it chains: `sandbox.exec([...])?.ok_or_err()?`.
543    pub fn ok_or_err(self) -> Result<ExecResult> {
544        if self.exit_code != 0 {
545            return Err(Error::CommandFailed {
546                exit_code: self.exit_code,
547                stdout: self.stdout,
548                stderr: self.stderr,
549                command: self.command,
550            });
551        }
552        Ok(self)
553    }
554}
555
556/// The captured result of [`crate::Client::exec`] against a remote daemon.
557///
558/// Unlike [`ExecResult`] (the local CLI's captured stdout/stderr as text), a
559/// remote exec's output is a single interleaved byte stream — the shell
560/// agent's `shellOutput` subscription does not distinguish stdout from
561/// stderr — so this carries raw bytes instead.
562#[derive(Debug, Clone, PartialEq, Eq)]
563pub struct RemoteExecResult {
564    pub exit_code: i32,
565    pub output: Vec<u8>,
566}
567
568impl RemoteExecResult {
569    /// Whether the command succeeded (exit 0).
570    pub fn ok(&self) -> bool {
571        self.exit_code == 0
572    }
573
574    /// The output as (lossy) UTF-8 text.
575    pub fn text(&self) -> String {
576        String::from_utf8_lossy(&self.output).into_owned()
577    }
578}
579
580#[cfg(test)]
581mod tests {
582    use super::*;
583    use serde_json::json;
584
585    #[test]
586    fn sandbox_info_from_row_running() {
587        let info = SandboxInfo::from_row(&json!({
588            "id": "abc123def456",
589            "name": null,
590            "image": "alpine",
591            "kind": "linux",
592            "command": "sleep 300",
593            "running": true,
594            "exit_code": null,
595            "pid": 4242,
596            "detached": true,
597            "cpus": 2,
598            "mem": 1024,
599            "volume": null,
600            "state_dir": "/var/lib/bsdkrun/abc123",
601            "network": "devnet",
602            "net_ip": "192.168.127.3",
603            "created_at": 1700000000i64,
604            "finished_at": null,
605        }));
606        assert_eq!(info.status, "running");
607        assert!(info.running);
608        assert_eq!(info.exit_code, None);
609        assert_eq!(info.pid, Some(4242));
610        assert_eq!(info.network.as_deref(), Some("devnet"));
611    }
612
613    #[test]
614    fn sandbox_info_from_row_exited_defaults() {
615        let info = SandboxInfo::from_row(&json!({
616            "id": "abc",
617            "image": "alpine",
618            "kind": "linux",
619            "running": false,
620            "exit_code": 0,
621            "detached": true,
622            "cpus": 1,
623            "mem": 512,
624            "state_dir": "/s",
625            "created_at": 1,
626            "finished_at": 2,
627        }));
628        assert_eq!(info.status, "exited");
629        assert_eq!(info.command, "");
630        assert_eq!(info.finished_at, Some(2));
631    }
632
633    #[test]
634    fn sandbox_info_from_graphql_maps_camel_case_and_string_timestamps() {
635        let info = SandboxInfo::from_graphql(&json!({
636            "id": "abc123def456",
637            "name": null,
638            "image": "alpine",
639            "kind": "linux",
640            "command": "sleep 300",
641            "status": "running",
642            "running": true,
643            "exitCode": null,
644            "pid": 4242,
645            "detached": true,
646            "cpus": 2,
647            "mem": 1024,
648            "volume": null,
649            "stateDir": "/var/lib/bsdkrun/abc123",
650            "network": "devnet",
651            "netIp": "192.168.127.3",
652            "ports": [{"bind": "0.0.0.0", "host": 2222, "guest": 22}],
653            "createdAt": "1700000000",
654            "finishedAt": null,
655        }));
656        assert_eq!(info.state_dir, "/var/lib/bsdkrun/abc123");
657        assert_eq!(info.net_ip.as_deref(), Some("192.168.127.3"));
658        assert_eq!(info.created_at, 1700000000);
659        assert_eq!(info.finished_at, None);
660        assert_eq!(
661            info.ports,
662            vec![PortForward {
663                host: 2222,
664                guest: 22,
665                bind: "0.0.0.0".into()
666            }]
667        );
668    }
669
670    #[test]
671    fn graphql_floats_coerce_back_to_ints() {
672        // GraphQL widens pid/mem to Float to dodge 32-bit Int overflow; the
673        // mapping must still land on plain integers.
674        let info = SandboxInfo::from_graphql(&json!({
675            "id": "abc",
676            "image": "alpine",
677            "kind": "linux",
678            "command": "",
679            "status": "exited",
680            "running": false,
681            "exitCode": 0,
682            "pid": 4242.0,
683            "detached": true,
684            "cpus": 1,
685            "mem": 512.0,
686            "stateDir": "/s",
687            "createdAt": "1",
688            "finishedAt": "2",
689        }));
690        assert_eq!(info.pid, Some(4242));
691        assert_eq!(info.mem, 512);
692        assert_eq!(info.finished_at, Some(2));
693    }
694
695    #[test]
696    fn volume_and_network_rows() {
697        let vol = VolumeInfo::from_row(&json!({
698            "name": "web", "path": "/p", "size": "1G", "tracked": true
699        }));
700        assert_eq!(vol.name, "web");
701        assert_eq!(vol.guest, None);
702        assert_eq!(vol.created_at, None);
703
704        let net = NetworkInfo::from_row(&json!({
705            "name": "devnet",
706            "subnet": "192.168.127.0/24",
707            "gateway": "192.168.127.1",
708            "members": 2,
709            "running": 1,
710            "up": true,
711        }));
712        assert_eq!(net.members, 2);
713        assert!(net.up);
714    }
715
716    #[test]
717    fn command_result_defaults_sanely() {
718        let full = CommandResult::from_graphql(&json!({
719            "exitCode": 1, "stdout": "out", "stderr": "err"
720        }));
721        assert_eq!((full.exit_code, full.stdout.as_str()), (1, "out"));
722
723        let empty = CommandResult::from_graphql(&json!({}));
724        assert_eq!(empty.exit_code, 0);
725        assert_eq!(empty.stdout, "");
726    }
727
728    #[test]
729    fn shell_session_info_from_graphql() {
730        let s = ShellSessionInfo::from_graphql(&json!({
731            "id": "sess-1", "machineId": "abc123", "finished": false, "truncated": true
732        }));
733        assert_eq!(s.id, "sess-1");
734        assert_eq!(s.machine_id, "abc123");
735        assert!(!s.finished);
736        assert!(s.truncated);
737    }
738
739    #[test]
740    fn exec_result_helpers() {
741        let ok = ExecResult {
742            stdout: "hello\n\n".into(),
743            stderr: String::new(),
744            exit_code: 0,
745            command: "echo".into(),
746        };
747        assert!(ok.ok());
748        assert_eq!(ok.text(), "hello");
749        assert_eq!(ok.lines(), vec!["hello".to_string()]);
750        assert!(ok.ok_or_err().is_ok());
751
752        let failed = ExecResult {
753            stdout: String::new(),
754            stderr: "boom".into(),
755            exit_code: 1,
756            command: "false".into(),
757        };
758        assert!(!failed.ok());
759        assert!(matches!(
760            failed.ok_or_err(),
761            Err(Error::CommandFailed { exit_code: 1, .. })
762        ));
763    }
764
765    #[test]
766    fn exec_result_json_parses_stdout() {
767        let r = ExecResult {
768            stdout: "{\"a\": 1}".into(),
769            stderr: String::new(),
770            exit_code: 0,
771            command: "cat".into(),
772        };
773        let v: serde_json::Value = r.json().unwrap();
774        assert_eq!(v["a"], 1);
775    }
776}