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};
mod sealed {
use std::ffi::OsStr;
pub trait Sealed {}
impl Sealed for crate::Command {}
impl<S: AsRef<OsStr>, const N: usize> Sealed for [S; N] {}
impl<S: AsRef<OsStr>> Sealed for Vec<S> {}
impl<S: AsRef<OsStr>> Sealed for &[S] {}
}
pub trait IntoCommand<R: ProcessRunner>: sealed::Sealed {
#[doc(hidden)]
fn into_command(self, client: &CliClient<R>) -> Command;
}
impl<R: ProcessRunner> IntoCommand<R> for Command {
fn into_command(self, client: &CliClient<R>) -> Command {
client.apply_defaults(self)
}
}
impl<R: ProcessRunner, S: AsRef<OsStr>, const N: usize> IntoCommand<R> for [S; N] {
fn into_command(self, client: &CliClient<R>) -> Command {
client.command(self)
}
}
impl<R: ProcessRunner, S: AsRef<OsStr>> IntoCommand<R> for Vec<S> {
fn into_command(self, client: &CliClient<R>) -> Command {
client.command(self)
}
}
impl<R: ProcessRunner, S: AsRef<OsStr>> IntoCommand<R> for &[S] {
fn into_command(self, client: &CliClient<R>) -> Command {
client.command(self)
}
}
pub struct CliClient<R: ProcessRunner = JobRunner> {
program: OsString,
runner: R,
timeout: Option<Duration>,
envs: Vec<(OsString, Option<OsString>)>,
cancel: Option<tokio_util::sync::CancellationToken>,
}
impl<R: ProcessRunner> std::fmt::Debug for CliClient<R> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut d = f.debug_struct("CliClient");
d.field("program", &self.program)
.field("timeout", &self.timeout)
.field("env_names", &crate::command::redacted_env_names(&self.envs));
d.field("has_default_cancel", &self.cancel.is_some());
d.finish_non_exhaustive()
}
}
impl CliClient<JobRunner> {
pub fn new(program: impl AsRef<OsStr>) -> Self {
Self {
program: program.as_ref().to_os_string(),
runner: JobRunner,
timeout: None,
envs: Vec::new(),
cancel: 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,
envs: Vec::new(),
cancel: None,
}
}
#[must_use]
pub fn default_timeout(mut self, timeout: Duration) -> Self {
self.timeout = Some(timeout);
self
}
#[must_use]
pub fn default_env(mut self, key: impl AsRef<OsStr>, value: impl AsRef<OsStr>) -> Self {
self.envs.push((
key.as_ref().to_os_string(),
Some(value.as_ref().to_os_string()),
));
self
}
#[must_use]
pub fn default_env_remove(mut self, key: impl AsRef<OsStr>) -> Self {
self.envs.push((key.as_ref().to_os_string(), None));
self
}
#[must_use]
pub fn default_cancel_on(mut self, token: tokio_util::sync::CancellationToken) -> Self {
self.cancel = Some(token);
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_defaults(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_defaults(Command::new(&self.program).current_dir(dir).args(args))
}
fn apply_defaults(&self, mut command: Command) -> Command {
if command.configured_timeout().is_none()
&& let Some(timeout) = self.timeout
{
command = command.timeout(timeout);
}
if command.cancel_token().is_none()
&& let Some(token) = &self.cancel
{
command = command.cancel_on(token.clone());
}
command.fill_default_envs(&self.envs);
command
}
pub async fn run(&self, call: impl IntoCommand<R>) -> Result<String> {
let command = call.into_command(self);
let result = self.runner.checked(&command).await?;
let policy = command.output_buffer_policy();
result.reject_if_truncated(policy.max_lines, policy.max_bytes)?;
Ok(result.into_stdout().trim_end().to_owned())
}
pub async fn checked(&self, call: impl IntoCommand<R>) -> Result<ProcessResult<String>> {
self.runner.checked(&call.into_command(self)).await
}
pub async fn output_string(&self, call: impl IntoCommand<R>) -> Result<ProcessResult<String>> {
self.runner.output_string(&call.into_command(self)).await
}
pub async fn output_bytes(&self, call: impl IntoCommand<R>) -> Result<ProcessResult<Vec<u8>>> {
self.runner.output_bytes(&call.into_command(self)).await
}
pub async fn run_unit(&self, call: impl IntoCommand<R>) -> Result<()> {
self.runner.run_unit(&call.into_command(self)).await
}
pub async fn exit_code(&self, call: impl IntoCommand<R>) -> Result<i32> {
self.runner.exit_code(&call.into_command(self)).await
}
pub async fn probe(&self, call: impl IntoCommand<R>) -> Result<bool> {
self.runner.probe(&call.into_command(self)).await
}
pub async fn first_line<F>(
&self,
call: impl IntoCommand<R>,
predicate: F,
) -> Result<Option<String>>
where
F: Fn(&str) -> bool + Send,
{
self.runner
.first_line(&call.into_command(self), predicate)
.await
}
pub async fn parse<T, F>(&self, call: impl IntoCommand<R>, parse: F) -> Result<T>
where
T: Send,
F: FnOnce(&str) -> T + Send,
{
self.runner.parse(&call.into_command(self), parse).await
}
pub async fn try_parse<T, F>(&self, call: impl IntoCommand<R>, parse: F) -> Result<T>
where
T: Send,
F: FnOnce(&str) -> Result<T> + Send,
{
self.runner.try_parse(&call.into_command(self), parse).await
}
}
#[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
}
pub fn default_env(
mut self,
key: impl ::core::convert::AsRef<::std::ffi::OsStr>,
value: impl ::core::convert::AsRef<::std::ffi::OsStr>,
) -> Self {
self.core = self.core.default_env(key, value);
self
}
pub fn default_env_remove(
mut self,
key: impl ::core::convert::AsRef<::std::ffi::OsStr>,
) -> Self {
self.core = self.core.default_env_remove(key);
self
}
}
impl<R: $crate::ProcessRunner> $name<R> {
pub fn default_cancel_on(mut self, token: $crate::CancellationToken) -> Self {
self.core = self.core.default_cancel_on(token);
self
}
}
};
}
#[cfg(test)]
mod tests {
use std::path::Path;
use std::time::Duration;
use super::*;
use crate::Error;
use crate::testing::{RecordingRunner, Reply, ScriptedRunner};
#[test]
fn debug_redacts_default_env_values_keeping_names() {
let client = CliClient::new("git")
.default_env("API_TOKEN", "topsecret-value")
.default_env_remove("GIT_PAGER");
let dbg = format!("{client:?}");
assert!(
!dbg.contains("topsecret-value"),
"env value must not appear in Debug: {dbg}"
);
assert!(
dbg.contains("API_TOKEN") && dbg.contains("GIT_PAGER"),
"env names should appear: {dbg}"
);
}
crate::cli_client!(struct Demo => "git");
impl<R: ProcessRunner> Demo<R> {
async fn head(&self, dir: &Path) -> Result<String> {
self.core
.run(self.core.command_in(dir, ["rev-parse", "HEAD"]))
.await
}
async fn is_clean(&self, dir: &Path) -> Result<bool> {
Ok(self
.core
.exit_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 run_trims_trailing_whitespace_only() {
let demo = Demo::with_runner(
ScriptedRunner::new().on(["git", "rev-parse"], Reply::ok(" abc123 \n")),
);
assert_eq!(demo.head(Path::new(".")).await.unwrap(), " abc123");
}
#[tokio::test]
async fn exit_code_maps_exit_status() {
let demo = Demo::with_runner(ScriptedRunner::new().on(["git", "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(["git", "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 verbs_accept_args_directly_or_a_customized_command() {
use std::time::Duration;
let runner = ScriptedRunner::new().on(["git", "status"], Reply::ok("clean"));
let client = CliClient::with_runner("git", runner);
assert_eq!(client.run(["status"]).await.unwrap(), "clean");
assert_eq!(client.run(vec!["status"]).await.unwrap(), "clean");
let custom = client.command(["status"]).timeout(Duration::from_secs(3));
assert_eq!(custom.configured_timeout(), Some(Duration::from_secs(3)));
assert_eq!(client.run(custom).await.unwrap(), "clean");
let args = ["status"];
assert_eq!(client.run(&args[..]).await.unwrap(), "clean");
let result = client.checked(["status"]).await.unwrap();
assert_eq!(result.stdout(), "clean");
}
#[tokio::test]
async fn first_line_verb_streams_and_matches() {
let runner =
ScriptedRunner::new().on(["git", "log"], Reply::lines(["one", "two", "three"]));
let client = CliClient::with_runner("git", runner);
let found = client
.first_line(["log"], |line| line.starts_with('t'))
.await
.unwrap();
assert_eq!(found.as_deref(), Some("two"));
}
#[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
.run(client.command_in(Path::new("/repo"), ["status"]))
.await
.unwrap(),
"in-repo"
);
assert_eq!(
client.run(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
.run(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::path::Path::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 exit_code_errors_on_timeout() {
let client = CliClient::with_runner("gh", ScriptedRunner::new().fallback(Reply::timeout()));
assert!(matches!(
client
.exit_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))
);
}
#[tokio::test]
async fn probe_maps_exit_code_to_bool() {
let client = CliClient::with_runner(
"git",
ScriptedRunner::new()
.on(["git", "diff"], Reply::fail(1, ""))
.fallback(Reply::ok("")),
);
assert!(
!client
.probe(client.command(["diff", "--quiet"]))
.await
.unwrap()
);
assert!(client.probe(client.command(["status"])).await.unwrap());
}
#[tokio::test]
async fn default_env_is_applied_to_every_command() {
use std::ffi::OsString;
let client = CliClient::new("git").default_env("GIT_TERMINAL_PROMPT", "0");
for cmd in [
client.command(["status"]),
client.command_in(Path::new("."), ["fetch"]),
] {
assert!(
cmd.env_overrides()
.iter()
.any(|(k, v)| k == "GIT_TERMINAL_PROMPT"
&& v.as_deref() == Some(OsString::from("0").as_os_str())),
"default env missing on built command",
);
}
}
#[tokio::test]
async fn default_env_reaches_the_invocation() {
let rec = RecordingRunner::replying(Reply::ok("ok\n"));
let client = CliClient::with_runner("git", &rec).default_env("GIT_TERMINAL_PROMPT", "0");
let _ = client.run(client.command(["status"])).await.unwrap();
let call = rec.only_call();
assert!(
call.envs
.iter()
.any(|(k, v)| k == "GIT_TERMINAL_PROMPT" && v.is_some()),
"env override did not reach the runner: {:?}",
call.envs
);
}
#[tokio::test]
async fn a_prebuilt_command_passed_to_a_verb_still_gets_client_defaults() {
let token = crate::CancellationToken::new();
let client = CliClient::new("git")
.default_timeout(Duration::from_secs(9))
.default_env("GIT_TERMINAL_PROMPT", "0")
.default_cancel_on(token);
let raw = Command::new("git").args(["push"]);
let filled = raw.into_command(&client);
assert_eq!(
filled.configured_timeout(),
Some(Duration::from_secs(9)),
"the client default timeout fills the gap"
);
assert!(
filled.cancel_token().is_some(),
"the client cancel token reaches it"
);
assert!(
filled
.env_overrides()
.iter()
.any(|(k, _)| k == "GIT_TERMINAL_PROMPT"),
"the client default env reaches it"
);
let explicit = Command::new("git")
.args(["push"])
.timeout(Duration::from_secs(2))
.env("GIT_TERMINAL_PROMPT", "1");
let filled = explicit.into_command(&client);
assert_eq!(
filled.configured_timeout(),
Some(Duration::from_secs(2)),
"an explicit per-command timeout wins"
);
let prompt: Vec<_> = filled
.env_overrides()
.iter()
.filter(|(k, _)| k == "GIT_TERMINAL_PROMPT")
.collect();
assert_eq!(prompt.len(), 1, "no duplicate env op for the same key");
assert_eq!(
prompt[0].1.as_deref(),
Some(std::ffi::OsStr::new("1")),
"the per-command env value wins over the client default"
);
}
#[tokio::test]
async fn prebuilt_command_env_wins_over_a_case_differing_client_default() {
let client = CliClient::new("git").default_env("Path", "from-client");
let cmd = Command::new("git").env("PATH", "from-command");
let filled = cmd.into_command(&client);
let path_ops: Vec<_> = filled
.env_overrides()
.iter()
.filter(|(k, _)| k.to_str().is_some_and(|k| k.eq_ignore_ascii_case("PATH")))
.collect();
#[cfg(windows)]
{
assert_eq!(
path_ops.len(),
1,
"the case-differing client default for the same var is skipped"
);
assert_eq!(
path_ops[0].1.as_deref(),
Some(std::ffi::OsStr::new("from-command")),
"the explicit per-command value wins"
);
}
#[cfg(not(windows))]
{
assert_eq!(
path_ops.len(),
2,
"on Unix PATH and Path are distinct variables — both kept"
);
}
}
#[tokio::test]
async fn default_cancel_on_is_applied_to_every_command() {
let token = crate::CancellationToken::new();
let client = CliClient::new("git").default_cancel_on(token);
for cmd in [
client.command(["status"]),
client.command_in(Path::new("."), ["fetch"]),
] {
assert!(
cmd.cancel_token().is_some(),
"default token missing on built command"
);
}
assert!(format!("{client:?}").contains("has_default_cancel: true"));
}
#[tokio::test(start_paused = true)]
async fn per_command_cancel_on_overrides_the_default() {
use crate::CancellationToken;
let default_token = CancellationToken::new();
let explicit = CancellationToken::new();
let client = CliClient::with_runner("gh", ScriptedRunner::new().fallback(Reply::pending()))
.default_cancel_on(default_token.clone());
let cmd = client.command(["run", "watch"]).cancel_on(explicit.clone());
let call = client.output_string(cmd);
tokio::pin!(call);
default_token.cancel();
assert!(
tokio::time::timeout(Duration::from_secs(3600), &mut call)
.await
.is_err(),
"the replaced default token must not cancel the call"
);
explicit.cancel();
let err = tokio::time::timeout(Duration::from_secs(3600), call)
.await
.expect("the explicit token must resolve the call")
.expect_err("explicit token cancels");
assert!(matches!(err, Error::Cancelled { .. }), "got {err:?}");
}
#[tokio::test(start_paused = true)]
async fn acceptance_pending_reply_with_client_default_cancel() {
use crate::CancellationToken;
let token = CancellationToken::new();
let rec = RecordingRunner::new(
ScriptedRunner::new().on(["gh", "run", "watch"], Reply::pending()),
);
let client = CliClient::with_runner("gh", &rec).default_cancel_on(token.clone());
let call = client.output_string(client.command(["run", "watch", "123"]));
tokio::pin!(call);
assert!(
tokio::time::timeout(Duration::from_secs(3600), &mut call)
.await
.is_err(),
"must not resolve before the token fires"
);
token.cancel();
match tokio::time::timeout(Duration::from_secs(3600), call)
.await
.expect("the cancelled token must resolve the call")
{
Err(Error::Cancelled { program }) => assert_eq!(program, "gh"),
other => panic!("expected Error::Cancelled, got {other:?}"),
}
assert_eq!(rec.only_call().args_str(), ["run", "watch", "123"]);
}
#[test]
fn macro_emits_default_cancel_on() {
let _client = Demo::with_runner(ScriptedRunner::new())
.default_cancel_on(crate::CancellationToken::new());
}
#[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))
.default_env("GIT_TERMINAL_PROMPT", "0")
.default_env_remove("GIT_PAGER");
}
}