Skip to main content

airs_cli_rs/
lib.rs

1//! `airs-cli-rs`: a native Prisma AIRS CLI built on the `scm-sdk` crate.
2//!
3//! [`run`] is the whole process: version pre-scan, argument parsing, usage
4//! validation, a current-thread Tokio runtime with signal-driven cancellation, the
5//! command, and the exit-code mapping (DESIGN §2).
6#![forbid(unsafe_code)]
7
8// Every module is private: the binary is the only consumer, and private modules keep
9// the dead-code lint working across the crate.
10mod cli;
11mod clients;
12mod commands;
13mod debug;
14mod env;
15mod error;
16mod input;
17mod json;
18mod output;
19mod pagination;
20mod render;
21mod signals;
22mod store;
23mod tenant;
24mod views;
25
26use clap::FromArgMatches;
27use commands::{Invocation, Plan};
28use error::{CliError, EXIT_OK, EXIT_OPERATIONAL, EXIT_USAGE};
29use output::Output;
30use std::ffi::OsString;
31use std::io::{ErrorKind, Write};
32
33/// The bare version printed by `-V` / `--version`.
34pub const VERSION: &str = env!("CARGO_PKG_VERSION");
35
36/// Run the CLI with `args` (including the program name) and return the exit code.
37pub fn run<I, T>(args: I) -> i32
38where
39    I: IntoIterator<Item = T>,
40    T: Into<OsString>,
41{
42    let args: Vec<OsString> = args.into_iter().map(Into::into).collect();
43    if version_requested(&args) {
44        return print_version();
45    }
46    let parsed = cli::command()
47        .try_get_matches_from(&args)
48        .and_then(|matches| cli::Cli::from_arg_matches(&matches));
49    let cli = match parsed {
50        Ok(cli) => cli,
51        Err(error) => {
52            // Help goes to stdout (exit 0); parse errors to stderr (exit 2), without
53            // argument values for the commands that take secrets (D91).
54            let error = cli::redacted_parse_error(&args, &error).unwrap_or(error);
55            let _ = error.print();
56            return error.exit_code();
57        }
58    };
59    let mut output = Output::stdio(cli.global.quiet);
60    output.set_debug(cli.global.debug);
61    let plan = match commands::plan(&cli) {
62        Ok(plan) => plan,
63        Err(error) => {
64            output.report(&error);
65            return error.exit_code(None);
66        }
67    };
68    if let Plan::GroupHelp(path) = plan {
69        output.write_stderr(&cli::group_help(path));
70        return EXIT_USAGE;
71    }
72    let mut invocation = Invocation::new(output, cli.global.tenant.clone());
73    // After all usage validation, before the tenant is read (DESIGN §2.9).
74    if cli.global.debug
75        && plan.debug_policy() == debug::DebugPolicy::Log
76        && let Err(error) = invocation.open_debug_log()
77    {
78        invocation.output.report(&error);
79        return error.exit_code(None);
80    }
81    execute(plan, invocation)
82}
83
84/// `-V` / `--version` anywhere before a literal `--` (commander fires it during parse,
85/// even where an option value is expected).
86pub fn version_requested(args: &[OsString]) -> bool {
87    args.iter()
88        .skip(1)
89        .take_while(|arg| *arg != "--")
90        .any(|arg| arg == "-V" || arg == "--version")
91}
92
93/// The failure for a stdout write that failed for a reason other than a closed pipe.
94/// Fixed text: the OS error is never echoed.
95fn stdout_write_failed() -> CliError {
96    CliError::operational("AISEC_SDK_ERROR:Output could not be written to stdout")
97}
98
99fn print_version() -> i32 {
100    let mut stdout = std::io::stdout().lock();
101    match writeln!(stdout, "{VERSION}").and_then(|()| stdout.flush()) {
102        Err(error) if error.kind() != ErrorKind::BrokenPipe => {
103            let text = stdout_write_failed().render_colored(render::style::stderr_color());
104            let _ = std::io::stderr().write_all(text.as_bytes());
105            EXIT_OPERATIONAL
106        }
107        _ => EXIT_OK,
108    }
109}
110
111fn execute(plan: Plan, mut invocation: Invocation) -> i32 {
112    let runtime = match tokio::runtime::Builder::new_current_thread()
113        .enable_all()
114        .build()
115    {
116        Ok(runtime) => runtime,
117        Err(_) => {
118            let error = CliError::operational("AISEC_SDK_ERROR:The async runtime could not start");
119            invocation.output.report(&error);
120            return EXIT_OPERATIONAL;
121        }
122    };
123    let signals = signals::install(invocation.session.clone());
124    signals.set_quiet(invocation.output.quiet());
125    invocation.output.attach_signals(signals.clone());
126    let fallback = plan.interrupt_context();
127    let result = runtime.block_on(commands::dispatch(&mut invocation, plan));
128    // A tenant write that passed its commit point reports that the change was saved.
129    let fallback = if invocation.committed {
130        error::InterruptContext::ChangeSaved
131    } else {
132        fallback
133    };
134    // Never wait for leftover blocking tasks (a slow `getaddrinfo` after a cancel or a
135    // deadline): the process exits right after `finish`.
136    runtime.shutdown_background();
137    let signal = signals.code();
138    // A signal always ends the run as interrupted; partial results are never written.
139    let result = match (result, signal) {
140        (Err(CliError::Interrupted(context)), _) => Err(CliError::Interrupted(context)),
141        (_, Some(_)) => Err(CliError::Interrupted(fallback)),
142        (result, None) => result,
143    };
144    finish(&mut invocation.output, result, signal)
145}
146
147fn finish(output: &mut Output, result: Result<(), CliError>, signal: Option<i32>) -> i32 {
148    match result {
149        Ok(()) => match output.commit() {
150            Ok(()) => EXIT_OK,
151            // Node's console ignores EPIPE; so do we.
152            Err(error) if error.kind() == ErrorKind::BrokenPipe => EXIT_OK,
153            Err(_) => {
154                // A signal that arrived just before the write: report the interruption.
155                let error = match output.pending_signal() {
156                    Some(_) => CliError::Interrupted(error::InterruptContext::OutputIncomplete),
157                    None => stdout_write_failed(),
158                };
159                output.report(&error);
160                error.exit_code(output.pending_signal())
161            }
162        },
163        Err(error) => {
164            output.discard();
165            output.report(&error);
166            error.exit_code(signal)
167        }
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    fn args(list: &[&str]) -> Vec<OsString> {
176        list.iter().map(OsString::from).collect()
177    }
178
179    #[test]
180    fn version_pre_scan() {
181        assert!(version_requested(&args(&["airs-cli-rs", "-V"])));
182        assert!(version_requested(&args(&[
183            "airs-cli-rs",
184            "runtime",
185            "scan",
186            "--profile",
187            "-V",
188            "hi"
189        ])));
190        assert!(version_requested(&args(&[
191            "airs-cli-rs",
192            "doctor",
193            "--version"
194        ])));
195        assert!(!version_requested(&args(&[
196            "airs-cli-rs",
197            "runtime",
198            "scan",
199            "--profile",
200            "p",
201            "--",
202            "-V"
203        ])));
204        assert!(
205            !version_requested(&args(&["-V"])),
206            "the program name is skipped"
207        );
208        assert!(!version_requested(&args(&["airs-cli-rs", "-v"])));
209    }
210
211    #[test]
212    fn finish_maps_results_to_exit_codes() {
213        let (mut out, stdout, stderr) = output::testing::capture(false);
214        out.data_line("data");
215        assert_eq!(finish(&mut out, Ok(()), None), 0);
216        assert_eq!(stdout.text(), "data\n");
217
218        let (mut out, stdout, stderr2) = output::testing::capture(false);
219        out.data_line("partial");
220        assert_eq!(finish(&mut out, Err(CliError::usage("bad")), None), 2);
221        assert_eq!(stdout.text(), "");
222        assert_eq!(stderr2.text(), "\n  ✗ bad\n\n");
223
224        let (mut out, _, stderr3) = output::testing::capture(false);
225        let code = finish(
226            &mut out,
227            Err(CliError::Interrupted(
228                error::InterruptContext::NoDataWritten,
229            )),
230            Some(143),
231        );
232        assert_eq!(code, 143);
233        assert!(stderr3.text().contains("Interrupted; no data was written"));
234        assert_eq!(stderr.text(), "");
235    }
236
237    #[test]
238    fn broken_pipe_is_success() {
239        struct Closed;
240        impl Write for Closed {
241            fn write(&mut self, _: &[u8]) -> std::io::Result<usize> {
242                Err(std::io::Error::from(ErrorKind::BrokenPipe))
243            }
244            fn flush(&mut self) -> std::io::Result<()> {
245                Ok(())
246            }
247        }
248        let mut out = Output::with_sinks(false, Box::new(Closed), Box::new(std::io::sink()));
249        out.data_line("data");
250        assert_eq!(finish(&mut out, Ok(()), None), 0);
251    }
252
253    #[test]
254    fn other_stdout_write_failures_are_reported() {
255        struct Full;
256        impl Write for Full {
257            fn write(&mut self, _: &[u8]) -> std::io::Result<usize> {
258                Err(std::io::Error::from(ErrorKind::StorageFull))
259            }
260            fn flush(&mut self) -> std::io::Result<()> {
261                Ok(())
262            }
263        }
264        let stderr = output::testing::Capture::default();
265        let mut out = Output::with_sinks(false, Box::new(Full), Box::new(stderr.clone()));
266        out.data_line("data");
267        assert_eq!(finish(&mut out, Ok(()), None), 1);
268        assert_eq!(
269            stderr.text(),
270            "\n  ✗ Error: AISEC_SDK_ERROR:Output could not be written to stdout\n\n"
271        );
272    }
273
274    #[test]
275    fn a_signal_before_the_final_write_reports_the_interruption() {
276        let (mut out, stdout, stderr) = output::testing::capture(false);
277        let signals = std::sync::Arc::new(signals::SignalState::default());
278        assert_eq!(signals.on_signal(143), signals::Action::Cancel);
279        out.attach_signals(signals);
280        out.data_line("data");
281        assert_eq!(finish(&mut out, Ok(()), None), 143);
282        assert_eq!(stdout.text(), "", "nothing is written after a signal");
283        assert_eq!(
284            stderr.text(),
285            "\n  ✗ Error: Interrupted; the output was not completely written\n\n"
286        );
287    }
288}