Skip to main content

browser_automation_cli/
envelope.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! JSON envelope helpers (`schema_version = 1`).
3//!
4//! Success and error envelopes are written to **stdout** for agent parsing.
5//! Human diagnostics stay on stderr.
6//!
7//! Wire format is a **single compact JSON object per line** (RFC 8259; CLI
8//! semantics of `application/json`). Unknown fields on **input** follow
9//! Must-Ignore at the clap boundary; envelopes themselves are fully typed
10//! on serialize.
11//!
12//! # Success shape
13//!
14//! ```json
15//! {"schema_version":1,"ok":true,"data":{}}
16//! ```
17//!
18//! # Error shape
19//!
20//! ```json
21//! {"schema_version":1,"ok":false,"error":{"kind":"unavailable","message":"...","exit_code":69}}
22//! ```
23
24use serde::Serialize;
25use serde_json::Value;
26
27use crate::error::CliError;
28use crate::output;
29
30/// Success envelope (`ok: true`) — typed wire contract for agents.
31#[derive(Debug, Serialize)]
32pub struct SuccessEnvelope {
33    /// Envelope schema version (currently `1`).
34    pub schema_version: u32,
35    /// Always `true` for this shape.
36    pub ok: bool,
37    /// Command-specific payload (dynamic by design at the CLI boundary).
38    pub data: Value,
39}
40
41/// Error object nested under an error envelope.
42#[derive(Debug, Serialize)]
43pub struct ErrorBody {
44    /// Machine-stable error kind (`unavailable`, `data`, …).
45    pub kind: String,
46    /// Human/agent message (English technical).
47    pub message: String,
48    /// Sysexits-style process exit code.
49    pub exit_code: u8,
50    /// Optional recovery hint.
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub suggestion: Option<String>,
53}
54
55/// Error envelope (`ok: false`) — typed wire contract for agents.
56#[derive(Debug, Serialize)]
57pub struct ErrorEnvelope {
58    /// Envelope schema version (currently `1`).
59    pub schema_version: u32,
60    /// Always `false` for this shape.
61    pub ok: bool,
62    /// Structured error.
63    pub error: ErrorBody,
64    /// Optional partial data (e.g. fail-fast `run` steps already completed).
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub data: Option<Value>,
67}
68
69/// Print a success envelope with arbitrary JSON `data` and flush a single line.
70pub fn print_success_json(data: Value) -> Result<(), CliError> {
71    let env = SuccessEnvelope {
72        schema_version: 1,
73        ok: true,
74        data,
75    };
76    output::write_json_line_ser(&env)
77}
78
79/// Print an error envelope derived from [`CliError`].
80pub fn print_error_json(err: &CliError) -> Result<(), CliError> {
81    print_error_json_with_data(err, err.data().cloned())
82}
83
84/// Print an error envelope with optional partial `data` (e.g. fail-fast `run` steps).
85pub fn print_error_json_with_data(err: &CliError, data: Option<Value>) -> Result<(), CliError> {
86    let env = ErrorEnvelope {
87        schema_version: 1,
88        ok: false,
89        error: ErrorBody {
90            kind: err.kind().as_str().to_string(),
91            message: err.message().to_string(),
92            exit_code: err.exit_code(),
93            suggestion: err.suggestion().map(|s| s.to_string()),
94        },
95        data,
96    };
97    output::write_json_line_ser(&env)
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103    use crate::error::ErrorKind;
104    use serde_json::json;
105
106    #[test]
107    fn success_envelope_roundtrip_shape() {
108        let env = SuccessEnvelope {
109            schema_version: 1,
110            ok: true,
111            data: json!({"x": 1}),
112        };
113        let s = crate::json_util::to_compact_string(&env).unwrap();
114        let v: Value = crate::json_util::from_str(&s).unwrap();
115        assert_eq!(v["schema_version"], 1);
116        assert_eq!(v["ok"], true);
117        assert_eq!(v["data"]["x"], 1);
118        assert!(!s.contains('\n'));
119    }
120
121    #[test]
122    fn error_envelope_omits_empty_suggestion() {
123        let err = CliError::new(ErrorKind::Data, "bad");
124        let env = ErrorEnvelope {
125            schema_version: 1,
126            ok: false,
127            error: ErrorBody {
128                kind: err.kind().as_str().to_string(),
129                message: err.message().to_string(),
130                exit_code: err.exit_code(),
131                suggestion: None,
132            },
133            data: None,
134        };
135        let s = crate::json_util::to_compact_string(&env).unwrap();
136        let v: Value = crate::json_util::from_str(&s).unwrap();
137        assert!(v.get("data").is_none());
138        assert!(v["error"].get("suggestion").is_none());
139    }
140}