Cover more `main` functionality with native Rust tests.

PiperOrigin-RevId: 477448741
diff --git a/cc_bindings_from_rs/cc_bindings_from_rs.rs b/cc_bindings_from_rs/cc_bindings_from_rs.rs
index 24a9f58..9eb0afe 100644
--- a/cc_bindings_from_rs/cc_bindings_from_rs.rs
+++ b/cc_bindings_from_rs/cc_bindings_from_rs.rs
@@ -139,14 +139,17 @@
     }
 }
 
-// TODO(lukasza): Add end-to-end tests that verify that the exit code is
-// non-zero when:
-// * input contains syntax errors - test coverage for `RunCompiler::run`.
-// * mandatory parameters (e.g. `--h_out`) are missing - test coverage for how
-//   `main` calls `Cmdline::new`.
-// * `--h_out` cannot be written to (in this case, the error message should
-//   include the os-level error + Crubit-level error that includes the file
-//   name) - test coverage for `write_file`.
+/// Main entrypoint that (unlike `main`) doesn't do any intitializations that
+/// should only happen once for the binary (e.g. it doesn't call
+/// `install_ice_hook`) and therefore can be used from the tests module below.
+fn run_with_cmdline_args(args: &[String]) -> anyhow::Result<()> {
+    let cmdline = Cmdline::new(args)?;
+    bindings_driver::RunCompiler::new(&cmdline).run()
+}
+
+// TODO(lukasza): Add end-to-end shell tests that invoke our executable
+// and verify 1) the happy path (zero exit code) and 2) any random
+// error path (non-zero exit code).
 fn main() -> anyhow::Result<()> {
     rustc_driver::init_env_logger("CRUBIT_LOG");
 
@@ -155,15 +158,182 @@
 
     rustc_driver::install_ice_hook();
 
-    // Parse Crubit's cmdline arguments.
-    let cmdline = {
-        // `std::env::args()` will panic if any of the cmdline arguments are not valid
-        // Unicode.  This seems okay.
-        let args = std::env::args().collect_vec();
-        Cmdline::new(&args)?
-    };
+    // `std::env::args()` will panic if any of the cmdline arguments are not valid
+    // Unicode.  This seems okay.
+    let args = std::env::args().collect_vec();
 
-    // Invoke the Rust compiler and call `bindings_main::main` after parsing and
-    // analysis are done.
-    bindings_driver::RunCompiler::new(&cmdline).run()
+    run_with_cmdline_args(&args)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::run_with_cmdline_args;
+
+    use crate::lib::tests::get_sysroot_for_testing;
+    use itertools::Itertools;
+    use std::path::PathBuf;
+    use tempfile::{tempdir, TempDir};
+
+    /// Test data builder (see also
+    /// https://testing.googleblog.com/2018/02/testing-on-toilet-cleanly-create-test.html).
+    struct TestArgs {
+        h_path: Option<String>,
+        extra_crubit_args: Vec<String>,
+        extra_rustc_args: Vec<String>,
+        tempdir: TempDir,
+    }
+
+    impl TestArgs {
+        fn default_args() -> anyhow::Result<Self> {
+            Ok(Self {
+                h_path: None,
+                extra_crubit_args: vec![],
+                extra_rustc_args: vec![],
+                tempdir: tempdir()?,
+            })
+        }
+
+        /// Use the specified `h_path` rather than auto-generating one in
+        /// `self`-managed temporary directory.
+        fn with_h_path(mut self, h_path: &str) -> Self {
+            self.h_path = Some(h_path.to_string());
+            self
+        }
+
+        /// Appends `extra_rustc_args` at the end of the cmdline (i.e. as
+        /// additional rustc args, in addition to `--sysroot`,
+        /// `--crate-type=...`, etc.).
+        fn with_extra_rustc_args<T>(mut self, extra_rustc_args: T) -> Self
+        where
+            T: IntoIterator,
+            T::Item: Into<String>,
+        {
+            self.extra_rustc_args = extra_rustc_args.into_iter().map(|t| t.into()).collect_vec();
+            self
+        }
+
+        /// Appends `extra_crubit_args` before the first `--`.
+        fn with_extra_crubit_args<T>(mut self, extra_crubit_args: T) -> Self
+        where
+            T: IntoIterator,
+            T::Item: Into<String>,
+        {
+            self.extra_crubit_args = extra_crubit_args.into_iter().map(|t| t.into()).collect_vec();
+            self
+        }
+
+        /// Invokes `super::run_with_cmdline_args` with default `test_crate.rs`
+        /// input (and with other default args + args gathered by
+        /// `self`).
+        ///
+        /// Returns the path to the `h_out` file.  The file's lifetime is the
+        /// same as `&self`.
+        fn run(&self) -> anyhow::Result<PathBuf> {
+            let h_path = match self.h_path.as_ref() {
+                None => self.tempdir.path().join("test_crate.rs"),
+                Some(s) => PathBuf::from(s),
+            };
+
+            let rs_input_path = self.tempdir.path().join("test_crate.rs");
+            std::fs::write(
+                &rs_input_path,
+                r#" pub fn public_function() {
+                        private_function()
+                    }
+
+                    fn private_function() {}
+                "#,
+            )?;
+
+            let mut args = vec![
+                "cc_bindings_from_rs_unittest_executable".to_string(),
+                format!("--h_out={}", h_path.display()),
+            ];
+            args.extend(self.extra_crubit_args.iter().cloned());
+            args.extend([
+                "--".to_string(),
+                "--crate-type=lib".to_string(),
+                format!("--sysroot={}", get_sysroot_for_testing().display()),
+                rs_input_path.display().to_string(),
+            ]);
+            args.extend(self.extra_rustc_args.iter().cloned());
+
+            run_with_cmdline_args(&args)?;
+
+            Ok(h_path)
+        }
+    }
+
+    #[test]
+    fn test_happy_path() -> anyhow::Result<()> {
+        let test_args = TestArgs::default_args()?;
+        let h_path = test_args.run().expect("Default args should succeed");
+
+        assert!(h_path.exists());
+        let h_body = std::fs::read_to_string(&h_path)?;
+        assert_eq!(
+            h_body,
+            "// Automatically @generated C++ bindings for the following Rust crate:\n\
+             // test_crate\n\
+             \n\
+             // List of public functions:\n\
+             // public_function\n"
+        );
+        Ok(())
+    }
+
+    #[test]
+    fn test_cmdline_error_propagation() -> anyhow::Result<()> {
+        // Tests that errors from `Cmdline::new` get propagated.  Broader coverage of
+        // various error types can be found in tests in `cmdline.rs`.
+        let err = TestArgs::default_args()?
+            .with_extra_crubit_args(["--unrecognized-crubit-flag"])
+            .run()
+            .expect_err("--unrecognized_crubit_flag should trigger an error");
+
+        let msg = err.to_string();
+        assert_eq!("Unrecognized option: 'unrecognized-crubit-flag'", msg);
+        Ok(())
+    }
+
+    #[test]
+    fn test_rustc_error_propagation() -> anyhow::Result<()> {
+        // Tests that `rustc` errors are propagated.
+        let err = TestArgs::default_args()?
+            .with_extra_rustc_args(["--unrecognized-rustc-flag"])
+            .run()
+            .expect_err("--unrecognized-rustc-flag should trigger an error");
+
+        let msg = err.to_string();
+        assert_eq!("Errors reported by Rust compiler.", msg);
+        Ok(())
+    }
+
+    #[test]
+    fn test_invalid_h_out_path() -> anyhow::Result<()> {
+        // Tests not only the specific problem of an invalid `--h_out` argument, but
+        // also tests that errors from `bindings_main::main` are propagated.
+        let err = TestArgs::default_args()?
+            .with_h_path("../..")
+            .run()
+            .expect_err("Unwriteable --h_out should trigger an error");
+
+        let msg = err.to_string();
+        assert_eq!("Error when writing to ../..", msg);
+        Ok(())
+    }
+
+    #[test]
+    fn test_no_output_file() -> anyhow::Result<()> {
+        // Tests that we stop the compilation midway.
+        let tmpdir = tempdir()?;
+        let out_path = tmpdir.path().join("unexpected_output.o");
+        TestArgs::default_args()?
+            .with_extra_rustc_args(vec!["-o", &out_path.display().to_string()])
+            .run()
+            .expect("No rustc or Crubit errors are expected in this test");
+
+        assert!(!out_path.exists());
+        Ok(())
+    }
 }
