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