Skip to main content

tftio_cli_common/
runner.rs

1//! Shared CLI runner helpers.
2
3use crate::err_response;
4use serde_json::json;
5use std::fmt::Display;
6
7/// Shared fatal CLI error state.
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub struct FatalCliError {
10    command: String,
11    json_output: bool,
12    message: String,
13}
14
15impl FatalCliError {
16    /// Create a fatal CLI error with the shared renderer contract.
17    #[must_use]
18    pub fn new(command: impl Into<String>, json_output: bool, message: impl Into<String>) -> Self {
19        Self {
20            command: command.into(),
21            json_output,
22            message: message.into(),
23        }
24    }
25
26    /// Return the command label used in shared error output.
27    #[must_use]
28    pub fn command(&self) -> &str {
29        &self.command
30    }
31
32    /// Return whether the error should emit JSON.
33    #[must_use]
34    pub fn json_output(&self) -> bool {
35        self.json_output
36    }
37
38    /// Return the fatal message.
39    #[must_use]
40    pub fn message(&self) -> &str {
41        &self.message
42    }
43
44    /// Render the fatal error as a string without emitting it.
45    #[must_use]
46    pub fn render(&self) -> String {
47        if self.json_output {
48            err_response(self.command(), "ERROR", self.message(), json!({})).to_string()
49        } else {
50            format!("error: {}", self.message())
51        }
52    }
53
54    /// Emit the fatal error to the correct output stream.
55    pub fn emit(&self) {
56        if self.json_output {
57            println!("{}", self.render());
58        } else {
59            eprintln!("{}", self.render());
60        }
61    }
62
63    /// Emit the fatal error and return the shared failure exit code.
64    #[must_use]
65    pub fn emit_and_exit_code(self) -> i32 {
66        self.emit();
67        1
68    }
69}
70
71/// Run a fallible CLI closure and convert shared fatal errors into exit codes.
72#[must_use]
73pub fn run_with_fatal_handler<F>(run: F) -> i32
74where
75    F: FnOnce() -> Result<i32, FatalCliError>,
76{
77    match run() {
78        Ok(exit_code) => exit_code,
79        Err(error) => error.emit_and_exit_code(),
80    }
81}
82
83/// Run a fallible CLI closure with shared fatal rendering for any displayable error type.
84#[must_use]
85pub fn run_with_display_error_handler<F, E>(command: &str, json_output: bool, run: F) -> i32
86where
87    F: FnOnce() -> Result<i32, E>,
88    E: Display,
89{
90    run_with_fatal_handler(|| {
91        run().map_err(|error| FatalCliError::new(command, json_output, error.to_string()))
92    })
93}
94
95/// Parse CLI state with one closure and execute it with another.
96#[must_use]
97pub fn parse_and_run<T, P, F>(parse: P, run: F) -> i32
98where
99    P: FnOnce() -> T,
100    F: FnOnce(T) -> Result<i32, FatalCliError>,
101{
102    run_with_fatal_handler(|| run(parse()))
103}
104
105/// Parse CLI state, run the handler, and exit the process with the resulting code.
106pub fn parse_and_exit<T, P, F>(parse: P, run: F) -> !
107where
108    P: FnOnce() -> T,
109    F: FnOnce(T) -> Result<i32, FatalCliError>,
110{
111    std::process::exit(parse_and_run(parse, run))
112}
113
114#[cfg(test)]
115mod tests {
116    use std::fmt;
117
118    use crate::error::fatal_error;
119
120    use super::*;
121
122    #[derive(Debug)]
123    struct DisplayOnlyError(&'static str);
124
125    impl fmt::Display for DisplayOnlyError {
126        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
127            write!(f, "{}", self.0)
128        }
129    }
130
131    #[test]
132    fn run_with_fatal_handler_returns_success_code() {
133        let exit_code = run_with_fatal_handler(|| Ok(7));
134        assert_eq!(exit_code, 7);
135    }
136
137    #[test]
138    fn run_with_fatal_handler_converts_fatal_error_to_failure_code() {
139        let exit_code = run_with_fatal_handler(|| Err(fatal_error("scan", false, "bad")));
140        assert_eq!(exit_code, 1);
141    }
142
143    #[test]
144    fn parse_and_run_passes_parsed_value_to_runner() {
145        let exit_code = parse_and_run(
146            || String::from("parsed"),
147            |cli| {
148                if cli == "parsed" {
149                    Ok(0)
150                } else {
151                    Err(fatal_error("scan", false, "unexpected cli"))
152                }
153            },
154        );
155        assert_eq!(exit_code, 0);
156    }
157
158    #[test]
159    fn fatal_cli_error_renders_json_when_requested() {
160        let rendered = FatalCliError::new("scan", true, "bad").render();
161        assert!(rendered.contains("\"ok\":false"));
162        assert!(rendered.contains("\"code\":\"ERROR\""));
163        assert!(rendered.contains("\"command\":\"scan\""));
164    }
165
166    #[test]
167    fn run_with_display_error_handler_returns_success_code() {
168        let exit_code =
169            run_with_display_error_handler("scan", false, || Ok::<i32, DisplayOnlyError>(9));
170        assert_eq!(exit_code, 9);
171    }
172
173    #[test]
174    fn run_with_display_error_handler_converts_display_errors() {
175        let exit_code = run_with_display_error_handler("scan", false, || {
176            Err::<i32, DisplayOnlyError>(DisplayOnlyError("bad"))
177        });
178        assert_eq!(exit_code, 1);
179    }
180}