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