#![forbid(unsafe_code)]
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::io::{self, Write};
pub const UTF8_BOM: char = '\u{feff}';
#[must_use]
pub fn strip_utf8_bom(s: &str) -> &str {
s.strip_prefix(UTF8_BOM).unwrap_or(s)
}
pub fn write_json_line<W: Write, T: Serialize>(mut w: W, value: &T) -> io::Result<()> {
let s = serde_json::to_string(value).map_err(io::Error::other)?;
w.write_all(s.as_bytes())?;
w.write_all(b"\n")?;
w.flush()?;
Ok(())
}
pub fn print_json_line<T: Serialize>(value: &T) -> io::Result<()> {
let stdout = io::stdout();
let mut handle = stdout.lock();
let Some(cfg) = crate::agent_shape::current() else {
return write_json_line(&mut handle, value);
};
let mut shaped = serde_json::to_value(value).map_err(io::Error::other)?;
crate::agent_shape::apply(&mut shaped, &cfg);
write_json_line(&mut handle, &shaped)
}
pub fn print_json_line_stderr<T: Serialize>(value: &T) -> io::Result<()> {
let stderr = io::stderr();
let mut handle = stderr.lock();
match write_json_line(&mut handle, value) {
Ok(()) => Ok(()),
Err(e) if e.kind() == io::ErrorKind::BrokenPipe => Ok(()),
Err(e) => Err(e),
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ErrorEnvelope {
pub exit_code: i32,
#[serde(default)]
pub error_code: String,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub remote_exit_code: Option<i32>,
pub error_class: crate::errors::ErrorClass,
pub retryable: bool,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub suggestion: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub target_resolved: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub target_source: Option<crate::json_wire::TargetSource>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub host_resolved: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub host_source: Option<crate::json_wire::TargetSource>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub active_fallback: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SuccessEnvelope {
pub ok: bool,
pub event: String,
#[serde(flatten)]
pub fields: BTreeMap<String, serde_json::Value>,
}
impl SuccessEnvelope {
#[must_use]
pub fn new(event: impl Into<String>, fields: BTreeMap<String, serde_json::Value>) -> Self {
Self {
ok: true,
event: event.into(),
fields,
}
}
#[must_use]
pub fn from_value(event: &str, fields: serde_json::Value) -> Self {
let mut map = BTreeMap::new();
match fields {
serde_json::Value::Object(obj) => {
for (k, v) in obj {
map.insert(k, v);
}
}
other => {
map.insert("data".into(), other);
}
}
Self::new(event, map)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn compact_json_is_single_line() {
let env = ErrorEnvelope {
exit_code: 65,
error_code: "invalid_argument".into(),
message: "bad".into(),
remote_exit_code: None,
error_class: crate::errors::ErrorClass::Permanent,
retryable: false,
suggestion: None,
target_resolved: None,
target_source: None,
host_resolved: None,
host_source: None,
active_fallback: None,
};
let s = serde_json::to_string(&env).expect("envelope serializes");
assert!(!s.contains('\n'), "agent wire must be compact: {s}");
assert!(s.starts_with('{'));
assert!(s.contains("\"exit_code\":65"));
}
#[test]
fn an_unresolved_target_is_absent_rather_than_empty() {
let env = ErrorEnvelope {
exit_code: 66,
error_code: "vps_not_found".into(),
message: "no such host".into(),
remote_exit_code: None,
error_class: crate::errors::ErrorClass::Permanent,
retryable: false,
suggestion: None,
target_resolved: None,
target_source: None,
host_resolved: None,
host_source: None,
active_fallback: None,
};
let s = serde_json::to_string(&env).expect("envelope serializes");
assert!(!s.contains("target_resolved"), "{s}");
assert!(!s.contains("host_resolved"), "{s}");
}
}