Skip to main content

testing_conventions/
entrypoint.rs

1//! The binary target's entry point. It lives here rather than in `src/main.rs` so the binary
2//! root stays declaration-only: Rust requires a `main` in that file, and a `fn main` with a body
3//! is a unit-gate subject whose own `#[cfg(test)]` module the unit suite would have to run. A
4//! re-export carries no function, so the gates read `main.rs` as declarations and the logic sits
5//! here, under test.
6
7use std::process::ExitCode;
8
9/// Run the CLI over the process arguments and map the outcome onto an exit code.
10///
11/// `cfg(not(test))` keeps this out of the unit tier's coverage report. A test cannot execute it:
12/// `cargo test` replaces a binary's `main` with the harness's own, and calling it directly would
13/// run the real CLI over the harness's argv — `cargo test install` would write to the repo. It
14/// carries no decision to cover either; [`report`] holds all of them and the e2e tier runs the
15/// real binary. Only the process-boundary argv read lives here.
16#[cfg(not(test))]
17pub fn main() -> ExitCode {
18    report(crate::run(std::env::args_os()))
19}
20
21/// The exit code a finished [`crate::run`] earns, after printing whatever the caller should see.
22///
23/// A clap failure renders itself — `--help` and `--version` are clap "errors" that belong on
24/// stdout with code 0, a usage mistake on stderr with code 2 — so it prints itself and reports
25/// its own code. `print` plus `exit_code` is what `clap::Error::exit` does either side of
26/// `process::exit`; splitting them keeps this function callable from a test.
27fn report(result: anyhow::Result<i32>) -> ExitCode {
28    match result {
29        Ok(code) => ExitCode::from(code as u8),
30        Err(err) => match err.downcast_ref::<clap::Error>() {
31            Some(clap_err) => {
32                let _ = clap_err.print();
33                ExitCode::from(clap_err.exit_code() as u8)
34            }
35            None => {
36                // `{err:#}` prints the whole anyhow chain, so a wrapped failure keeps its context.
37                eprintln!("error: {err:#}");
38                ExitCode::from(1)
39            }
40        },
41    }
42}
43
44#[cfg(test)]
45mod tests {
46    use super::*;
47    use clap::error::ErrorKind;
48
49    #[test]
50    fn a_success_code_becomes_that_exit_code() {
51        assert_eq!(report(Ok(0)), ExitCode::from(0));
52        assert_eq!(report(Ok(3)), ExitCode::from(3));
53    }
54
55    #[test]
56    fn a_plain_failure_exits_one() {
57        assert_eq!(
58            report(Err(anyhow::anyhow!("the gate found violations"))),
59            ExitCode::from(1)
60        );
61    }
62
63    #[test]
64    fn a_usage_mistake_carries_claps_own_exit_code() {
65        let clap_err = clap::Error::new(ErrorKind::InvalidValue);
66        let expected = clap_err.exit_code() as u8;
67
68        assert_eq!(report(Err(clap_err.into())), ExitCode::from(expected));
69        assert_eq!(expected, 2, "clap reports a usage mistake as 2");
70    }
71
72    #[test]
73    fn a_help_request_exits_zero_rather_than_as_a_failure() {
74        let help = clap::Error::new(ErrorKind::DisplayHelp);
75
76        assert_eq!(report(Err(help.into())), ExitCode::from(0));
77    }
78}