1use serde_json::{Value, json};
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum JsonOutput {
8 Text,
10 Json,
12}
13
14impl JsonOutput {
15 #[must_use]
17 pub const fn from_flag(json: bool) -> Self {
18 if json { Self::Json } else { Self::Text }
19 }
20
21 #[must_use]
23 pub const fn is_json(self) -> bool {
24 matches!(self, Self::Json)
25 }
26}
27
28#[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#[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#[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#[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#[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 fn tracking_text(called: &Cell<bool>) -> String {
116 called.set(true);
117 String::from("text")
118 }
119
120 fn tracking_data(called: &Cell<bool>) -> Value {
122 called.set(true);
123 json!({ "x": 1 })
124 }
125
126 #[test]
127 fn json_output_tracks_flag_state() {
128 assert_eq!(JsonOutput::from_flag(false), JsonOutput::Text);
129 assert_eq!(JsonOutput::from_flag(true), JsonOutput::Json);
130 assert!(!JsonOutput::Text.is_json());
131 assert!(JsonOutput::Json.is_json());
132 }
133
134 #[test]
135 fn ok_response_contains_expected_shape() {
136 let value = ok_response("list", json!({ "x": 1 }));
137 assert_eq!(value["ok"], json!(true));
138 assert_eq!(value["command"], json!("list"));
139 assert_eq!(value["data"]["x"], json!(1));
140 }
141
142 #[test]
143 fn err_response_contains_expected_shape() {
144 let value = err_response("list", "ERROR", "bad", json!({}));
145 assert_eq!(value["ok"], json!(false));
146 assert_eq!(value["error"]["code"], json!("ERROR"));
147 assert_eq!(value["error"]["message"], json!("bad"));
148 }
149
150 #[test]
151 fn render_response_uses_json_envelope_when_requested() {
152 let value = render_response("list", JsonOutput::Json, json!({"x": 1}), "text");
153 assert!(value.contains("\"ok\":true"));
154 }
155
156 #[test]
157 fn render_response_with_skips_text_builder_for_json_output() {
158 let called = Cell::new(false);
159 let value = render_response_with("list", JsonOutput::Json, json!({"x": 1}), || {
160 tracking_text(&called)
161 });
162
163 assert!(value.contains("\"ok\":true"));
164 assert!(!called.get());
165 }
166
167 #[test]
168 fn render_response_with_builds_text_for_text_output() {
169 let called = Cell::new(false);
170 let value = render_response_with("list", JsonOutput::Text, json!({"x": 1}), || {
171 tracking_text(&called)
172 });
173 assert_eq!(value, "text");
174 assert!(called.get());
175 }
176
177 #[test]
178 fn render_response_parts_skips_text_builder_for_json_output() {
179 let data_called = Cell::new(false);
180 let text_called = Cell::new(false);
181 let value = render_response_parts(
182 "list",
183 JsonOutput::Json,
184 || tracking_data(&data_called),
185 || tracking_text(&text_called),
186 );
187
188 assert!(value.contains("\"ok\":true"));
189 assert!(data_called.get());
190 assert!(!text_called.get());
191 }
192
193 #[test]
194 fn render_response_parts_skips_data_builder_for_text_output() {
195 let data_called = Cell::new(false);
196 let text_called = Cell::new(false);
197 let value = render_response_parts(
198 "list",
199 JsonOutput::Text,
200 || tracking_data(&data_called),
201 || tracking_text(&text_called),
202 );
203
204 assert_eq!(value, "text");
205 assert!(!data_called.get());
206 assert!(text_called.get());
207 }
208}