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 monólito).
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}
126
127// ---------------------------------------------------------------------------
128// Success envelope (stdout)
129// ---------------------------------------------------------------------------
130
131/// Agent-first success envelope: `{ "ok": true, "event": …, …fields }`.
132///
133/// Extra fields are merged from a map so callers can attach event-specific keys
134/// without proliferating one struct per CRUD event.
135#[derive(Debug, Clone, Serialize, Deserialize)]
136pub struct SuccessEnvelope {
137 /// Always `true` for success envelopes.
138 pub ok: bool,
139 /// Event discriminator (`vps-added`, `scp-transfer`, …).
140 pub event: String,
141 /// Additional event fields (flattened at serialize time via map merge).
142 #[serde(flatten)]
143 pub fields: BTreeMap<String, serde_json::Value>,
144}
145
146impl SuccessEnvelope {
147 /// Builds a success envelope from an event name and extra fields.
148 #[must_use]
149 pub fn new(event: impl Into<String>, fields: BTreeMap<String, serde_json::Value>) -> Self {
150 Self {
151 ok: true,
152 event: event.into(),
153 fields,
154 }
155 }
156
157 /// Builds from a `serde_json::Value` object (or wraps non-objects under `data`).
158 #[must_use]
159 pub fn from_value(event: &str, fields: serde_json::Value) -> Self {
160 let mut map = BTreeMap::new();
161 match fields {
162 serde_json::Value::Object(obj) => {
163 for (k, v) in obj {
164 map.insert(k, v);
165 }
166 }
167 other => {
168 map.insert("data".into(), other);
169 }
170 }
171 Self::new(event, map)
172 }
173}