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 #[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}