Skip to main content

fs_core/cli/
output.rs

1//! What a tool prints: a JSON result by default, text on request, and a
2//! structured error on stderr whose code is the exit status.
3//!
4//! JSON is written here rather than through serde: the values are a
5//! handful of scalars, arrays and objects, and a writer this size keeps
6//! the tools' dependencies to their argument parser.
7
8use std::ffi::OsString;
9use std::fmt::Write as _;
10use std::process::ExitCode;
11
12use clap::ArgMatches;
13
14/// Exit status: the operation failed.
15pub const EXIT_FAILED: u8 = 1;
16/// Exit status: the command line was wrong (clap's own choice as well).
17pub const EXIT_USAGE: u8 = 2;
18/// Exit status: the verb exists, and this driver cannot do it — not
19/// implemented yet, or the format is read-only.
20pub const EXIT_UNSUPPORTED: u8 = 3;
21
22/// A JSON value, with object keys kept in the order they were added so
23/// the output reads the way the code builds it.
24#[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    /// An object from `(key, value)` pairs.
37    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    /// The value under `key`, for an object.
42    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    /// Indented JSON, two spaces a level, and no trailing newline.
50    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    /// JSON on one line.
86    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    /// The same value for a person: a scalar is itself (a string without
129    /// quotes, `null` as nothing), an object is `key: value` lines with
130    /// nested keys dotted (`<fs>.uuid: ...`), and an array is one line per
131    /// element.
132    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/// JSON or text, from `--json`/`--text` (see [`super::format_args`]).
248#[derive(Clone, Copy, Debug, PartialEq, Eq)]
249pub enum Format {
250    Json,
251    Text,
252}
253
254impl Format {
255    /// The format asked for: JSON unless `--text` was given, and when both
256    /// switches were, the one given LAST on the command line — whichever
257    /// level (the tool's or a subcommand's) it belongs to.
258    ///
259    /// The order is read from `argv`, because the parse cannot give it:
260    /// with the switches global, clap copies each level's switch into the
261    /// other, so after `fs.<fs> --text img ls --json` BOTH read as given
262    /// at BOTH levels, at the same index. Taking "the deepest level's" as
263    /// the answer, as this did before, answered text there. The parse still
264    /// decides WHETHER a switch was given, so an argument that merely looks
265    /// like one (after `--`) changes nothing.
266    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
286/// Whether a raw command line asks for text: the last `--text` or
287/// `--json` before any `--` decides. [`Format::of`] reads the order here,
288/// and a command line that failed to parse has nothing else to read.
289pub 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/// A successful run's result.
298#[derive(Debug, Default)]
299pub struct Outcome {
300    /// Printed on stdout: pretty JSON, or its text form.
301    pub report: Option<Json>,
302    /// The text form, when the tool has a better one than
303    /// [`Json::to_text`].
304    pub text: Option<String>,
305    /// The exit status, for a tool whose success has more than one answer
306    /// (fsck's "corrected"). Zero otherwise.
307    pub code: u8,
308}
309
310impl Outcome {
311    /// A result to print.
312    pub fn report(report: Json) -> Outcome {
313        Outcome {
314            report: Some(report),
315            ..Outcome::default()
316        }
317    }
318
319    /// Nothing to print: the tool wrote its output itself (raw bytes).
320    pub fn done() -> Outcome {
321        Outcome::default()
322    }
323
324    /// Use `text` for `--text` instead of the generic rendering.
325    pub fn with_text(mut self, text: impl Into<String>) -> Outcome {
326        self.text = Some(text.into());
327        self
328    }
329
330    /// Exit with `code` although the run succeeded.
331    pub fn with_code(mut self, code: u8) -> Outcome {
332        self.code = code;
333        self
334    }
335}
336
337/// A failure: printed as `{"error": message, "code": code}` on stderr (or
338/// `<tool>: <message>` with `--text`), and `code` is the exit status.
339#[derive(Debug, Clone, PartialEq, Eq)]
340pub struct CliError {
341    pub message: String,
342    pub code: u8,
343}
344
345impl CliError {
346    /// The operation failed (exit 1).
347    pub fn failed(message: impl Into<String>) -> CliError {
348        CliError {
349            message: message.into(),
350            code: EXIT_FAILED,
351        }
352    }
353
354    /// The command line was wrong (exit 2).
355    pub fn usage(message: impl Into<String>) -> CliError {
356        CliError {
357            message: message.into(),
358            code: EXIT_USAGE,
359        }
360    }
361
362    /// The verb exists and this driver cannot do it yet (exit 3). The
363    /// message starts `not implemented`, which scripts may match on.
364    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    /// The verb exists and the format cannot do it (exit 3).
372    pub fn refused(message: impl Into<String>) -> CliError {
373        CliError {
374            message: message.into(),
375            code: EXIT_UNSUPPORTED,
376        }
377    }
378
379    /// A failure with a tool-specific status (fsck's 8).
380    pub fn with_code(mut self, code: u8) -> CliError {
381        self.code = code;
382        self
383    }
384
385    /// The JSON form.
386    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
394/// What a run's result or failure prints, and its exit status, without
395/// printing it: see [`super::Response`].
396pub 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            // An empty text form prints nothing at all, not an empty line:
407            // a tool whose `--text` has nothing to say stays silent on stdout.
408            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
432/// Print a run's result or failure and turn it into the exit status.
433pub 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        // After `--` an argument is data, however it is spelt.
472        assert!(!text_requested(&argv(&["x", "--", "--text"])));
473        assert!(text_requested(&argv(&["x", "--text", "--", "--json"])));
474    }
475}