Skip to main content

gam_test_support/
cli_harness.rs

1use std::path::{Path, PathBuf};
2use std::process::Command;
3
4/// Path to the `gam` CLI binary for tests that shell out to it.
5///
6/// The CLI was peeled off the engine into the `crates/gam-cli` workspace member
7/// (#1521). Cargo only injects `CARGO_BIN_EXE_<name>` for integration tests that
8/// live in the *same* package as the `[[bin]]`, so the root `gam` package's
9/// integration targets no longer receive `CARGO_BIN_EXE_gam` — `option_env!`
10/// resolves to `None` at the call site and we must locate the binary at runtime.
11///
12/// The workspace shares one `target/` directory, and the CLI binary for a given
13/// profile sits next to the test harness's own profile directory:
14/// `target/<profile>/gam` alongside `target/<profile>/deps/<test>`. Deriving the
15/// path from the running test's own executable therefore tracks whatever profile
16/// the suite was built under (`debug`, `release`, or the quality suite's
17/// `release-dev`) instead of hardcoding `debug`, which only ever existed for the
18/// default `cargo test` runs and left the optimized quality job unable to spawn
19/// the binary at all.
20#[macro_export]
21macro_rules! gam_binary {
22    () => {
23        $crate::cli_harness::resolve_gam_binary(option_env!("CARGO_BIN_EXE_gam"))
24    };
25}
26
27/// Runtime resolver backing the [`gam_binary!`] macro. `compiled_in` is the
28/// call site's compile-time `CARGO_BIN_EXE_gam` (honored when present, e.g. if a
29/// future refactor moves the bin back into this package).
30pub fn resolve_gam_binary(compiled_in: Option<&str>) -> PathBuf {
31    if let Some(path) = compiled_in {
32        return PathBuf::from(path);
33    }
34
35    // Preferred: the binary that matches the profile this test was built under.
36    // current_exe() == target/<profile>/deps/<test-name>, so its grandparent is
37    // the profile directory that also holds `gam`.
38    if let Ok(exe) = std::env::current_exe() {
39        if let Some(profile_dir) = exe.parent().and_then(Path::parent) {
40            let candidate = profile_dir.join("gam");
41            if candidate.is_file() {
42                return candidate;
43            }
44        }
45    }
46
47    // Fallback: scan the well-known profile directories under the workspace
48    // target dir and return the first that exists.
49    let target = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("target");
50    for profile in ["release-dev", "release", "debug"] {
51        let candidate = target.join(profile).join("gam");
52        if candidate.is_file() {
53            return candidate;
54        }
55    }
56
57    // Nothing found: hand back a concrete path so the spawn error names it.
58    target.join("release-dev").join("gam")
59}
60
61pub fn run_or_panic(mut command: Command, label: &str) {
62    let output = command
63        .output()
64        // SAFETY: test-support helper intentionally panics with command context
65        // when the child process cannot even be spawned.
66        .unwrap_or_else(|err| panic!("failed to spawn `{label}`: {err}"));
67    assert!(
68        output.status.success(),
69        "`{label}` failed with status {}\n--- stdout ---\n{}\n--- stderr ---\n{}",
70        output.status,
71        String::from_utf8_lossy(&output.stdout),
72        String::from_utf8_lossy(&output.stderr),
73    );
74}
75
76pub fn run_capture_or_panic(mut command: Command, label: &str) -> String {
77    let output = command
78        .output()
79        // SAFETY: test-support helper intentionally panics with command context
80        // when the child process cannot even be spawned.
81        .unwrap_or_else(|err| panic!("failed to spawn `{label}`: {err}"));
82    if !output.status.success() {
83        // SAFETY: test-support helper intentionally panics with captured child
84        // output so failed CLI invocations preserve the relevant diagnostics.
85        panic!(
86            "`{label}` failed with status {}\n--- stdout ---\n{}\n--- stderr ---\n{}",
87            output.status,
88            String::from_utf8_lossy(&output.stdout),
89            String::from_utf8_lossy(&output.stderr)
90        );
91    }
92    let mut combined = String::from_utf8_lossy(&output.stdout).into_owned();
93    combined.push_str(&String::from_utf8_lossy(&output.stderr));
94    combined
95}
96
97pub fn write_predict_csv_rows<const N: usize, I>(path: &Path, header: [&str; N], rows: I)
98where
99    I: IntoIterator<Item = [String; N]>,
100{
101    let mut writer = csv::Writer::from_path(path).expect("create predict csv");
102    writer.write_record(header).expect("write header");
103    for row in rows {
104        writer
105            .write_record(row.iter().map(String::as_str))
106            .expect("write predict row");
107    }
108    writer.flush().expect("flush predict csv");
109}
110
111pub fn read_prediction_means(path: &Path) -> Vec<f64> {
112    let mut reader = csv::Reader::from_path(path).expect("open predictions csv");
113    let headers = reader.headers().expect("predict csv headers").clone();
114    let mean_idx = headers
115        .iter()
116        .position(|h| h == "mean")
117        .or_else(|| headers.iter().position(|h| h == "linear_predictor"))
118        .unwrap_or_else(|| {
119            // SAFETY: test-support helper intentionally panics with header context
120            panic!("predict csv has neither `mean` nor `linear_predictor` column: {headers:?}")
121        });
122    reader
123        .records()
124        .map(|rec| {
125            let rec = rec.expect("predict csv row");
126            rec[mean_idx]
127                .parse::<f64>()
128                // SAFETY: test-support helper intentionally panics with cell context
129                .unwrap_or_else(|_| panic!("non-numeric prediction: {:?}", &rec[mean_idx]))
130        })
131        .collect()
132}
133
134pub fn fit_then_predict_gaussian(
135    train_path: &Path,
136    formula: &str,
137    model_path: &Path,
138    predict_path: &Path,
139    out_path: &Path,
140) -> Vec<f64> {
141    let mut fit_cmd = Command::new(crate::gam_binary!());
142    fit_cmd
143        .arg("fit")
144        .arg(train_path)
145        .arg(formula)
146        .args(["--family", "gaussian"])
147        .arg("--out")
148        .arg(model_path);
149    run_or_panic(fit_cmd, &format!("gam fit {formula}"));
150    assert!(model_path.is_file(), "gam fit did not write {model_path:?}");
151
152    let mut predict_cmd = Command::new(crate::gam_binary!());
153    predict_cmd
154        .arg("predict")
155        .arg(model_path)
156        .arg(predict_path)
157        .arg("--out")
158        .arg(out_path);
159    run_or_panic(predict_cmd, "gam predict");
160
161    read_prediction_means(out_path)
162}