Skip to main content

nils_common/
diag_output.rs

1//! Shared JSON envelope helpers for CLI diagnostic / structured output.
2//!
3//! Caller crates re-export the surface (typically via `pub use nils_common::diag_output::*;`
4//! in a crate-local `diag_output` module) so existing `crate::diag_output::*` paths keep
5//! working without touching call sites.
6
7use anyhow::Result;
8use serde::Serialize;
9use serde_json::Value;
10
11#[derive(Debug, Clone, Serialize)]
12pub struct ErrorEnvelope {
13    pub code: String,
14    pub message: String,
15    #[serde(skip_serializing_if = "Option::is_none")]
16    pub details: Option<Value>,
17}
18
19#[derive(Debug, Clone, Serialize)]
20pub struct JsonEnvelopeResult<T: Serialize> {
21    pub schema_version: String,
22    pub command: String,
23    pub ok: bool,
24    pub result: T,
25}
26
27#[derive(Debug, Clone, Serialize)]
28pub struct JsonEnvelopeResults<T: Serialize> {
29    pub schema_version: String,
30    pub command: String,
31    pub ok: bool,
32    pub results: Vec<T>,
33}
34
35#[derive(Debug, Clone, Serialize)]
36pub struct JsonEnvelopeError {
37    pub schema_version: String,
38    pub command: String,
39    pub ok: bool,
40    pub error: ErrorEnvelope,
41}
42
43pub fn emit_json<T: Serialize>(payload: &T) -> Result<()> {
44    println!("{}", serde_json::to_string(payload)?);
45    Ok(())
46}
47
48pub fn emit_success_result<T: Serialize>(
49    schema_version: &str,
50    command: &str,
51    result: T,
52) -> Result<()> {
53    emit_json(&JsonEnvelopeResult {
54        schema_version: schema_version.to_string(),
55        command: command.to_string(),
56        ok: true,
57        result,
58    })
59}
60
61pub fn emit_success_results<T: Serialize>(
62    schema_version: &str,
63    command: &str,
64    results: Vec<T>,
65) -> Result<()> {
66    emit_json(&JsonEnvelopeResults {
67        schema_version: schema_version.to_string(),
68        command: command.to_string(),
69        ok: true,
70        results,
71    })
72}
73
74pub fn emit_error(
75    schema_version: &str,
76    command: &str,
77    code: &str,
78    message: impl Into<String>,
79    details: Option<Value>,
80) -> Result<()> {
81    emit_json(&JsonEnvelopeError {
82        schema_version: schema_version.to_string(),
83        command: command.to_string(),
84        ok: false,
85        error: ErrorEnvelope {
86            code: code.to_string(),
87            message: message.into(),
88            details,
89        },
90    })
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96    use serde_json::{json, to_value};
97
98    const TEST_SCHEMA: &str = "nils-common.diag-output.test.v1";
99
100    #[test]
101    fn error_envelope_serialization_omits_details_when_none() {
102        let envelope = JsonEnvelopeError {
103            schema_version: TEST_SCHEMA.to_string(),
104            command: "diag test".to_string(),
105            ok: false,
106            error: ErrorEnvelope {
107                code: "bad-input".to_string(),
108                message: "invalid".to_string(),
109                details: None,
110            },
111        };
112        let value = to_value(envelope).expect("serialize");
113        assert_eq!(value["ok"], false);
114        assert!(value["error"].get("details").is_none());
115    }
116
117    #[test]
118    fn error_envelope_serialization_emits_details_when_present() {
119        let envelope = JsonEnvelopeError {
120            schema_version: TEST_SCHEMA.to_string(),
121            command: "diag test".to_string(),
122            ok: false,
123            error: ErrorEnvelope {
124                code: "bad-input".to_string(),
125                message: "invalid".to_string(),
126                details: Some(json!({"hint": "retry"})),
127            },
128        };
129        let value = to_value(envelope).expect("serialize");
130        assert_eq!(value["error"]["details"], json!({"hint": "retry"}));
131    }
132
133    #[test]
134    fn success_result_serialization_round_trip() {
135        let payload = JsonEnvelopeResult {
136            schema_version: TEST_SCHEMA.to_string(),
137            command: "diag list".to_string(),
138            ok: true,
139            result: json!({"status": "ok"}),
140        };
141        let value = to_value(&payload).expect("serialize");
142        assert_eq!(value["schema_version"], TEST_SCHEMA);
143        assert_eq!(value["command"], "diag list");
144        assert_eq!(value["ok"], true);
145        assert_eq!(value["result"]["status"], "ok");
146    }
147
148    #[test]
149    fn success_results_serialization_preserves_vec_order() {
150        let payload = JsonEnvelopeResults {
151            schema_version: TEST_SCHEMA.to_string(),
152            command: "diag list".to_string(),
153            ok: true,
154            results: vec![json!({"item": 1}), json!({"item": 2})],
155        };
156        let value = to_value(&payload).expect("serialize");
157        assert_eq!(value["results"][0]["item"], 1);
158        assert_eq!(value["results"][1]["item"], 2);
159    }
160
161    #[test]
162    fn emit_helpers_return_ok() {
163        assert!(emit_success_result(TEST_SCHEMA, "diag test", json!({"status": "ok"})).is_ok());
164        assert!(
165            emit_success_results(
166                TEST_SCHEMA,
167                "diag test",
168                vec![json!({"item": 1}), json!({"item": 2})],
169            )
170            .is_ok()
171        );
172        assert!(
173            emit_error(
174                TEST_SCHEMA,
175                "diag test",
176                "failure",
177                "boom",
178                Some(json!({"hint": "retry"})),
179            )
180            .is_ok()
181        );
182        assert!(emit_error(TEST_SCHEMA, "diag test", "failure", "boom", None).is_ok());
183    }
184}