Skip to main content

tftio_cli_common/
runner.rs

1//! Shared CLI runner helpers.
2
3use crate::{JsonOutput, 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    output: JsonOutput,
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>, output: JsonOutput, message: impl Into<String>) -> Self {
19        Self {
20            command: command.into(),
21            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 the output mode used for shared error rendering.
33    #[must_use]
34    pub const fn output(&self) -> JsonOutput {
35        self.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.output.is_json() {
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.output.is_json() {
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, output: JsonOutput, 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, 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 =
140            run_with_fatal_handler(|| Err(fatal_error("scan", JsonOutput::Text, "bad")));
141        assert_eq!(exit_code, 1);
142    }
143
144    #[test]
145    fn parse_and_run_passes_parsed_value_to_runner() {
146        let exit_code = parse_and_run(
147            || String::from("parsed"),
148            |cli| {
149                if cli == "parsed" {
150                    Ok(0)
151                } else {
152                    Err(fatal_error("scan", JsonOutput::Text, "unexpected cli"))
153                }
154            },
155        );
156        assert_eq!(exit_code, 0);
157    }
158
159    #[test]
160    fn fatal_cli_error_renders_json_when_requested() {
161        let rendered = FatalCliError::new("scan", JsonOutput::Json, "bad").render();
162        assert!(rendered.contains("\"ok\":false"));
163        assert!(rendered.contains("\"code\":\"ERROR\""));
164        assert!(rendered.contains("\"command\":\"scan\""));
165    }
166
167    #[test]
168    fn run_with_display_error_handler_returns_success_code() {
169        let exit_code = run_with_display_error_handler("scan", JsonOutput::Text, || {
170            Ok::<i32, DisplayOnlyError>(9)
171        });
172        assert_eq!(exit_code, 9);
173    }
174
175    #[test]
176    fn run_with_display_error_handler_converts_display_errors() {
177        let exit_code = run_with_display_error_handler("scan", JsonOutput::Text, || {
178            Err::<i32, DisplayOnlyError>(DisplayOnlyError("bad"))
179        });
180        assert_eq!(exit_code, 1);
181    }
182}