Skip to main content

ssh_cli/json_wire/
emit.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// G-COMP: compact JSON emit primitives + envelopes (extracted from json_wire monolith).
3#![forbid(unsafe_code)]
4//! Compact JSON + LF writers and agent error/success envelopes.
5
6use serde::{Deserialize, Serialize};
7use std::collections::BTreeMap;
8use std::io::{self, Write};
9
10/// UTF-8 BOM character; stripped before parsing external JSON.
11pub const UTF8_BOM: char = '\u{feff}';
12
13/// Strips a leading UTF-8 BOM if present (Rules: remove BOM before parse).
14///
15/// # Examples
16///
17/// ```
18/// use ssh_cli::json_wire::{strip_utf8_bom, UTF8_BOM};
19///
20/// assert_eq!(strip_utf8_bom("{\"ok\":true}"), "{\"ok\":true}");
21/// let with_bom = format!("{UTF8_BOM}{{\"ok\":true}}");
22/// assert_eq!(strip_utf8_bom(&with_bom), "{\"ok\":true}");
23/// ```
24#[must_use]
25pub fn strip_utf8_bom(s: &str) -> &str {
26    s.strip_prefix(UTF8_BOM).unwrap_or(s)
27}
28
29/// Serializes `value` as **compact** JSON + trailing LF on the given writer.
30///
31/// DI primitive (G-IO-11): pass a `Cursor`/`Vec` in tests; production uses
32/// process stdout/stderr via [`print_json_line`] / [`print_json_line_stderr`].
33///
34/// # Examples
35///
36/// ```
37/// use ssh_cli::json_wire::write_json_line;
38/// use serde_json::json;
39/// use std::io::Cursor;
40///
41/// let mut buf = Cursor::new(Vec::new());
42/// write_json_line(&mut buf, &json!({"ok": true})).unwrap();
43/// let s = String::from_utf8(buf.into_inner()).unwrap();
44/// assert_eq!(s, "{\"ok\":true}\n");
45/// assert!(!s.contains('\r'));
46/// ```
47///
48/// # Errors
49/// Serialization failure or I/O (including `BrokenPipe`).
50pub fn write_json_line<W: Write, T: Serialize>(mut w: W, value: &T) -> io::Result<()> {
51    let s = serde_json::to_string(value).map_err(io::Error::other)?;
52    w.write_all(s.as_bytes())?;
53    w.write_all(b"\n")?;
54    w.flush()?;
55    Ok(())
56}
57
58/// Compact JSON + LF on stdout (agent success / data path).
59///
60/// This is the **single funnel** every structured payload passes through, which is why
61/// agent-native shaping ([`crate::agent_shape`]) is applied here rather than at each of
62/// the fifteen call sites. Reducing before serialization is the whole point: shaping
63/// downstream with `jaq` would mean the oversized envelope was already built and
64/// written, so the tokens were already spent.
65///
66/// When no shaping flag was passed, [`crate::agent_shape::is_active`] short-circuits and
67/// the value is serialized directly — the default path pays no `to_value` round-trip.
68///
69/// # Errors
70/// Serialization or stdout I/O (including `BrokenPipe` → exit 141).
71pub fn print_json_line<T: Serialize>(value: &T) -> io::Result<()> {
72    let stdout = io::stdout();
73    let mut handle = stdout.lock();
74
75    let Some(cfg) = crate::agent_shape::current() else {
76        return write_json_line(&mut handle, value);
77    };
78    let mut shaped = serde_json::to_value(value).map_err(io::Error::other)?;
79    crate::agent_shape::apply(&mut shaped, &cfg);
80    write_json_line(&mut handle, &shaped)
81}
82
83/// Compact JSON + LF on stderr; BrokenPipe is ignored (downstream closed).
84///
85/// # Errors
86/// Non-pipe stderr write failures.
87pub fn print_json_line_stderr<T: Serialize>(value: &T) -> io::Result<()> {
88    let stderr = io::stderr();
89    let mut handle = stderr.lock();
90    match write_json_line(&mut handle, value) {
91        Ok(()) => Ok(()),
92        Err(e) if e.kind() == io::ErrorKind::BrokenPipe => Ok(()),
93        Err(e) => Err(e),
94    }
95}
96
97// ---------------------------------------------------------------------------
98// Error envelope (stderr)
99// ---------------------------------------------------------------------------
100
101/// Stderr failure envelope when JSON errors mode is active.
102///
103/// Agents must read `retryable` / `error_class` before re-invoking (Rules Rust —
104/// retry/backoff). Historical clients that only inspect `exit_code` remain valid
105/// (`additionalProperties` / unknown-field ignore on the consumer side).
106#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
107pub struct ErrorEnvelope {
108    /// Process exit code (sysexits-inspired).
109    pub exit_code: i32,
110    /// Stable machine code (`vps_not_found`, `tls`, …) — G-ERR-08.
111    #[serde(default)]
112    pub error_code: String,
113    /// Human-readable message (may be localized).
114    pub message: String,
115    /// Optional remote shell exit when process exit is general failure.
116    #[serde(skip_serializing_if = "Option::is_none", default)]
117    pub remote_exit_code: Option<i32>,
118    /// High-level class (`transient` | `permanent` | `cancelled`).
119    pub error_class: crate::errors::ErrorClass,
120    /// Whether an agent may re-invoke with the same argv after backoff.
121    pub retryable: bool,
122    /// Optional short remediation hint for agents.
123    #[serde(skip_serializing_if = "Option::is_none", default)]
124    pub suggestion: Option<String>,
125    /// Host this process had resolved when the failure occurred (canonical name).
126    ///
127    /// GAP-SSH-EXEC-ENVELOPE-002 asks for the audit fields on the error path because
128    /// the failing step is where the target matters most: a step-zero failure on the
129    /// wrong machine and one on the right machine were byte-identical envelopes.
130    ///
131    /// Absent — not empty — when the failure predates target resolution, which is
132    /// most usage errors. `None` says "no host was chosen"; `""` would assert that a
133    /// host was chosen and is nameless. Only one of those is true.
134    #[serde(skip_serializing_if = "Option::is_none", default)]
135    pub target_resolved: Option<String>,
136    /// How [`Self::target_resolved`] was obtained (canonical name).
137    #[serde(skip_serializing_if = "Option::is_none", default)]
138    pub target_source: Option<crate::json_wire::TargetSource>,
139    /// Compatibility alias of [`Self::target_resolved`] (0.5.5 spelling).
140    ///
141    /// Written from the same value, never independently — see
142    /// [`crate::json_wire::TargetEcho`] for why both spellings ship.
143    #[serde(skip_serializing_if = "Option::is_none", default)]
144    pub host_resolved: Option<String>,
145    /// Compatibility alias of [`Self::target_source`] (0.5.5 spelling).
146    #[serde(skip_serializing_if = "Option::is_none", default)]
147    pub host_source: Option<crate::json_wire::TargetSource>,
148    /// Whether that host came from the active marker instead of argv.
149    #[serde(skip_serializing_if = "Option::is_none", default)]
150    pub active_fallback: Option<bool>,
151}
152
153// ---------------------------------------------------------------------------
154// Success envelope (stdout)
155// ---------------------------------------------------------------------------
156
157/// Agent-first success envelope: `{ "ok": true, "event": …, …fields }`.
158///
159/// Extra fields are merged from a map so callers can attach event-specific keys
160/// without proliferating one struct per CRUD event.
161#[derive(Debug, Clone, Serialize, Deserialize)]
162pub struct SuccessEnvelope {
163    /// Always `true` for success envelopes.
164    pub ok: bool,
165    /// Event discriminator (`vps-added`, `scp-transfer`, …).
166    pub event: String,
167    /// Additional event fields (flattened at serialize time via map merge).
168    #[serde(flatten)]
169    pub fields: BTreeMap<String, serde_json::Value>,
170}
171
172impl SuccessEnvelope {
173    /// Builds a success envelope from an event name and extra fields.
174    #[must_use]
175    pub fn new(event: impl Into<String>, fields: BTreeMap<String, serde_json::Value>) -> Self {
176        Self {
177            ok: true,
178            event: event.into(),
179            fields,
180        }
181    }
182
183    /// Builds from a `serde_json::Value` object (or wraps non-objects under `data`).
184    #[must_use]
185    pub fn from_value(event: &str, fields: serde_json::Value) -> Self {
186        let mut map = BTreeMap::new();
187        match fields {
188            serde_json::Value::Object(obj) => {
189                for (k, v) in obj {
190                    map.insert(k, v);
191                }
192            }
193            other => {
194                map.insert("data".into(), other);
195            }
196        }
197        Self::new(event, map)
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204
205    /// The agent wire is one line per event, failures included.
206    ///
207    /// Lives here rather than in `vps_export.rs`, where it used to sit: the subject is
208    /// [`ErrorEnvelope`], and a test two modules away from its subject is a test nobody
209    /// updates when the subject changes.
210    #[test]
211    fn compact_json_is_single_line() {
212        let env = ErrorEnvelope {
213            exit_code: 65,
214            error_code: "invalid_argument".into(),
215            message: "bad".into(),
216            remote_exit_code: None,
217            error_class: crate::errors::ErrorClass::Permanent,
218            retryable: false,
219            suggestion: None,
220            target_resolved: None,
221            target_source: None,
222            host_resolved: None,
223            host_source: None,
224            active_fallback: None,
225        };
226        let s = serde_json::to_string(&env).expect("envelope serializes");
227        assert!(!s.contains('\n'), "agent wire must be compact: {s}");
228        assert!(s.starts_with('{'));
229        assert!(s.contains("\"exit_code\":65"));
230    }
231
232    /// A failure that resolved no target must not claim one.
233    ///
234    /// Absent is the honest answer; an empty string would assert that a host was
235    /// chosen and has no name, and only one of those is true.
236    #[test]
237    fn an_unresolved_target_is_absent_rather_than_empty() {
238        let env = ErrorEnvelope {
239            exit_code: 66,
240            error_code: "vps_not_found".into(),
241            message: "no such host".into(),
242            remote_exit_code: None,
243            error_class: crate::errors::ErrorClass::Permanent,
244            retryable: false,
245            suggestion: None,
246            target_resolved: None,
247            target_source: None,
248            host_resolved: None,
249            host_source: None,
250            active_fallback: None,
251        };
252        let s = serde_json::to_string(&env).expect("envelope serializes");
253        assert!(!s.contains("target_resolved"), "{s}");
254        assert!(!s.contains("host_resolved"), "{s}");
255    }
256}