Skip to main content

tftio_cli_common/
json.rs

1//! Shared JSON response helpers.
2
3use serde_json::{Value, json};
4
5/// Shared text-vs-JSON output mode.
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum JsonOutput {
8    /// Render human-readable text output.
9    Text,
10    /// Render JSON output.
11    Json,
12}
13
14impl JsonOutput {
15    /// Convert a `--json` flag into a shared output mode.
16    #[must_use]
17    pub const fn from_flag(json: bool) -> Self {
18        if json { Self::Json } else { Self::Text }
19    }
20
21    /// Whether this mode renders JSON.
22    #[must_use]
23    pub const fn is_json(self) -> bool {
24        matches!(self, Self::Json)
25    }
26}
27
28/// Build a success response envelope.
29#[must_use]
30#[allow(
31    clippy::needless_pass_by_value,
32    reason = "data is moved into the response envelope, so taking it by value avoids a clone"
33)]
34pub fn ok_response(command: &str, data: Value) -> Value {
35    json!({
36        "ok": true,
37        "command": command,
38        "data": data
39    })
40}
41
42/// Build an error response envelope.
43#[must_use]
44#[allow(
45    clippy::needless_pass_by_value,
46    reason = "details is moved into the response envelope, so taking it by value avoids a clone"
47)]
48pub fn err_response(command: &str, code: &str, message: &str, details: Value) -> Value {
49    json!({
50        "ok": false,
51        "command": command,
52        "error": {
53            "code": code,
54            "message": message,
55            "details": details
56        }
57    })
58}
59
60/// Render either the shared JSON envelope or plain text for a command response.
61#[must_use]
62#[allow(
63    clippy::needless_pass_by_value,
64    reason = "data is moved into the render closure, so taking it by value avoids a clone"
65)]
66pub fn render_response(
67    command: &str,
68    output: JsonOutput,
69    data: Value,
70    text: impl Into<String>,
71) -> String {
72    render_response_parts(command, output, || data, || text.into())
73}
74
75/// Render either the shared JSON envelope or lazily-built plain text for a command response.
76#[must_use]
77#[allow(
78    clippy::needless_pass_by_value,
79    reason = "data is moved into the render closure, so taking it by value avoids a clone"
80)]
81pub fn render_response_with<F>(command: &str, output: JsonOutput, data: Value, text: F) -> String
82where
83    F: FnOnce() -> String,
84{
85    render_response_parts(command, output, || data, text)
86}
87
88/// Render either the shared JSON envelope or lazily-built command data and plain text.
89#[must_use]
90#[allow(
91    clippy::needless_pass_by_value,
92    reason = "the data and text closures are consumed (called once) inside the function, so by-value is correct"
93)]
94pub fn render_response_parts<D, T>(command: &str, output: JsonOutput, data: D, text: T) -> String
95where
96    D: FnOnce() -> Value,
97    T: FnOnce() -> String,
98{
99    if output.is_json() {
100        ok_response(command, data()).to_string()
101    } else {
102        text()
103    }
104}
105
106#[cfg(test)]
107mod tests {
108    use std::cell::Cell;
109
110    use serde_json::json;
111
112    use super::*;
113
114    #[test]
115    fn json_output_tracks_flag_state() {
116        assert_eq!(JsonOutput::from_flag(false), JsonOutput::Text);
117        assert_eq!(JsonOutput::from_flag(true), JsonOutput::Json);
118        assert!(!JsonOutput::Text.is_json());
119        assert!(JsonOutput::Json.is_json());
120    }
121
122    #[test]
123    fn ok_response_contains_expected_shape() {
124        let value = ok_response("list", json!({ "x": 1 }));
125        assert_eq!(value["ok"], json!(true));
126        assert_eq!(value["command"], json!("list"));
127        assert_eq!(value["data"]["x"], json!(1));
128    }
129
130    #[test]
131    fn err_response_contains_expected_shape() {
132        let value = err_response("list", "ERROR", "bad", json!({}));
133        assert_eq!(value["ok"], json!(false));
134        assert_eq!(value["error"]["code"], json!("ERROR"));
135        assert_eq!(value["error"]["message"], json!("bad"));
136    }
137
138    #[test]
139    fn render_response_uses_json_envelope_when_requested() {
140        let value = render_response("list", JsonOutput::Json, json!({"x": 1}), "text");
141        assert!(value.contains("\"ok\":true"));
142    }
143
144    #[test]
145    fn render_response_with_skips_text_builder_for_json_output() {
146        let called = Cell::new(false);
147        let value = render_response_with("list", JsonOutput::Json, json!({"x": 1}), || {
148            called.set(true);
149            String::from("text")
150        });
151
152        assert!(value.contains("\"ok\":true"));
153        assert!(!called.get());
154    }
155
156    #[test]
157    fn render_response_with_builds_text_for_text_output() {
158        let value = render_response_with("list", JsonOutput::Text, json!({"x": 1}), || {
159            String::from("text")
160        });
161        assert_eq!(value, "text");
162    }
163
164    #[test]
165    fn render_response_parts_skips_text_builder_for_json_output() {
166        let called = Cell::new(false);
167        let value = render_response_parts(
168            "list",
169            JsonOutput::Json,
170            || json!({"x": 1}),
171            || {
172                called.set(true);
173                String::from("text")
174            },
175        );
176
177        assert!(value.contains("\"ok\":true"));
178        assert!(!called.get());
179    }
180
181    #[test]
182    fn render_response_parts_skips_data_builder_for_text_output() {
183        let called = Cell::new(false);
184        let value = render_response_parts(
185            "list",
186            JsonOutput::Text,
187            || {
188                called.set(true);
189                json!({"x": 1})
190            },
191            || String::from("text"),
192        );
193
194        assert_eq!(value, "text");
195        assert!(!called.get());
196    }
197}