Skip to main content

diffler_ci/
exec.rs

1//! The subprocess seam. Adapters shell to provider CLIs (`gh`, `glab`) through
2//! `CommandRunner` so tests can inject recorded output instead of running a live
3//! CLI — this is what makes the adapters fully unit-testable. The runner is
4//! async (tokio process) so adapter futures never block the executor.
5
6use async_trait::async_trait;
7use tokio::process::Command;
8
9use crate::error::{CiError, Result};
10
11/// Runs a CLI and returns its stdout.
12#[async_trait]
13pub trait CommandRunner: Send + Sync {
14    /// `program` is a static name (e.g. `"gh"`) so a missing binary can be
15    /// reported precisely; `args` is the full argument vector.
16    async fn run(&self, program: &'static str, args: &[String]) -> Result<String>;
17}
18
19/// Spawns the real binary on `PATH`.
20pub struct RealRunner;
21
22#[async_trait]
23impl CommandRunner for RealRunner {
24    async fn run(&self, program: &'static str, args: &[String]) -> Result<String> {
25        let output = Command::new(program)
26            .args(args)
27            .output()
28            .await
29            .map_err(|_| CiError::CliMissing(program))?;
30        if !output.status.success() {
31            return Err(CiError::Exec {
32                cmd: format!("{program} {}", args.join(" ")),
33                message: String::from_utf8_lossy(&output.stderr).trim().to_owned(),
34            });
35        }
36        String::from_utf8(output.stdout).map_err(|err| CiError::Parse {
37            what: format!("{program} output"),
38            message: err.to_string(),
39        })
40    }
41}
42
43#[cfg(test)]
44pub(crate) mod test_support {
45    use async_trait::async_trait;
46
47    use super::{CommandRunner, Result};
48
49    /// A `CommandRunner` that returns canned stdout for the first registered key
50    /// that appears as a substring of the joined command (e.g. `"run list"`,
51    /// `"run view"`, `"--log"`, `"api graphql"`). Keys are tried in insertion
52    /// order so the most specific can win.
53    pub struct RecordingRunner {
54        responses: Vec<(&'static str, String)>,
55    }
56
57    impl RecordingRunner {
58        pub fn new(responses: &[(&'static str, &str)]) -> Self {
59            Self {
60                responses: responses
61                    .iter()
62                    .map(|(k, v)| (*k, (*v).to_owned()))
63                    .collect(),
64            }
65        }
66    }
67
68    #[async_trait]
69    impl CommandRunner for RecordingRunner {
70        async fn run(&self, _program: &'static str, args: &[String]) -> Result<String> {
71            let joined = args.join(" ");
72            let hit = self
73                .responses
74                .iter()
75                .find(|(key, _)| joined.contains(key))
76                .map(|(_, value)| value.clone());
77            Ok(hit.unwrap_or_default())
78        }
79    }
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85
86    #[tokio::test]
87    async fn real_runner_reports_a_missing_binary() {
88        let err = RealRunner
89            .run("definitely-not-a-real-binary-xyzzy", &[])
90            .await
91            .unwrap_err();
92        assert!(matches!(err, CiError::CliMissing(_)));
93    }
94}