1use std::ffi::OsString;
9use std::fmt::Write as _;
10use std::process::ExitCode;
11
12use clap::ArgMatches;
13
14pub const EXIT_FAILED: u8 = 1;
16pub const EXIT_USAGE: u8 = 2;
18pub const EXIT_UNSUPPORTED: u8 = 3;
21
22#[derive(Clone, Debug, PartialEq)]
25pub enum Json {
26 Null,
27 Bool(bool),
28 Int(i64),
29 UInt(u64),
30 Str(String),
31 Arr(Vec<Json>),
32 Obj(Vec<(String, Json)>),
33}
34
35impl Json {
36 pub fn object<K: Into<String>>(pairs: impl IntoIterator<Item = (K, Json)>) -> Json {
38 Json::Obj(pairs.into_iter().map(|(k, v)| (k.into(), v)).collect())
39 }
40
41 pub fn get(&self, key: &str) -> Option<&Json> {
43 match self {
44 Json::Obj(pairs) => pairs.iter().find(|(k, _)| k == key).map(|(_, v)| v),
45 _ => None,
46 }
47 }
48
49 pub fn to_pretty(&self) -> String {
51 let mut out = String::new();
52 self.write_pretty(&mut out, 0);
53 out
54 }
55
56 fn write_pretty(&self, out: &mut String, depth: usize) {
57 let pad = |out: &mut String, depth: usize| out.push_str(&" ".repeat(depth));
58 match self {
59 Json::Arr(items) if !items.is_empty() => {
60 out.push_str("[\n");
61 for (i, item) in items.iter().enumerate() {
62 pad(out, depth + 1);
63 item.write_pretty(out, depth + 1);
64 out.push_str(if i + 1 < items.len() { ",\n" } else { "\n" });
65 }
66 pad(out, depth);
67 out.push(']');
68 }
69 Json::Obj(pairs) if !pairs.is_empty() => {
70 out.push_str("{\n");
71 for (i, (key, value)) in pairs.iter().enumerate() {
72 pad(out, depth + 1);
73 write_string(out, key);
74 out.push_str(": ");
75 value.write_pretty(out, depth + 1);
76 out.push_str(if i + 1 < pairs.len() { ",\n" } else { "\n" });
77 }
78 pad(out, depth);
79 out.push('}');
80 }
81 other => other.write_compact(out),
82 }
83 }
84
85 pub fn to_compact(&self) -> String {
87 let mut out = String::new();
88 self.write_compact(&mut out);
89 out
90 }
91
92 fn write_compact(&self, out: &mut String) {
93 match self {
94 Json::Null => out.push_str("null"),
95 Json::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
96 Json::Int(n) => {
97 let _ = write!(out, "{n}");
98 }
99 Json::UInt(n) => {
100 let _ = write!(out, "{n}");
101 }
102 Json::Str(s) => write_string(out, s),
103 Json::Arr(items) => {
104 out.push('[');
105 for (i, item) in items.iter().enumerate() {
106 if i > 0 {
107 out.push_str(", ");
108 }
109 item.write_compact(out);
110 }
111 out.push(']');
112 }
113 Json::Obj(pairs) => {
114 out.push('{');
115 for (i, (key, value)) in pairs.iter().enumerate() {
116 if i > 0 {
117 out.push_str(", ");
118 }
119 write_string(out, key);
120 out.push_str(": ");
121 value.write_compact(out);
122 }
123 out.push('}');
124 }
125 }
126 }
127
128 pub fn to_text(&self) -> String {
133 let mut lines = Vec::new();
134 self.text_lines("", &mut lines);
135 lines.join("\n")
136 }
137
138 fn text_lines(&self, prefix: &str, lines: &mut Vec<String>) {
139 match self {
140 Json::Obj(pairs) => {
141 for (key, value) in pairs {
142 let dotted = if prefix.is_empty() {
143 key.clone()
144 } else {
145 format!("{prefix}.{key}")
146 };
147 match value {
148 Json::Obj(_) => value.text_lines(&dotted, lines),
149 _ => lines.push(format!("{dotted}: {}", value.scalar_text())),
150 }
151 }
152 }
153 Json::Arr(items) => {
154 for item in items {
155 lines.push(item.scalar_text());
156 }
157 }
158 scalar => lines.push(scalar.scalar_text()),
159 }
160 }
161
162 fn scalar_text(&self) -> String {
163 match self {
164 Json::Null => String::new(),
165 Json::Bool(b) => b.to_string(),
166 Json::Int(n) => n.to_string(),
167 Json::UInt(n) => n.to_string(),
168 Json::Str(s) => s.clone(),
169 Json::Arr(items) => items
170 .iter()
171 .map(Json::scalar_text)
172 .collect::<Vec<_>>()
173 .join(","),
174 Json::Obj(pairs) => pairs
175 .iter()
176 .map(|(k, v)| format!("{k}={}", v.scalar_text()))
177 .collect::<Vec<_>>()
178 .join(" "),
179 }
180 }
181}
182
183fn write_string(out: &mut String, s: &str) {
184 out.push('"');
185 for c in s.chars() {
186 match c {
187 '"' => out.push_str("\\\""),
188 '\\' => out.push_str("\\\\"),
189 '\n' => out.push_str("\\n"),
190 '\r' => out.push_str("\\r"),
191 '\t' => out.push_str("\\t"),
192 c if (c as u32) < 0x20 || c == '\u{7f}' => {
193 let _ = write!(out, "\\u{:04x}", c as u32);
194 }
195 c => out.push(c),
196 }
197 }
198 out.push('"');
199}
200
201impl From<bool> for Json {
202 fn from(b: bool) -> Json {
203 Json::Bool(b)
204 }
205}
206impl From<u64> for Json {
207 fn from(n: u64) -> Json {
208 Json::UInt(n)
209 }
210}
211impl From<u32> for Json {
212 fn from(n: u32) -> Json {
213 Json::UInt(n.into())
214 }
215}
216impl From<u16> for Json {
217 fn from(n: u16) -> Json {
218 Json::UInt(n.into())
219 }
220}
221impl From<i64> for Json {
222 fn from(n: i64) -> Json {
223 Json::Int(n)
224 }
225}
226impl From<&str> for Json {
227 fn from(s: &str) -> Json {
228 Json::Str(s.to_string())
229 }
230}
231impl From<String> for Json {
232 fn from(s: String) -> Json {
233 Json::Str(s)
234 }
235}
236impl<T: Into<Json>> From<Option<T>> for Json {
237 fn from(v: Option<T>) -> Json {
238 v.map_or(Json::Null, Into::into)
239 }
240}
241impl<T: Into<Json>> From<Vec<T>> for Json {
242 fn from(v: Vec<T>) -> Json {
243 Json::Arr(v.into_iter().map(Into::into).collect())
244 }
245}
246
247#[derive(Clone, Copy, Debug, PartialEq, Eq)]
249pub enum Format {
250 Json,
251 Text,
252}
253
254impl Format {
255 pub fn of(matches: &ArgMatches, argv: &[OsString]) -> Format {
267 let mut given = false;
268 let mut level = Some(matches);
269 while let Some(m) = level {
270 given |= flag(m, "text") || flag(m, "json");
271 level = m.subcommand().map(|(_, sub)| sub);
272 }
273 if given && text_requested(argv) {
274 Format::Text
275 } else {
276 Format::Json
277 }
278 }
279}
280
281fn flag(matches: &ArgMatches, id: &str) -> bool {
282 matches!(matches.try_get_one::<bool>(id), Ok(Some(true)))
283 && matches.value_source(id) == Some(clap::parser::ValueSource::CommandLine)
284}
285
286pub fn text_requested(argv: &[OsString]) -> bool {
290 argv.iter()
291 .take_while(|a| *a != "--")
292 .filter(|a| *a == "--text" || *a == "--json")
293 .last()
294 .is_some_and(|a| a == "--text")
295}
296
297#[derive(Debug, Default)]
299pub struct Outcome {
300 pub report: Option<Json>,
302 pub text: Option<String>,
305 pub code: u8,
308}
309
310impl Outcome {
311 pub fn report(report: Json) -> Outcome {
313 Outcome {
314 report: Some(report),
315 ..Outcome::default()
316 }
317 }
318
319 pub fn done() -> Outcome {
321 Outcome::default()
322 }
323
324 pub fn with_text(mut self, text: impl Into<String>) -> Outcome {
326 self.text = Some(text.into());
327 self
328 }
329
330 pub fn with_code(mut self, code: u8) -> Outcome {
332 self.code = code;
333 self
334 }
335}
336
337#[derive(Debug, Clone, PartialEq, Eq)]
340pub struct CliError {
341 pub message: String,
342 pub code: u8,
343}
344
345impl CliError {
346 pub fn failed(message: impl Into<String>) -> CliError {
348 CliError {
349 message: message.into(),
350 code: EXIT_FAILED,
351 }
352 }
353
354 pub fn usage(message: impl Into<String>) -> CliError {
356 CliError {
357 message: message.into(),
358 code: EXIT_USAGE,
359 }
360 }
361
362 pub fn not_implemented(what: impl Into<String>) -> CliError {
365 CliError {
366 message: format!("not implemented: {}", what.into()),
367 code: EXIT_UNSUPPORTED,
368 }
369 }
370
371 pub fn refused(message: impl Into<String>) -> CliError {
373 CliError {
374 message: message.into(),
375 code: EXIT_UNSUPPORTED,
376 }
377 }
378
379 pub fn with_code(mut self, code: u8) -> CliError {
381 self.code = code;
382 self
383 }
384
385 pub fn to_json(&self) -> Json {
387 Json::object([
388 ("error", Json::from(self.message.as_str())),
389 ("code", Json::from(u64::from(self.code))),
390 ])
391 }
392}
393
394pub fn render(program: &str, format: Format, result: Result<Outcome, CliError>) -> super::Response {
397 match result {
398 Ok(outcome) => {
399 let printed = match (&outcome.report, format) {
400 (None, _) => None,
401 (Some(report), Format::Json) => Some(report.to_pretty()),
402 (Some(report), Format::Text) => {
403 Some(outcome.text.clone().unwrap_or_else(|| report.to_text()))
404 }
405 };
406 let stdout = printed
409 .filter(|p| !p.is_empty())
410 .map(|p| format!("{p}\n"))
411 .unwrap_or_default();
412 super::Response {
413 stdout,
414 stderr: String::new(),
415 code: outcome.code,
416 }
417 }
418 Err(error) => {
419 let stderr = match format {
420 Format::Json => format!("{}\n", error.to_json().to_compact()),
421 Format::Text => format!("{program}: {}\n", error.message),
422 };
423 super::Response {
424 stdout: String::new(),
425 stderr,
426 code: error.code,
427 }
428 }
429 }
430}
431
432pub fn finish(program: &str, format: Format, result: Result<Outcome, CliError>) -> ExitCode {
434 render(program, format, result).emit()
435}
436
437#[cfg(test)]
438mod tests {
439 use super::*;
440
441 #[test]
442 fn strings_are_escaped_and_control_characters_survive() {
443 let v = Json::from("a\"b\\c\nd\u{1}é");
444 assert_eq!(v.to_compact(), r#""a\"b\\c\nd\u0001é""#);
445 }
446
447 #[test]
448 fn objects_keep_their_order_and_nest_in_text_with_dots() {
449 let v = Json::object([
450 ("fs", Json::from("demo")),
451 ("label", Json::Null),
452 ("demo", Json::object([("uuid", Json::from("x"))])),
453 ]);
454 assert_eq!(
455 v.to_compact(),
456 r#"{"fs": "demo", "label": null, "demo": {"uuid": "x"}}"#
457 );
458 assert_eq!(v.to_text(), "fs: demo\nlabel: \ndemo.uuid: x");
459 assert_eq!(
460 v.to_pretty(),
461 "{\n \"fs\": \"demo\",\n \"label\": null,\n \"demo\": {\n \"uuid\": \"x\"\n }\n}"
462 );
463 }
464
465 #[test]
466 fn the_last_format_flag_on_a_raw_command_line_wins() {
467 let argv = |v: &[&str]| v.iter().map(OsString::from).collect::<Vec<_>>();
468 assert!(text_requested(&argv(&["x", "--json", "--text"])));
469 assert!(!text_requested(&argv(&["x", "--text", "--json"])));
470 assert!(!text_requested(&argv(&["x"])));
471 assert!(!text_requested(&argv(&["x", "--", "--text"])));
473 assert!(text_requested(&argv(&["x", "--text", "--", "--json"])));
474 }
475}