diff --git a/cc_bindings_from_rs/cmdline.rs b/cc_bindings_from_rs/cmdline.rs
index ba5e28b..8c81174 100644
--- a/cc_bindings_from_rs/cmdline.rs
+++ b/cc_bindings_from_rs/cmdline.rs
@@ -43,21 +43,26 @@
     use itertools::Itertools;
     use tempfile::tempdir;
 
-    fn new_cmdline(args: &[&str]) -> Result<Cmdline, Fail> {
-        let args = args.iter().map(|s| s.to_string()).collect_vec();
+    fn new_cmdline<'a>(args: impl IntoIterator<Item = &'a str>) -> Result<Cmdline, Fail> {
+        // When `Cmdline::new` is invoked from `main.rs`, it includes not only the
+        // "real" cmdline arguments, but also the name of the executable.
+        let args = std::iter::once("cc_bindings_from_rs_unittest_executable")
+            .chain(args)
+            .map(|s| s.to_string())
+            .collect_vec();
         Cmdline::new(&args)
     }
 
     #[test]
     fn test_h_out_happy_path() -> Result<(), Fail> {
-        let cmdline = new_cmdline(&["--h_out=foo.h"])?;
+        let cmdline = new_cmdline(["--h_out=foo.h"])?;
         assert_eq!(Path::new("foo.h"), cmdline.h_out());
         Ok(())
     }
 
     #[test]
     fn test_h_out_missing() {
-        match new_cmdline(&[]) {
+        match new_cmdline([]) {
             Err(Fail::OptionMissing(arg)) if arg == H_OUT => (),
             other => panic!("Unexpected success or unrecognized error: {:?}", other),
         }
@@ -65,7 +70,7 @@
 
     #[test]
     fn test_h_out_without_arg() {
-        match new_cmdline(&["--h_out"]) {
+        match new_cmdline(["--h_out"]) {
             Err(Fail::ArgumentMissing(arg)) if arg == H_OUT => (),
             other => panic!("Unexpected success or unrecognized error: {:?}", other),
         }
@@ -73,7 +78,7 @@
 
     #[test]
     fn test_h_out_duplicated() {
-        match new_cmdline(&["--h_out=foo.h", "--h_out=bar.h"]) {
+        match new_cmdline(["--h_out=foo.h", "--h_out=bar.h"]) {
             Err(Fail::OptionDuplicated(arg)) if arg == H_OUT => (),
             other => panic!("Unexpected success or unrecognized error: {:?}", other),
         }
@@ -82,10 +87,10 @@
     #[test]
     fn test_rustc_args_happy_path() -> Result<(), Fail> {
         // Note that this test would fail without the `--` separator.
-        let cmdline = new_cmdline(&["--h_out=foo.h", "--", "test.rs", "--crate-type=lib"])?;
+        let cmdline = new_cmdline(["--h_out=foo.h", "--", "test.rs", "--crate-type=lib"])?;
         let rustc_args = cmdline.rustc_args();
         assert!(
-            itertools::equal(&["test.rs", "--crate-type=lib"], rustc_args),
+            itertools::equal(&["cc_bindings_from_rs_unittest_executable", "test.rs", "--crate-type=lib"], rustc_args),
             "rustc_args = {:?}",
             rustc_args
         );
diff --git a/cc_bindings_from_rs/lib.rs b/cc_bindings_from_rs/lib.rs
index 9d3cb2e..482c60f 100644
--- a/cc_bindings_from_rs/lib.rs
+++ b/cc_bindings_from_rs/lib.rs
@@ -74,14 +74,24 @@
 }
 
 #[cfg(test)]
-mod tests {
+pub mod tests {
     use super::GeneratedBindings;
 
     use anyhow::Result;
     use itertools::Itertools;
+    use std::path::PathBuf;
 
     use token_stream_printer::tokens_to_string;
 
+    pub fn get_sysroot_for_testing() -> PathBuf {
+        let runfiles = runfiles::Runfiles::create().unwrap();
+        runfiles.rlocation(if std::env::var("LEGACY_TOOLCHAIN_RUST_TEST").is_ok() {
+            "google3/third_party/unsupported_toolchains/rust/toolchains/nightly"
+        } else {
+            "google3/nowhere/llvm/rust"
+        })
+    }
+
     #[test]
     fn test_get_names_of_exported_fns_public_vs_private() {
         let test_src = r#"
@@ -156,16 +166,9 @@
         // `Mir`, etc. would also trigger code gen).
         let output_types = OutputTypes::new(&[(OutputType::Bitcode, None /* PathBuf */)]);
 
-        let runfiles = runfiles::Runfiles::create().unwrap();
-        let sysroot_path =
-            runfiles.rlocation(if std::env::var("LEGACY_TOOLCHAIN_RUST_TEST").is_ok() {
-                "google3/third_party/unsupported_toolchains/rust/toolchains/nightly"
-            } else {
-                "google3/nowhere/llvm/rust"
-            });
         let opts = Options {
             crate_types: vec![CrateType::Rlib], // Test inputs simulate library crates.
-            maybe_sysroot: Some(sysroot_path),
+            maybe_sysroot: Some(get_sysroot_for_testing()),
             output_types,
             ..Default::default()
         };