use std::ffi::{OsStr, OsString};
use std::path::Path;
use std::time::Duration;
use crate::command::Command;
use crate::error::Result;
use crate::result::ProcessResult;
use crate::runner::{JobRunner, ProcessRunner, ProcessRunnerExt};
pub struct CliClient<R: ProcessRunner = JobRunner> {
program: OsString,
runner: R,
timeout: Option<Duration>,
}
impl CliClient<JobRunner> {
pub fn new(program: impl AsRef<OsStr>) -> Self {
Self {
program: program.as_ref().to_os_string(),
runner: JobRunner,
timeout: None,
}
}
}
impl<R: ProcessRunner> CliClient<R> {
pub fn with_runner(program: impl AsRef<OsStr>, runner: R) -> Self {
Self {
program: program.as_ref().to_os_string(),
runner,
timeout: None,
}
}
pub fn default_timeout(mut self, timeout: Duration) -> Self {
self.timeout = Some(timeout);
self
}
pub fn runner(&self) -> &R {
&self.runner
}
pub fn timeout(&self) -> Option<Duration> {
self.timeout
}
pub fn command<I, S>(&self, args: I) -> Command
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
self.apply_timeout(Command::new(&self.program).args(args))
}
pub fn command_in<I, S>(&self, dir: &Path, args: I) -> Command
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
self.apply_timeout(Command::new(&self.program).current_dir(dir).args(args))
}
fn apply_timeout(&self, command: Command) -> Command {
match self.timeout {
Some(timeout) => command.timeout(timeout),
None => command,
}
}
pub async fn text(&self, command: Command) -> Result<String> {
Ok(self
.runner
.checked(&command)
.await?
.into_stdout()
.trim()
.to_owned())
}
pub async fn capture(&self, command: Command) -> Result<ProcessResult<String>> {
self.runner.output(&command).await
}
pub async fn unit(&self, command: Command) -> Result<()> {
self.runner.checked(&command).await.map(drop)
}
pub async fn code(&self, command: Command) -> Result<i32> {
self.runner.exit_code(&command).await
}
pub async fn parse<T>(&self, command: Command, parse: impl FnOnce(&str) -> T) -> Result<T> {
let out = self.runner.checked(&command).await?;
Ok(parse(out.stdout()))
}
pub async fn try_parse<T>(
&self,
command: Command,
parse: impl FnOnce(&str) -> Result<T>,
) -> Result<T> {
let out = self.runner.checked(&command).await?;
parse(out.stdout())
}
}
#[macro_export]
macro_rules! cli_client {
($(#[$meta:meta])* $vis:vis struct $name:ident => $binary:expr) => {
$(#[$meta])*
$vis struct $name<R: $crate::ProcessRunner = $crate::JobRunner> {
core: $crate::CliClient<R>,
}
impl $name<$crate::JobRunner> {
pub fn new() -> Self {
Self { core: $crate::CliClient::new($binary) }
}
}
impl ::core::default::Default for $name<$crate::JobRunner> {
fn default() -> Self {
Self::new()
}
}
impl<R: $crate::ProcessRunner> $name<R> {
pub fn with_runner(runner: R) -> Self {
Self { core: $crate::CliClient::with_runner($binary, runner) }
}
pub fn default_timeout(mut self, timeout: ::core::time::Duration) -> Self {
self.core = self.core.default_timeout(timeout);
self
}
}
};
}
#[cfg(test)]
mod tests {
use std::path::Path;
use std::time::Duration;
use super::*;
use crate::{Error, RecordingRunner, Reply, ScriptedRunner};
crate::cli_client!(struct Demo => "git");
impl<R: ProcessRunner> Demo<R> {
async fn head(&self, dir: &Path) -> Result<String> {
self.core
.text(self.core.command_in(dir, ["rev-parse", "HEAD"]))
.await
}
async fn is_clean(&self, dir: &Path) -> Result<bool> {
Ok(self
.core
.code(self.core.command_in(dir, ["diff", "--quiet"]))
.await?
== 0)
}
async fn branches(&self, dir: &Path) -> Result<Vec<String>> {
self.core
.parse(self.core.command_in(dir, ["branch"]), |s| {
s.lines().map(|l| l.trim().to_owned()).collect()
})
.await
}
}
#[tokio::test]
async fn text_trims_and_drives_the_scripted_runner() {
let demo =
Demo::with_runner(ScriptedRunner::new().on(["rev-parse"], Reply::ok(" abc123\n")));
assert_eq!(demo.head(Path::new(".")).await.unwrap(), "abc123");
}
#[tokio::test]
async fn code_maps_exit_status() {
let demo = Demo::with_runner(ScriptedRunner::new().on(["diff"], Reply::fail(1, "")));
assert!(!demo.is_clean(Path::new(".")).await.unwrap());
}
#[tokio::test]
async fn parse_builds_a_typed_value() {
let demo =
Demo::with_runner(ScriptedRunner::new().on(["branch"], Reply::ok("main\nfeature\n")));
assert_eq!(
demo.branches(Path::new(".")).await.unwrap(),
vec!["main", "feature"]
);
}
#[tokio::test]
async fn try_parse_maps_failure_to_parse_error() {
let client = CliClient::with_runner(
"gh",
ScriptedRunner::new().fallback(Reply::ok("not a number")),
);
let err = client
.try_parse::<u32>(client.command(["x"]), |s| {
s.trim().parse::<u32>().map_err(|e| Error::Parse {
program: "gh".into(),
message: e.to_string(),
})
})
.await
.unwrap_err();
assert!(matches!(err, Error::Parse { .. }), "got {err:?}");
}
#[tokio::test]
async fn when_predicate_reads_public_command_accessors() {
let runner = ScriptedRunner::new()
.when(
|c| c.working_dir() == Some(Path::new("/repo")),
Reply::ok("in-repo"),
)
.fallback(Reply::ok("elsewhere"));
let client = CliClient::with_runner("git", runner);
assert_eq!(
client
.text(client.command_in(Path::new("/repo"), ["status"]))
.await
.unwrap(),
"in-repo"
);
assert_eq!(
client.text(client.command(["status"])).await.unwrap(),
"elsewhere"
);
}
#[tokio::test]
async fn recording_runner_captures_args_cwd_and_absence() {
let rec = RecordingRunner::replying(Reply::ok("https://gh/pr/2\n"));
let client = CliClient::with_runner("gh", &rec);
let _ = client
.text(client.command_in(Path::new("/repo"), ["pr", "create", "--title", "T"]))
.await
.unwrap();
let call = rec.only_call();
assert_eq!(call.cwd.as_deref(), Some(std::ffi::OsStr::new("/repo")));
assert_eq!(call.args_str(), ["pr", "create", "--title", "T"]);
assert!(!call.has_flag("--base"), "no --base flag was passed");
}
#[tokio::test]
async fn code_errors_on_timeout() {
let client = CliClient::with_runner("gh", ScriptedRunner::new().fallback(Reply::timeout()));
assert!(matches!(
client
.code(client.command(["auth", "status"]))
.await
.unwrap_err(),
Error::Timeout { .. }
));
}
#[tokio::test]
async fn default_timeout_is_applied() {
let client = CliClient::new("git").default_timeout(Duration::from_secs(7));
assert_eq!(
client.command(["status"]).configured_timeout(),
Some(Duration::from_secs(7))
);
}
#[test]
fn macro_generates_all_constructors() {
let _real = Demo::new();
let _default = Demo::default();
let _fake =
Demo::with_runner(ScriptedRunner::new()).default_timeout(Duration::from_secs(1));
}
}