Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
271 changes: 271 additions & 0 deletions crates/cli/src/agent_io.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
//! Agent-mode I/O: JSONL emit for direct print sites under NO_DNA.
//!
//! Surfpool has two ways of producing user-visible output:
//!
//! 1. **Logger calls** (`info!`/`warn!`/`error!`) — flow through `fern` and
//! are formatted by the dispatch's format closure (see [`crate::cli::setup_logger`]).
//! 2. **Direct prints** (`println!`/`eprintln!`) — bypass the logger entirely.
//!
//! Under NO_DNA, both paths must produce the same JSONL contract.
//! The logger path is handled by the format closure; this module
//! handles direct prints via [`cli_emit`] and the `cli_info!`/`cli_warn!`/
//! `cli_error!` macros.
//!
//! The JSON shape is `{"ts","level","target","msg"}`
//! - `ts` — RFC3339 UTC milliseconds
//! - `level` — lowercase string (`"trace"`/`"debug"`/`"info"`/`"warn"`/`"error"`)
//! - `target` — pseudo-event tag (caller-provided, e.g. `"surfpool.update.download"`)
//! - `msg` — `serde_json`-escaped string of the original human text
//!
//! Outside NO_DNA, `cli_emit` routes to `println!` (info/debug/trace) or
//! `eprintln!` (warn/error) — i.e. the legacy split human callers expect.

use chrono::{DateTime, SecondsFormat, Utc};

use crate::no_dna::{AgentMode, OutputFormat};

/// Format an agent-mode JSON record at the given timestamp.
pub fn format_agent_json_at(ts: DateTime<Utc>, level: &str, target: &str, msg: &str) -> String {
format_agent_json_with_data_at(ts, level, target, msg, None)
}

/// Like [`format_agent_json_at`] but optionally includes a `data` object for
/// structured payloads (used by `surfpool ls` to attach runbook metadata).
///
/// `msg` is passed through [`strip_ansi`] before serialization
/// (defense-in-depth against any caller that leaks color escapes into a CLI
/// emit; the primary line of defense is V38 in macros.rs).
pub fn format_agent_json_with_data_at(
ts: DateTime<Utc>,
level: &str,
target: &str,
msg: &str,
data: Option<serde_json::Value>,
) -> String {
let mut obj = serde_json::Map::with_capacity(5);
obj.insert(
"ts".into(),
serde_json::Value::String(ts.to_rfc3339_opts(SecondsFormat::Millis, true)),
);
obj.insert("level".into(), serde_json::Value::String(level.to_string()));
obj.insert(
"target".into(),
serde_json::Value::String(target.to_string()),
);
obj.insert("msg".into(), serde_json::Value::String(strip_ansi(msg)));
if let Some(d) = data {
obj.insert("data".into(), d);
}
serde_json::Value::Object(obj).to_string()
}

