Skip to main content

processkit/
client.rs

1//! [`CliClient`] — a small, reusable core for building typed wrappers around an
2//! external CLI tool (`git`, `jj`, `gh`, …).
3//!
4//! It owns the program name, a [`ProcessRunner`], and an optional default
5//! timeout; hands back preconfigured [`Command`]s; and provides the terminal
6//! run/parse helpers a wrapper otherwise repeats. A wrapper then reduces to a
7//! typed facade over its parsers, with no process plumbing — and is mockable by
8//! construction, since the runner is injectable (pass a
9//! [`ScriptedRunner`](crate::testing::ScriptedRunner) in tests).
10//!
11//! The [`cli_client!`](crate::cli_client) macro scaffolds the wrapper struct and
12//! its constructors.
13
14use std::ffi::{OsStr, OsString};
15use std::path::Path;
16use std::time::Duration;
17
18use crate::command::Command;
19use crate::error::Result;
20use crate::result::ProcessResult;
21use crate::runner::{JobRunner, ProcessRunner, ProcessRunnerExt};
22
23mod sealed {
24    use std::ffi::OsStr;
25    pub trait Sealed {}
26    impl Sealed for crate::Command {}
27    impl<S: AsRef<OsStr>, const N: usize> Sealed for [S; N] {}
28    impl<S: AsRef<OsStr>> Sealed for Vec<S> {}
29    impl<S: AsRef<OsStr>> Sealed for &[S] {}
30}
31
32/// What a [`CliClient`] verb accepts: either an **argument list** — built
33/// into a [`Command`] for the client's program with its defaults (timeout, env,
34/// cancellation) applied — or a ready-made `Command`, run as-is.
35///
36/// This lets one verb serve both the common `git.run(["status"])` and the
37/// customized `git.run(git.command(["push"]).timeout(d))`, removing the
38/// double-mention of the older `git.run(git.command([…]))` form. Implemented for
39/// argument containers (`[S; N]`, `Vec<S>`, `&[S]` where `S: AsRef<OsStr>`) and
40/// for [`Command`]; **sealed** (not implementable downstream).
41///
42/// Either form receives the client's defaults (timeout / env /
43/// [`default_cancel_on`](CliClient::default_cancel_on)): an argument list builds a
44/// fresh command with them, and a ready-made [`Command`] has them **filled into
45/// the gaps it left** — its own explicit settings win, but a client-wide cancel
46/// token / timeout / env still reaches a per-call-customized command rather than
47/// being silently dropped.
48pub trait IntoCommand<R: ProcessRunner>: sealed::Sealed {
49    /// Build the [`Command`] to run for `client` — used by the verbs.
50    #[doc(hidden)]
51    fn into_command(self, client: &CliClient<R>) -> Command;
52}
53
54impl<R: ProcessRunner> IntoCommand<R> for Command {
55    fn into_command(self, client: &CliClient<R>) -> Command {
56        // Fill defaults into the caller-supplied command's gaps; its explicit
57        // settings win. Idempotent if `command()` already applied them.
58        client.apply_defaults(self)
59    }
60}
61
62impl<R: ProcessRunner, S: AsRef<OsStr>, const N: usize> IntoCommand<R> for [S; N] {
63    fn into_command(self, client: &CliClient<R>) -> Command {
64        client.command(self)
65    }
66}
67
68impl<R: ProcessRunner, S: AsRef<OsStr>> IntoCommand<R> for Vec<S> {
69    fn into_command(self, client: &CliClient<R>) -> Command {
70        client.command(self)
71    }
72}
73
74impl<R: ProcessRunner, S: AsRef<OsStr>> IntoCommand<R> for &[S] {
75    fn into_command(self, client: &CliClient<R>) -> Command {
76        client.command(self)
77    }
78}
79
80/// Owns a CLI tool's program name, [`ProcessRunner`], and default timeout, and
81/// builds + runs [`Command`]s against them.
82///
83/// Generic over the runner so tests inject a fake; [`new`](Self::new) uses the
84/// real job-backed [`JobRunner`].
85pub struct CliClient<R: ProcessRunner = JobRunner> {
86    program: OsString,
87    runner: R,
88    timeout: Option<Duration>,
89    /// Environment overrides applied to every built command (`None` = remove),
90    /// in registration order — e.g. `GIT_TERMINAL_PROMPT=0` set once instead of
91    /// on every probe.
92    envs: Vec<(OsString, Option<OsString>)>,
93    /// A cancellation token applied to every built command (see
94    /// [`default_cancel_on`](Self::default_cancel_on)).
95    cancel: Option<tokio_util::sync::CancellationToken>,
96}
97
98// Manual Debug: no `Debug` bound on R; env values omitted per secret-safety rule.
99impl<R: ProcessRunner> std::fmt::Debug for CliClient<R> {
100    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
101        let mut d = f.debug_struct("CliClient");
102        d.field("program", &self.program)
103            .field("timeout", &self.timeout)
104            .field("env_names", &crate::command::redacted_env_names(&self.envs));
105        d.field("has_default_cancel", &self.cancel.is_some());
106        d.finish_non_exhaustive()
107    }
108}
109
110impl CliClient<JobRunner> {
111    /// A client driving `program` through the real job-backed runner.
112    pub fn new(program: impl AsRef<OsStr>) -> Self {
113        Self {
114            program: program.as_ref().to_os_string(),
115            runner: JobRunner,
116            timeout: None,
117            envs: Vec::new(),
118            cancel: None,
119        }
120    }
121}
122
123impl<R: ProcessRunner> CliClient<R> {
124    /// A client driving `program` through `runner` — pass a fake in tests.
125    pub fn with_runner(program: impl AsRef<OsStr>, runner: R) -> Self {
126        Self {
127            program: program.as_ref().to_os_string(),
128            runner,
129            timeout: None,
130            envs: Vec::new(),
131            cancel: None,
132        }
133    }
134
135    /// Apply a default timeout to every command this client builds.
136    #[must_use]
137    pub fn default_timeout(mut self, timeout: Duration) -> Self {
138        self.timeout = Some(timeout);
139        self
140    }
141
142    /// Set an environment variable on every command this client builds — e.g.
143    /// `GIT_TERMINAL_PROMPT=0` so a probe can never block on a credential
144    /// prompt. Per-command [`Command::env`] still works and is layered after.
145    #[must_use]
146    pub fn default_env(mut self, key: impl AsRef<OsStr>, value: impl AsRef<OsStr>) -> Self {
147        self.envs.push((
148            key.as_ref().to_os_string(),
149            Some(value.as_ref().to_os_string()),
150        ));
151        self
152    }
153
154    /// Remove an inherited environment variable on every command this client
155    /// builds.
156    #[must_use]
157    pub fn default_env_remove(mut self, key: impl AsRef<OsStr>) -> Self {
158        self.envs.push((key.as_ref().to_os_string(), None));
159        self
160    }
161
162    /// Cancel every command this client builds when `token` fires: each built
163    /// command gets [`cancel_on(token.clone())`](Command::cancel_on), so
164    /// cancelling the token kills every in-flight run of **this client** (the
165    /// whole tree, surfacing [`Error::Cancelled`](crate::Error::Cancelled) on
166    /// the awaiting call — same semantics as the per-command builder).
167    ///
168    /// **Precedence:** a per-command [`Command::cancel_on`] chained on a built
169    /// command *replaces* the default (an explicit setting beats a default,
170    /// like a per-command [`timeout`](Command::timeout) after
171    /// [`default_timeout`](Self::default_timeout)). When both sources should
172    /// fire, wire it explicitly — derive a child of the default
173    /// (`let c = default.child_token()`), hand the command `cancel_on(c.clone())`,
174    /// and have the second source call `c.cancel()` — or simply build a
175    /// dedicated client per scope.
176    ///
177    /// Scope cancellation by client, not by call: clients are cheap — build
178    /// one per cancellable scope and hand each its own token.
179    #[must_use]
180    pub fn default_cancel_on(mut self, token: tokio_util::sync::CancellationToken) -> Self {
181        self.cancel = Some(token);
182        self
183    }
184
185    /// The injected runner — for direct [`ProcessRunner`]/[`ProcessRunnerExt`] use.
186    pub fn runner(&self) -> &R {
187        &self.runner
188    }
189
190    /// The default timeout, if one was set.
191    pub fn timeout(&self) -> Option<Duration> {
192        self.timeout
193    }
194
195    /// A [`Command`] for `program <args>` in the current directory, defaults
196    /// (timeout, env) pre-applied. Chain more builders (`.arg`, `.stdin`, …) for
197    /// dynamic-argument commands.
198    pub fn command<I, S>(&self, args: I) -> Command
199    where
200        I: IntoIterator<Item = S>,
201        S: AsRef<OsStr>,
202    {
203        self.apply_defaults(Command::new(&self.program).args(args))
204    }
205
206    /// A [`Command`] for `program <args>` run in `dir`, defaults (timeout, env)
207    /// pre-applied.
208    pub fn command_in<I, S>(&self, dir: &Path, args: I) -> Command
209    where
210        I: IntoIterator<Item = S>,
211        S: AsRef<OsStr>,
212    {
213        self.apply_defaults(Command::new(&self.program).current_dir(dir).args(args))
214    }
215
216    /// Fill the client's defaults into `command`, but only where the command has
217    /// not set them itself — so a fresh [`command()`](Self::command) (no settings)
218    /// gets every default, while a caller-supplied [`Command`] passed straight to
219    /// a verb keeps its own explicit timeout/cancel/env and only fills the gaps
220    /// (so a client-wide cancel token / timeout / env is not silently dropped
221    /// when you customize a single call). Idempotent — running it twice (a verb
222    /// applies it to a command that `command()` already defaulted) is a no-op
223    /// the second time.
224    fn apply_defaults(&self, mut command: Command) -> Command {
225        if command.configured_timeout().is_none()
226            && let Some(timeout) = self.timeout
227        {
228            command = command.timeout(timeout);
229        }
230        if command.cancel_token().is_none()
231            && let Some(token) = &self.cancel
232        {
233            command = command.cancel_on(token.clone());
234        }
235        command.fill_default_envs(&self.envs);
236        command
237    }
238
239    /// Run, returning stdout (trailing whitespace trimmed) on success (errors on
240    /// a non-zero exit) — the same verb, with the same semantics, as
241    /// [`Command::run`](crate::Command::run) and
242    /// [`ProcessRunnerExt::run`](crate::ProcessRunnerExt::run). Trims with
243    /// `trim_end`: the trailing newline is noise, but leading whitespace can be
244    /// significant.
245    ///
246    /// Accepts an argument list (`git.run(["status"])`) or a customized
247    /// [`Command`] (`git.run(git.command(["push"]).timeout(d))`) — see
248    /// [`IntoCommand`].
249    pub async fn run(&self, call: impl IntoCommand<R>) -> Result<String> {
250        let command = call.into_command(self);
251        let result = self.runner.checked(&command).await?;
252        let policy = command.output_buffer_policy();
253        result.reject_if_truncated(policy.max_lines, policy.max_bytes)?;
254        Ok(result.into_stdout().trim_end().to_owned())
255    }
256
257    /// Run, requiring an accepted exit, and return the full
258    /// [`ProcessResult`] (untrimmed) — the [`CliClient`] analogue of
259    /// [`ProcessRunnerExt::checked`](crate::ProcessRunnerExt::checked); the
260    /// building block when you need the whole result after success-checking.
261    pub async fn checked(&self, call: impl IntoCommand<R>) -> Result<ProcessResult<String>> {
262        self.runner.checked(&call.into_command(self)).await
263    }
264
265    /// Run, capturing the full result without erroring on a non-zero exit — the
266    /// same verb as [`ProcessRunner::output_string`].
267    pub async fn output_string(&self, call: impl IntoCommand<R>) -> Result<ProcessResult<String>> {
268        self.runner.output_string(&call.into_command(self)).await
269    }
270
271    /// Run, capturing stdout as **raw bytes** (stderr as text), without erroring
272    /// on a non-zero exit — the same verb as [`ProcessRunner::output_bytes`].
273    /// For binary tools whose stdout is not UTF-8.
274    pub async fn output_bytes(&self, call: impl IntoCommand<R>) -> Result<ProcessResult<Vec<u8>>> {
275        self.runner.output_bytes(&call.into_command(self)).await
276    }
277
278    /// Run for the side effect, discarding stdout (errors on a non-zero exit) —
279    /// the same verb as
280    /// [`ProcessRunnerExt::run_unit`](crate::ProcessRunnerExt::run_unit).
281    pub async fn run_unit(&self, call: impl IntoCommand<R>) -> Result<()> {
282        self.runner.run_unit(&call.into_command(self)).await
283    }
284
285    /// Run and return the exit code (e.g. `git diff --quiet`, `gh auth status`)
286    /// — never errors on a non-zero exit. The same verb as
287    /// [`Command::exit_code`](crate::Command::exit_code).
288    pub async fn exit_code(&self, call: impl IntoCommand<R>) -> Result<i32> {
289        self.runner.exit_code(&call.into_command(self)).await
290    }
291
292    /// Run a predicate and read its exit code as a boolean: exit `0` →
293    /// `Ok(true)`, exit `1` → `Ok(false)`, anything else → `Err`. Collapses the
294    /// `match code { 0 => …, 1 => …, _ => Err }` idiom for commands whose exit
295    /// code is the answer (`git diff --quiet`, `git show-ref --verify --quiet`,
296    /// `grep -q`, …); other codes / timeout / signal-kill all error.
297    pub async fn probe(&self, call: impl IntoCommand<R>) -> Result<bool> {
298        self.runner.probe(&call.into_command(self)).await
299    }
300
301    /// Stream stdout and return the first line matching `predicate` (`None` if
302    /// the stream ends first) — the [`CliClient`] analogue of
303    /// [`ProcessRunnerExt::first_line`](crate::ProcessRunnerExt::first_line),
304    /// bounded by the command's [`timeout`](crate::Command::timeout).
305    pub async fn first_line<F>(
306        &self,
307        call: impl IntoCommand<R>,
308        predicate: F,
309    ) -> Result<Option<String>>
310    where
311        F: Fn(&str) -> bool + Send,
312    {
313        self.runner
314            .first_line(&call.into_command(self), predicate)
315            .await
316    }
317
318    /// Run (errors on a non-zero exit) and feed stdout to an infallible
319    /// `parse` — the shape of git/jj struct-returning commands. Fails loud on a
320    /// bounded-buffer truncation. Delegates to
321    /// [`ProcessRunnerExt::parse`](crate::ProcessRunnerExt::parse).
322    pub async fn parse<T, F>(&self, call: impl IntoCommand<R>, parse: F) -> Result<T>
323    where
324        T: Send,
325        F: FnOnce(&str) -> T + Send,
326    {
327        self.runner.parse(&call.into_command(self), parse).await
328    }
329
330    /// Run (errors on a non-zero exit) and feed stdout to a *fallible* `parse` —
331    /// the shape of JSON deserialization, where a parse failure becomes
332    /// [`Error::Parse`](crate::Error::Parse). Fails loud on a bounded-buffer
333    /// truncation. Delegates to
334    /// [`ProcessRunnerExt::try_parse`](crate::ProcessRunnerExt::try_parse).
335    pub async fn try_parse<T, F>(&self, call: impl IntoCommand<R>, parse: F) -> Result<T>
336    where
337        T: Send,
338        F: FnOnce(&str) -> Result<T> + Send,
339    {
340        self.runner.try_parse(&call.into_command(self), parse).await
341    }
342}
343
344/// Scaffold a typed CLI-wrapper struct around a [`CliClient`].
345///
346/// Expands `cli_client!(pub struct Git => "git");` into a
347/// `struct Git<R: ProcessRunner = JobRunner> { core: CliClient<R> }` with
348/// `new()` (real runner), a `Default` impl, `with_runner(runner)`, and
349/// `default_timeout(d)`. Implement the tool's typed methods on it, delegating to
350/// `self.core` — see the crate docs for an example.
351///
352/// This macro is **committed public API**. Because it is `#[macro_export]`,
353/// it lives at the crate root and is a stable part of the surface — the
354/// supported scaffold for typed CLI wrappers. The hand-rolled equivalent (a
355/// struct wrapping [`CliClient`]) remains valid and interchangeable.
356#[macro_export]
357macro_rules! cli_client {
358    ($(#[$meta:meta])* $vis:vis struct $name:ident => $binary:expr) => {
359        $(#[$meta])*
360        $vis struct $name<R: $crate::ProcessRunner = $crate::JobRunner> {
361            core: $crate::CliClient<R>,
362        }
363
364        impl $name<$crate::JobRunner> {
365            /// Create a client driving the real job-backed runner.
366            pub fn new() -> Self {
367                Self { core: $crate::CliClient::new($binary) }
368            }
369        }
370
371        impl ::core::default::Default for $name<$crate::JobRunner> {
372            fn default() -> Self {
373                Self::new()
374            }
375        }
376
377        impl<R: $crate::ProcessRunner> $name<R> {
378            /// Create a client driving `runner` — inject a fake in tests.
379            pub fn with_runner(runner: R) -> Self {
380                Self { core: $crate::CliClient::with_runner($binary, runner) }
381            }
382
383            /// Apply a default timeout to every command this client builds.
384            pub fn default_timeout(mut self, timeout: ::core::time::Duration) -> Self {
385                self.core = self.core.default_timeout(timeout);
386                self
387            }
388
389            /// Set an environment variable on every command this client builds
390            /// (e.g. `GIT_TERMINAL_PROMPT=0`).
391            pub fn default_env(
392                mut self,
393                key: impl ::core::convert::AsRef<::std::ffi::OsStr>,
394                value: impl ::core::convert::AsRef<::std::ffi::OsStr>,
395            ) -> Self {
396                self.core = self.core.default_env(key, value);
397                self
398            }
399
400            /// Remove an inherited environment variable on every command this
401            /// client builds.
402            pub fn default_env_remove(
403                mut self,
404                key: impl ::core::convert::AsRef<::std::ffi::OsStr>,
405            ) -> Self {
406                self.core = self.core.default_env_remove(key);
407                self
408            }
409        }
410
411        impl<R: $crate::ProcessRunner> $name<R> {
412            /// Cancel every command this client builds when `token` fires (a
413            /// per-command `cancel_on` replaces the default — see
414            /// `CliClient::default_cancel_on`).
415            pub fn default_cancel_on(mut self, token: $crate::CancellationToken) -> Self {
416                self.core = self.core.default_cancel_on(token);
417                self
418            }
419        }
420    };
421}
422
423#[cfg(test)]
424mod tests {
425    use std::path::Path;
426    use std::time::Duration;
427
428    use super::*;
429    use crate::Error;
430    use crate::testing::{RecordingRunner, Reply, ScriptedRunner};
431
432    #[test]
433    fn debug_redacts_default_env_values_keeping_names() {
434        let client = CliClient::new("git")
435            .default_env("API_TOKEN", "topsecret-value")
436            .default_env_remove("GIT_PAGER");
437        let dbg = format!("{client:?}");
438        assert!(
439            !dbg.contains("topsecret-value"),
440            "env value must not appear in Debug: {dbg}"
441        );
442        assert!(
443            dbg.contains("API_TOKEN") && dbg.contains("GIT_PAGER"),
444            "env names should appear: {dbg}"
445        );
446    }
447
448    crate::cli_client!(struct Demo => "git");
449
450    impl<R: ProcessRunner> Demo<R> {
451        async fn head(&self, dir: &Path) -> Result<String> {
452            self.core
453                .run(self.core.command_in(dir, ["rev-parse", "HEAD"]))
454                .await
455        }
456        async fn is_clean(&self, dir: &Path) -> Result<bool> {
457            Ok(self
458                .core
459                .exit_code(self.core.command_in(dir, ["diff", "--quiet"]))
460                .await?
461                == 0)
462        }
463        async fn branches(&self, dir: &Path) -> Result<Vec<String>> {
464            self.core
465                .parse(self.core.command_in(dir, ["branch"]), |s| {
466                    s.lines().map(|l| l.trim().to_owned()).collect()
467                })
468                .await
469        }
470    }
471
472    #[tokio::test]
473    async fn run_trims_trailing_whitespace_only() {
474        let demo = Demo::with_runner(
475            ScriptedRunner::new().on(["git", "rev-parse"], Reply::ok("  abc123 \n")),
476        );
477        assert_eq!(demo.head(Path::new(".")).await.unwrap(), "  abc123");
478    }
479
480    #[tokio::test]
481    async fn exit_code_maps_exit_status() {
482        let demo = Demo::with_runner(ScriptedRunner::new().on(["git", "diff"], Reply::fail(1, "")));
483        assert!(!demo.is_clean(Path::new(".")).await.unwrap());
484    }
485
486    #[tokio::test]
487    async fn parse_builds_a_typed_value() {
488        let demo = Demo::with_runner(
489            ScriptedRunner::new().on(["git", "branch"], Reply::ok("main\nfeature\n")),
490        );
491        assert_eq!(
492            demo.branches(Path::new(".")).await.unwrap(),
493            vec!["main", "feature"]
494        );
495    }
496
497    #[tokio::test]
498    async fn try_parse_maps_failure_to_parse_error() {
499        let client = CliClient::with_runner(
500            "gh",
501            ScriptedRunner::new().fallback(Reply::ok("not a number")),
502        );
503        let err = client
504            .try_parse::<u32, _>(client.command(["x"]), |s| {
505                s.trim().parse::<u32>().map_err(|e| Error::Parse {
506                    program: "gh".into(),
507                    message: e.to_string(),
508                })
509            })
510            .await
511            .unwrap_err();
512        assert!(matches!(err, Error::Parse { .. }), "got {err:?}");
513    }
514
515    #[tokio::test]
516    async fn verbs_accept_args_directly_or_a_customized_command() {
517        use std::time::Duration;
518        let runner = ScriptedRunner::new().on(["git", "status"], Reply::ok("clean"));
519        let client = CliClient::with_runner("git", runner);
520
521        // Argument list — the program comes from the client, defaults applied.
522        assert_eq!(client.run(["status"]).await.unwrap(), "clean");
523        assert_eq!(client.run(vec!["status"]).await.unwrap(), "clean");
524        // A customized Command runs through the same verb (pass-through).
525        let custom = client.command(["status"]).timeout(Duration::from_secs(3));
526        assert_eq!(custom.configured_timeout(), Some(Duration::from_secs(3)));
527        assert_eq!(client.run(custom).await.unwrap(), "clean");
528        let args = ["status"];
529        assert_eq!(client.run(&args[..]).await.unwrap(), "clean");
530        let result = client.checked(["status"]).await.unwrap();
531        assert_eq!(result.stdout(), "clean");
532    }
533
534    #[tokio::test]
535    async fn first_line_verb_streams_and_matches() {
536        let runner =
537            ScriptedRunner::new().on(["git", "log"], Reply::lines(["one", "two", "three"]));
538        let client = CliClient::with_runner("git", runner);
539        let found = client
540            .first_line(["log"], |line| line.starts_with('t'))
541            .await
542            .unwrap();
543        assert_eq!(found.as_deref(), Some("two"));
544    }
545
546    #[tokio::test]
547    async fn when_predicate_reads_public_command_accessors() {
548        // Proves `Command`'s accessors are public enough for an external
549        // `ScriptedRunner::when` predicate to inspect the command.
550        let runner = ScriptedRunner::new()
551            .when(
552                |c| c.working_dir() == Some(Path::new("/repo")),
553                Reply::ok("in-repo"),
554            )
555            .fallback(Reply::ok("elsewhere"));
556        let client = CliClient::with_runner("git", runner);
557        assert_eq!(
558            client
559                .run(client.command_in(Path::new("/repo"), ["status"]))
560                .await
561                .unwrap(),
562            "in-repo"
563        );
564        assert_eq!(
565            client.run(client.command(["status"])).await.unwrap(),
566            "elsewhere"
567        );
568    }
569
570    #[tokio::test]
571    async fn recording_runner_captures_args_cwd_and_absence() {
572        let rec = RecordingRunner::replying(Reply::ok("https://gh/pr/2\n"));
573        let client = CliClient::with_runner("gh", &rec);
574        let _ = client
575            .run(client.command_in(Path::new("/repo"), ["pr", "create", "--title", "T"]))
576            .await
577            .unwrap();
578
579        let call = rec.only_call();
580        assert_eq!(call.cwd.as_deref(), Some(std::path::Path::new("/repo")));
581        assert_eq!(call.args_str(), ["pr", "create", "--title", "T"]);
582        assert!(!call.has_flag("--base"), "no --base flag was passed");
583    }
584
585    #[tokio::test]
586    async fn exit_code_errors_on_timeout() {
587        let client = CliClient::with_runner("gh", ScriptedRunner::new().fallback(Reply::timeout()));
588        assert!(matches!(
589            client
590                .exit_code(client.command(["auth", "status"]))
591                .await
592                .unwrap_err(),
593            Error::Timeout { .. }
594        ));
595    }
596
597    #[tokio::test]
598    async fn default_timeout_is_applied() {
599        let client = CliClient::new("git").default_timeout(Duration::from_secs(7));
600        assert_eq!(
601            client.command(["status"]).configured_timeout(),
602            Some(Duration::from_secs(7))
603        );
604    }
605
606    #[tokio::test]
607    async fn probe_maps_exit_code_to_bool() {
608        let client = CliClient::with_runner(
609            "git",
610            ScriptedRunner::new()
611                .on(["git", "diff"], Reply::fail(1, ""))
612                .fallback(Reply::ok("")),
613        );
614        // `git diff --quiet` exits 1 (dirty) -> false; anything else (0) -> true.
615        assert!(
616            !client
617                .probe(client.command(["diff", "--quiet"]))
618                .await
619                .unwrap()
620        );
621        assert!(client.probe(client.command(["status"])).await.unwrap());
622    }
623
624    #[tokio::test]
625    async fn default_env_is_applied_to_every_command() {
626        use std::ffi::OsString;
627        let client = CliClient::new("git").default_env("GIT_TERMINAL_PROMPT", "0");
628        for cmd in [
629            client.command(["status"]),
630            client.command_in(Path::new("."), ["fetch"]),
631        ] {
632            assert!(
633                cmd.env_overrides()
634                    .iter()
635                    .any(|(k, v)| k == "GIT_TERMINAL_PROMPT"
636                        && v.as_deref() == Some(OsString::from("0").as_os_str())),
637                "default env missing on built command",
638            );
639        }
640    }
641
642    #[tokio::test]
643    async fn default_env_reaches_the_invocation() {
644        let rec = RecordingRunner::replying(Reply::ok("ok\n"));
645        let client = CliClient::with_runner("git", &rec).default_env("GIT_TERMINAL_PROMPT", "0");
646        let _ = client.run(client.command(["status"])).await.unwrap();
647        let call = rec.only_call();
648        assert!(
649            call.envs
650                .iter()
651                .any(|(k, v)| k == "GIT_TERMINAL_PROMPT" && v.is_some()),
652            "env override did not reach the runner: {:?}",
653            call.envs
654        );
655    }
656
657    #[tokio::test]
658    async fn a_prebuilt_command_passed_to_a_verb_still_gets_client_defaults() {
659        let token = crate::CancellationToken::new();
660        let client = CliClient::new("git")
661            .default_timeout(Duration::from_secs(9))
662            .default_env("GIT_TERMINAL_PROMPT", "0")
663            .default_cancel_on(token);
664
665        // Built WITHOUT the client (no defaults applied yet).
666        let raw = Command::new("git").args(["push"]);
667        let filled = raw.into_command(&client);
668        assert_eq!(
669            filled.configured_timeout(),
670            Some(Duration::from_secs(9)),
671            "the client default timeout fills the gap"
672        );
673        assert!(
674            filled.cancel_token().is_some(),
675            "the client cancel token reaches it"
676        );
677        assert!(
678            filled
679                .env_overrides()
680                .iter()
681                .any(|(k, _)| k == "GIT_TERMINAL_PROMPT"),
682            "the client default env reaches it"
683        );
684
685        let explicit = Command::new("git")
686            .args(["push"])
687            .timeout(Duration::from_secs(2))
688            .env("GIT_TERMINAL_PROMPT", "1");
689        let filled = explicit.into_command(&client);
690        assert_eq!(
691            filled.configured_timeout(),
692            Some(Duration::from_secs(2)),
693            "an explicit per-command timeout wins"
694        );
695        let prompt: Vec<_> = filled
696            .env_overrides()
697            .iter()
698            .filter(|(k, _)| k == "GIT_TERMINAL_PROMPT")
699            .collect();
700        assert_eq!(prompt.len(), 1, "no duplicate env op for the same key");
701        assert_eq!(
702            prompt[0].1.as_deref(),
703            Some(std::ffi::OsStr::new("1")),
704            "the per-command env value wins over the client default"
705        );
706    }
707
708    #[tokio::test]
709    async fn prebuilt_command_env_wins_over_a_case_differing_client_default() {
710        let client = CliClient::new("git").default_env("Path", "from-client");
711        let cmd = Command::new("git").env("PATH", "from-command");
712        let filled = cmd.into_command(&client);
713        let path_ops: Vec<_> = filled
714            .env_overrides()
715            .iter()
716            .filter(|(k, _)| k.to_str().is_some_and(|k| k.eq_ignore_ascii_case("PATH")))
717            .collect();
718        #[cfg(windows)]
719        {
720            assert_eq!(
721                path_ops.len(),
722                1,
723                "the case-differing client default for the same var is skipped"
724            );
725            assert_eq!(
726                path_ops[0].1.as_deref(),
727                Some(std::ffi::OsStr::new("from-command")),
728                "the explicit per-command value wins"
729            );
730        }
731        #[cfg(not(windows))]
732        {
733            assert_eq!(
734                path_ops.len(),
735                2,
736                "on Unix PATH and Path are distinct variables — both kept"
737            );
738        }
739    }
740
741    #[tokio::test]
742    async fn default_cancel_on_is_applied_to_every_command() {
743        let token = crate::CancellationToken::new();
744        let client = CliClient::new("git").default_cancel_on(token);
745        for cmd in [
746            client.command(["status"]),
747            client.command_in(Path::new("."), ["fetch"]),
748        ] {
749            assert!(
750                cmd.cancel_token().is_some(),
751                "default token missing on built command"
752            );
753        }
754        assert!(format!("{client:?}").contains("has_default_cancel: true"));
755    }
756
757    #[tokio::test(start_paused = true)]
758    async fn per_command_cancel_on_overrides_the_default() {
759        use crate::CancellationToken;
760        let default_token = CancellationToken::new();
761        let explicit = CancellationToken::new();
762        let client = CliClient::with_runner("gh", ScriptedRunner::new().fallback(Reply::pending()))
763            .default_cancel_on(default_token.clone());
764        let cmd = client.command(["run", "watch"]).cancel_on(explicit.clone());
765
766        let call = client.output_string(cmd);
767        tokio::pin!(call);
768        default_token.cancel();
769        assert!(
770            tokio::time::timeout(Duration::from_secs(3600), &mut call)
771                .await
772                .is_err(),
773            "the replaced default token must not cancel the call"
774        );
775        explicit.cancel();
776        let err = tokio::time::timeout(Duration::from_secs(3600), call)
777            .await
778            .expect("the explicit token must resolve the call")
779            .expect_err("explicit token cancels");
780        assert!(matches!(err, Error::Cancelled { .. }), "got {err:?}");
781    }
782
783    #[tokio::test(start_paused = true)]
784    async fn acceptance_pending_reply_with_client_default_cancel() {
785        use crate::CancellationToken;
786        let token = CancellationToken::new();
787        let rec = RecordingRunner::new(
788            ScriptedRunner::new().on(["gh", "run", "watch"], Reply::pending()),
789        );
790        let client = CliClient::with_runner("gh", &rec).default_cancel_on(token.clone());
791
792        let call = client.output_string(client.command(["run", "watch", "123"]));
793        tokio::pin!(call);
794        assert!(
795            tokio::time::timeout(Duration::from_secs(3600), &mut call)
796                .await
797                .is_err(),
798            "must not resolve before the token fires"
799        );
800        token.cancel();
801        match tokio::time::timeout(Duration::from_secs(3600), call)
802            .await
803            .expect("the cancelled token must resolve the call")
804        {
805            Err(Error::Cancelled { program }) => assert_eq!(program, "gh"),
806            other => panic!("expected Error::Cancelled, got {other:?}"),
807        }
808        assert_eq!(rec.only_call().args_str(), ["run", "watch", "123"]);
809    }
810
811    #[test]
812    fn macro_emits_default_cancel_on() {
813        let _client = Demo::with_runner(ScriptedRunner::new())
814            .default_cancel_on(crate::CancellationToken::new());
815    }
816
817    #[test]
818    fn macro_generates_all_constructors() {
819        let _real = Demo::new();
820        let _default = Demo::default();
821        let _fake = Demo::with_runner(ScriptedRunner::new())
822            .default_timeout(Duration::from_secs(1))
823            .default_env("GIT_TERMINAL_PROMPT", "0")
824            .default_env_remove("GIT_PAGER");
825    }
826}