Skip to main content

kaizen/collect/
outcomes.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2//! Pure parsers for `cargo test` / `cargo clippy` text; `run_outcome_measure` at I/O boundary.
3
4use regex::Regex;
5use std::io::Read;
6use std::path::Path;
7use std::process::{Command, Stdio};
8use std::time::{Duration, Instant};
9
10/// Counts from `cargo test` output (final `test result:` line if present).
11#[derive(Debug, Clone, Copy, Default, Eq, PartialEq)]
12pub struct CargoTestCounts {
13    pub passed: i32,
14    pub failed: i32,
15    pub ignored: i32,
16}
17
18/// Parse `test result: ok. 3 passed; 0 failed; 1 ignored; ...` from combined output.
19pub fn parse_cargo_test_summary(text: &str) -> Option<CargoTestCounts> {
20    let re =
21        Regex::new(r"test result:\s*\w+\.\s*(\d+)\s+passed;\s*(\d+)\s+failed;\s*(\d+)\s+ignored")
22            .ok()?;
23    let cap = re.captures(text)?;
24    Some(CargoTestCounts {
25        passed: cap.get(1)?.as_str().parse().ok()?,
26        failed: cap.get(2)?.as_str().parse().ok()?,
27        ignored: cap.get(3)?.as_str().parse().ok()?,
28    })
29}
30
31/// Heuristic: lines starting with `error:` in clippy/build output.
32pub fn parse_clippy_error_count(text: &str) -> i32 {
33    text.lines()
34        .filter(|l| l.trim_start().starts_with("error:"))
35        .count() as i32
36}
37
38/// Result of a workspace outcome run (for DB upsert).
39#[derive(Debug, Default)]
40pub struct OutcomeMeasureResult {
41    pub test_passed: Option<i32>,
42    pub test_failed: Option<i32>,
43    pub test_skipped: Option<i32>,
44    pub lint_errors: Option<i32>,
45    pub measure_error: Option<String>,
46}
47
48struct Captured {
49    combined: String,
50    exit_fail: bool,
51    timed_out: bool,
52}
53
54/// Run `test_cmd` (shell) under `workspace` with `timeout`, optional `lint_cmd` after.
55pub fn run_outcome_measure(
56    workspace: &Path,
57    test_cmd: &str,
58    lint_cmd: Option<&str>,
59    timeout: Duration,
60) -> OutcomeMeasureResult {
61    let test = shell_output(workspace, test_cmd, timeout);
62    let mut m = to_measure_result(&test);
63    if let Some(lc) = lint_cmd.filter(|s| !s.is_empty()) {
64        let lint = shell_output(workspace, lc, timeout);
65        m.lint_errors = Some(parse_clippy_error_count(&lint.combined));
66        if lint.timed_out {
67            m.measure_error = m.measure_error.or(Some("lint command timed out".into()));
68        } else if lint.exit_fail {
69            m.measure_error = m.measure_error.or(Some("lint command failed".into()));
70        }
71    }
72    m
73}
74
75fn to_measure_result(cap: &Captured) -> OutcomeMeasureResult {
76    let counts = parse_cargo_test_summary(&cap.combined);
77    let (tp, tf, tsk) = match counts {
78        Some(c) => (Some(c.passed), Some(c.failed), Some(c.ignored)),
79        None => (None, None, None),
80    };
81    let mut err = cap.then_error();
82    if err.is_none() && cap.exit_fail && counts.is_none() {
83        err = Some("command failed (no test result line)".into());
84    }
85    OutcomeMeasureResult {
86        test_passed: tp,
87        test_failed: tf,
88        test_skipped: tsk,
89        lint_errors: None,
90        measure_error: err,
91    }
92}
93
94impl Captured {
95    fn then_error(&self) -> Option<String> {
96        if self.timed_out {
97            Some("command timed out".into())
98        } else {
99            None
100        }
101    }
102}
103
104fn shell_output(workspace: &Path, cmd: &str, timeout: Duration) -> Captured {
105    let sh = if cfg!(unix) { "/bin/sh" } else { "sh" };
106    let mut c = match Command::new(sh)
107        .arg("-c")
108        .arg(cmd)
109        .current_dir(workspace)
110        .stdout(Stdio::piped())
111        .stderr(Stdio::piped())
112        .spawn()
113    {
114        Ok(ch) => ch,
115        Err(_) => {
116            return Captured {
117                combined: String::new(),
118                exit_fail: true,
119                timed_out: false,
120            };
121        }
122    };
123    let status = wait_limited(&mut c, timeout);
124    let timed_out = status.is_err();
125    let mut combined = String::new();
126    if let Some(mut stdout) = c.stdout.take() {
127        let _ = stdout.read_to_string(&mut combined);
128    }
129    if let Some(mut stderr) = c.stderr.take() {
130        let _ = stderr.read_to_string(&mut combined);
131    }
132    let exit_ok = status.as_ref().map(|s| s.success()).unwrap_or(false);
133    let exit_fail = timed_out || !exit_ok;
134    Captured {
135        combined,
136        exit_fail,
137        timed_out,
138    }
139}
140
141fn wait_limited(
142    child: &mut std::process::Child,
143    timeout: Duration,
144) -> Result<std::process::ExitStatus, ()> {
145    let start = Instant::now();
146    while start.elapsed() < timeout {
147        match child.try_wait() {
148            Ok(Some(s)) => return Ok(s),
149            Ok(None) => std::thread::sleep(Duration::from_millis(100)),
150            Err(_) => return child.wait().map_err(|_| ()),
151        }
152    }
153    let _ = child.kill();
154    child.wait().map_err(|_| ())
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160
161    #[test]
162    fn parse_test_result_line() {
163        let t = "foo\n\ntest result: ok. 2 passed; 0 failed; 1 ignored; 0 measured; blah\n";
164        let c = parse_cargo_test_summary(t).unwrap();
165        assert_eq!(c.passed, 2);
166        assert_eq!(c.failed, 0);
167        assert_eq!(c.ignored, 1);
168    }
169
170    #[test]
171    fn clippy_errors_count() {
172        let t = "error: use of moved value\n\nerror: aborting";
173        assert_eq!(parse_clippy_error_count(t), 2);
174    }
175}