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 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/// # Errors
61/// Serialization or stdout I/O (including `BrokenPipe` → exit 141).
62pub fn print_json_line<T: Serialize>(value: &T) -> io::Result<()> {
63    let stdout = io::stdout();
64    let mut handle = stdout.lock();
65    write_json_line(&mut handle, value)
66}
67
68/// Compact JSON + LF on stderr; BrokenPipe is ignored (downstream closed).
69///
70/// # Errors
71/// Non-pipe stderr write failures.
72pub fn print_json_line_stderr<T: Serialize>(value: &T) -> io::Result<()> {
73    let stderr = io::stderr();
74    let mut handle = stderr.lock();
75    match write_json_line(&mut handle, value) {
76        Ok(()) => Ok(()),
77        Err(e) if e.kind() == io::ErrorKind::BrokenPipe => Ok(()),
78        Err(e) => Err(e),
79    }
80}
81
82// ---------------------------------------------------------------------------
83// Error envelope (stderr)
84// ---------------------------------------------------------------------------
85
86/// Stderr failure envelope when JSON errors mode is active.
87///
88/// Agents must read `retryable` / `error_class` before re-invoking (Rules Rust —
89/// retry/backoff). Historical clients that only inspect `exit_code` remain valid
90/// (`additionalProperties` / unknown-field ignore on the consumer side).
91#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
92pub struct ErrorEnvelope {
93    /// Process exit code (sysexits-inspired).
94    pub exit_code: i32,
95    /// Stable machine code (`vps_not_found`, `tls`, …) — G-ERR-08.
96    #[serde(default)]
97    pub error_code: String,
98    /// Human-readable message (may be localized).
99    pub message: String,
100    /// Optional remote shell exit when process exit is general failure.
101    #[serde(skip_serializing_if = "Option::is_none", default)]
102    pub remote_exit_code: Option<i32>,
103    /// High-level class (`transient` | `permanent` | `cancelled`).
104    pub error_class: crate::errors::ErrorClass,
105    /// Whether an agent may re-invoke with the same argv after backoff.
106    pub retryable: bool,
107    /// Optional short remediation hint for agents.
108    #[serde(skip_serializing_if = "Option::is_none", default)]
109    pub suggestion: Option<String>,
110}
111
112// ---------------------------------------------------------------------------
113// Success envelope (stdout)
114// ---------------------------------------------------------------------------
115
116/// Agent-first success envelope: `{ "ok": true, "event": …, …fields }`.
117///
118/// Extra fields are merged from a map so callers can attach event-specific keys
119/// without proliferating one struct per CRUD event.
120#[derive(Debug, Clone, Serialize, Deserialize)]
121pub struct SuccessEnvelope {
122    /// Always `true` for success envelopes.
123    pub ok: bool,
124    /// Event discriminator (`vps-added`, `scp-transfer`, …).
125    pub event: String,
126    /// Additional event fields (flattened at serialize time via map merge).
127    #[serde(flatten)]
128    pub fields: BTreeMap<String, serde_json::Value>,
129}
130
131impl SuccessEnvelope {
132    /// Builds a success envelope from an event name and extra fields.
133    #[must_use]
134    pub fn new(event: impl Into<String>, fields: BTreeMap<String, serde_json::Value>) -> Self {
135        Self {
136            ok: true,
137            event: event.into(),
138            fields,
139        }
140    }
141
142    /// Builds from a `serde_json::Value` object (or wraps non-objects under `data`).
143    #[must_use]
144    pub fn from_value(event: &str, fields: serde_json::Value) -> Self {
145        let mut map = BTreeMap::new();
146        match fields {
147            serde_json::Value::Object(obj) => {
148                for (k, v) in obj {
149                    map.insert(k, v);
150                }
151            }
152            other => {
153                map.insert("data".into(), other);
154            }
155        }
156        Self::new(event, map)
157    }
158}