use serde::Serialize;
use serde_json::Value;
#[derive(Debug, Default, Clone, Serialize)]
pub struct DaemonMeta {
#[serde(skip_serializing_if = "Option::is_none")]
pub active_debug_session: Option<ActiveSessionEcho>,
#[serde(skip_serializing_if = "Option::is_none")]
pub next_actions: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub hint: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ActiveSessionEcho {
pub id: String,
pub project_slug: String,
pub started_at_ns: u64,
}
#[derive(Debug, Clone, Serialize)]
pub struct EnvelopeError {
pub code: String,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub hint: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub fix: Option<EnvelopeFix>,
}
#[derive(Debug, Clone, Serialize)]
pub struct EnvelopeFix {
pub tool: String,
}
#[derive(Debug, Serialize)]
pub struct Envelope {
pub result: Value,
pub daemon8: DaemonMeta,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<EnvelopeError>,
}
impl Envelope {
pub fn ok(result: Value, meta: DaemonMeta) -> Self {
Self {
result,
daemon8: meta,
error: None,
}
}
pub fn err(
code: impl Into<String>,
message: impl Into<String>,
hint: Option<&str>,
fix_tool: Option<&str>,
meta: DaemonMeta,
) -> Self {
Self {
result: Value::Null,
daemon8: meta,
error: Some(EnvelopeError {
code: code.into(),
message: message.into(),
hint: hint.map(String::from),
fix: fix_tool.map(|t| EnvelopeFix { tool: t.into() }),
}),
}
}
pub fn render(self) -> String {
serde_json::to_string_pretty(&self)
.unwrap_or_else(|e| format!("{{\"error\":\"envelope serialization failed: {e}\"}}"))
}
}
pub fn ok_value(result: Value, meta: DaemonMeta) -> String {
Envelope::ok(result, meta).render()
}
pub fn err(
code: &str,
message: &str,
hint: Option<&str>,
fix_tool: Option<&str>,
meta: DaemonMeta,
) -> String {
Envelope::err(code, message, hint, fix_tool, meta).render()
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn ok_includes_meta_fields_when_set() {
let meta = DaemonMeta {
active_debug_session: Some(ActiveSessionEcho {
id: "ds_1".into(),
project_slug: "daemon8".into(),
started_at_ns: 1_000,
}),
next_actions: Some(vec!["create_checkpoint".into()]),
hint: Some("an active debug session is running".into()),
};
let s = ok_value(json!({"checkpoint": 42}), meta);
let parsed: Value = serde_json::from_str(&s).unwrap();
assert_eq!(parsed["result"]["checkpoint"], 42);
assert_eq!(parsed["daemon8"]["active_debug_session"]["id"], "ds_1");
assert_eq!(parsed["daemon8"]["next_actions"][0], "create_checkpoint");
assert!(parsed["error"].is_null() || parsed.get("error").is_none());
}
#[test]
fn ok_omits_unset_meta_fields() {
let s = ok_value(json!({"x": 1}), DaemonMeta::default());
let parsed: Value = serde_json::from_str(&s).unwrap();
assert!(parsed["daemon8"].as_object().unwrap().is_empty());
}
#[test]
fn err_carries_code_message_hint_and_fix() {
let s = err(
"no_active_debug_session",
"create_checkpoint requires an active debug session",
Some("call start_debug_session first"),
Some("start_debug_session"),
DaemonMeta::default(),
);
let parsed: Value = serde_json::from_str(&s).unwrap();
assert_eq!(parsed["error"]["code"], "no_active_debug_session");
assert_eq!(parsed["error"]["fix"]["tool"], "start_debug_session");
assert!(parsed["result"].is_null());
}
}