/// Strip ANSI CSI escape sequences (e.g., color codes like `\x1b[35m`) from a
/// string. Used by [`format_agent_json_with_data_at`] to ensure no ANSI bytes
/// land in the JSON `msg` field even if a caller accidentally leaks them
fn strip_ansi(s: &str) -> String {
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == 0x1b {
if i + 1 < bytes.len() && bytes[i + 1] == b'[' {
// CSI: ESC [ params* final-byte (ASCII letter)
i += 2;
while i < bytes.len() && !bytes[i].is_ascii_alphabetic() {
i += 1;
}
if i < bytes.len() {
i += 1;
}
continue;
}
// Other ESC sequence — skip the ESC byte itself.
i += 1;
continue;
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8(out).unwrap_or_else(|_| s.to_string())
}

/// Write a single JSONL line to stderr in agent-mode shape.
///
/// Bypasses the `log` crate so this works even before `setup_logger` has
/// installed the fern dispatch.
pub fn agent_emit(level: &str, target: &str, msg: &str) {
eprintln!("{}", format_agent_json_at(Utc::now(), level, target, msg));
}

/// Like [`agent_emit`] but attaches a `data` payload. Used by `surfpool ls`
/// to emit structured runbook records.
pub fn agent_emit_with_data(level: &str, target: &str, msg: &str, data: serde_json::Value) {
eprintln!(
"{}",
format_agent_json_with_data_at(Utc::now(), level, target, msg, Some(data))
);
}

/// Emit a CLI line, branching on agent mode.
///
/// Under NO_DNA: JSON to stderr via [`agent_emit`].
/// Outside NO_DNA: plain text — `stderr` with a `Warning: ` / `Error: `
/// prefix for `warn`/`error`, `stdout` for everything else. The prefix
/// preserves the legacy human-mode look from the `eprintln!("Warning: ...")`
/// sites this macro replaces; call sites pass the bare message
/// without a level prefix.
pub fn cli_emit(level: &str, target: &str, msg: &str) {
let mode = AgentMode::from_env();
if mode.output_format == OutputFormat::Json {
agent_emit(level, target, msg);
} else {
match level {
"error" => eprintln!("Error: {msg}"),
"warn" => eprintln!("Warning: {msg}"),
_ => println!("{msg}"),
}
}
}

/// `trace`-level direct emit. See [`cli_emit`].
#[macro_export]
macro_rules! cli_trace {
($target:literal, $($arg:tt)*) => {
$crate::agent_io::cli_emit("trace", $target, &format!($($arg)*))
};
}

/// `debug`-level direct emit. See [`cli_emit`].
#[macro_export]
macro_rules! cli_debug {
($target:literal, $($arg:tt)*) => {
$crate::agent_io::cli_emit("debug", $target, &format!($($arg)*))
};
}

/// `info`-level direct emit. See [`cli_emit`].
#[macro_export]
macro_rules! cli_info {
($target:literal, $($arg:tt)*) => {
$crate::agent_io::cli_emit("info", $target, &format!($($arg)*))
};
}

/// `warn`-level direct emit. See [`cli_emit`].
#[macro_export]
macro_rules! cli_warn {
($target:literal, $($arg:tt)*) => {
$crate::agent_io::cli_emit("warn", $target, &format!($($arg)*))
};
}

/// `error`-level direct emit. See [`cli_emit`].
#[macro_export]
macro_rules! cli_error {
($target:literal, $($arg:tt)*) => {
$crate::agent_io::cli_emit("error", $target, &format!($($arg)*))
};
}

#[cfg(test)]
mod tests {
use chrono::TimeZone;

use super::*;

// agent_emit output parses as JSON via serde_json;
// all required fields present; msg escaped.
#[test]
fn format_agent_json_is_valid_json() {
let ts = Utc.with_ymd_and_hms(2026, 6, 4, 17, 30, 0).unwrap();
let s = format_agent_json_at(ts, "info", "surfpool.test", "hello world");
let parsed: serde_json::Value = serde_json::from_str(&s).expect("must be valid JSON");
assert_eq!(parsed["level"], "info");
assert_eq!(parsed["target"], "surfpool.test");
assert_eq!(parsed["msg"], "hello world");
assert!(parsed["ts"].is_string());
}

#[test]
fn format_agent_json_escapes_msg() {
let ts = Utc.with_ymd_and_hms(2026, 6, 4, 17, 30, 0).unwrap();
let s = format_agent_json_at(ts, "error", "surfpool.test", "line1\nline2 \"quoted\"");
let parsed: serde_json::Value = serde_json::from_str(&s).expect("must be valid JSON");
assert_eq!(parsed["msg"], "line1\nline2 \"quoted\"");
// Raw string must NOT contain bare newlines or unescaped quotes.
assert!(!s.contains("line1\nline2"), "newlines must be escaped");
}

#[test]
fn timestamp_is_rfc3339_utc_with_z_suffix() {
let ts = Utc.with_ymd_and_hms(2026, 6, 4, 17, 30, 0).unwrap();
let s = format_agent_json_at(ts, "info", "t", "m");
let parsed: serde_json::Value = serde_json::from_str(&s).unwrap();
let ts_str = parsed["ts"].as_str().unwrap();
assert!(
ts_str.ends_with('Z'),
"RFC3339 UTC must end with Z, got {ts_str}"
);
assert!(
ts_str.starts_with("2026-06-04T17:30:00"),
"expected fixed timestamp, got {ts_str}"
);
// Re-parse to confirm round-trip
let _: DateTime<Utc> = ts_str.parse().expect("must round-trip parse");
}

#[test]
fn agent_json_contains_no_ansi_escapes() {
let ts = Utc.with_ymd_and_hms(2026, 6, 4, 17, 30, 0).unwrap();
// Pass a message that COULD contain colors if we accidentally let them through.
let s = format_agent_json_at(ts, "warn", "surfpool.test", "plain msg");
assert!(
!s.contains('\x1b'),
"JSON output must contain no ANSI ESC byte (0x1b)"
);
}

// Defensive — strip ANSI escapes from `msg` so a leaky
// caller (color macros, third-party libs) cannot pollute the JSON shape.
#[test]
fn strip_ansi_removes_csi_sequences() {
assert_eq!(super::strip_ansi("\x1b[35mfoo\x1b[0m"), "foo");
assert_eq!(super::strip_ansi("plain"), "plain");
assert_eq!(
super::strip_ansi("\x1b[1;31merror:\x1b[0m hello"),
"error: hello"
);
assert_eq!(super::strip_ansi(""), "");
}

#[test]
fn format_agent_json_strips_ansi_in_msg() {
let ts = Utc.with_ymd_and_hms(2026, 6, 4, 17, 30, 0).unwrap();
// Simulate what would happen if a caller leaked ANSI bytes into msg.
let s = format_agent_json_at(
ts,
"info",
"surfpool.test",
"\x1b[35m→\x1b[0m \x1b[35mdeploy\x1b[0m - done",
);
let parsed: serde_json::Value = serde_json::from_str(&s).expect("must be valid JSON");
let msg = parsed["msg"].as_str().unwrap();
assert!(
!msg.contains('\x1b'),
"msg field must not contain ANSI ESC after strip_ansi: {msg:?}"
);
assert!(
!msg.contains("[35m"),
"CSI params must be stripped: {msg:?}"
);
assert_eq!(msg, "→ deploy - done");
}

#[test]
fn format_agent_json_handles_unicode_in_msg() {
let ts = Utc.with_ymd_and_hms(2026, 6, 4, 17, 30, 0).unwrap();
let s = format_agent_json_at(ts, "info", "t", "✓ done — 日本語");
let parsed: serde_json::Value = serde_json::from_str(&s).expect("must be valid JSON");
assert_eq!(parsed["msg"], "✓ done — 日本語");
}
}
Loading
Loading