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::sync::Arc;
17use std::time::Duration;
18
19use crate::command::Command;
20use crate::error::{Error, Result};
21use crate::result::ProcessResult;
22use crate::retry::{RetryConfig, RetryPolicy};
23use crate::runner::{JobRunner, ProcessRunner, ProcessRunnerExt};
24
25mod sealed {
26    use std::ffi::OsStr;
27    pub trait Sealed {}
28    impl Sealed for crate::Command {}
29    impl<S: AsRef<OsStr>, const N: usize> Sealed for [S; N] {}
30    impl<S: AsRef<OsStr>, const N: usize> Sealed for &[S; N] {}
31    impl<S: AsRef<OsStr>> Sealed for Vec<S> {}
32    impl<S: AsRef<OsStr>> Sealed for &[S] {}
33}
34
35/// What a [`CliClient`] verb accepts: either an **argument list** — built
36/// into a [`Command`] for the client's program with its defaults (timeout, env,
37/// cancellation) applied — or a ready-made `Command`, run as-is.
38///
39/// This lets one verb serve both the common `git.run(["status"])` and the
40/// customized `git.run(git.command(["push"]).timeout(d))`, removing the
41/// double-mention of the older `git.run(git.command([…]))` form. Implemented for
42/// argument containers (`[S; N]`, `Vec<S>`, `&[S]` where `S: AsRef<OsStr>`) and
43/// for [`Command`]; **sealed** (not implementable downstream).
44///
45/// Either form receives the client's defaults (timeout / env /
46/// [`default_cancel_on`](CliClient::default_cancel_on)): an argument list builds a
47/// fresh command with them, and a ready-made [`Command`] has them **filled into
48/// the gaps it left** — its own explicit settings win, but a client-wide cancel
49/// token / timeout / env still reaches a per-call-customized command rather than
50/// being silently dropped.
51///
52/// **Program note:** a ready-made [`Command`] keeps *its own* `program` — the
53/// client fills only defaults, it does **not** substitute its program. So
54/// `git_client.run(Command::new("rsync"))` runs `rsync`, but with git's
55/// env/timeout/cancel defaults grafted on (e.g. `GIT_TERMINAL_PROMPT=0` on an
56/// rsync process). Pass a [`Command`] to a client's verb only when you want *that
57/// client's* defaults on it; otherwise build from an argument list (which uses
58/// the client's own program) or run the command through its own client.
59pub trait IntoCommand<R: ProcessRunner>: sealed::Sealed {
60    /// Build the [`Command`] to run for `client` — used by the verbs.
61    #[doc(hidden)]
62    fn into_command(self, client: &CliClient<R>) -> Command;
63}
64
65impl<R: ProcessRunner> IntoCommand<R> for Command {
66    fn into_command(self, client: &CliClient<R>) -> Command {
67        // Fill defaults into the caller-supplied command's gaps; its explicit
68        // settings win. Idempotent if `command()` already applied them.
69        client.apply_defaults(self)
70    }
71}
72
73impl<R: ProcessRunner, S: AsRef<OsStr>, const N: usize> IntoCommand<R> for [S; N] {
74    fn into_command(self, client: &CliClient<R>) -> Command {
75        client.command(self)
76    }
77}
78
79impl<R: ProcessRunner, S: AsRef<OsStr>, const N: usize> IntoCommand<R> for &[S; N] {
80    fn into_command(self, client: &CliClient<R>) -> Command {
81        client.command(self)
82    }
83}
84
85impl<R: ProcessRunner, S: AsRef<OsStr>> IntoCommand<R> for Vec<S> {
86    fn into_command(self, client: &CliClient<R>) -> Command {
87        client.command(self)
88    }
89}
90
91impl<R: ProcessRunner, S: AsRef<OsStr>> IntoCommand<R> for &[S] {
92    fn into_command(self, client: &CliClient<R>) -> Command {
93        client.command(self)
94    }
95}
96
97/// Owns a CLI tool's program name, [`ProcessRunner`], and default timeout, and
98/// builds + runs [`Command`]s against them.
99///
100/// Generic over the runner so tests inject a fake; [`new`](Self::new) uses the
101/// real job-backed [`JobRunner`].
102///
103/// `Clone` when the runner is `Clone` — the default [`JobRunner`] is, as are
104/// [`Command`] and [`Pipeline`](crate::Pipeline), so the whole CLI-wrapper family
105/// clones uniformly (e.g. to hand an owned, `'static` value to a spawned task or
106/// an async-runtime bridge). A clone copies the program, timeout, and env
107/// defaults and **shares the same default cancellation token**
108/// ([`default_cancel_on`](Self::default_cancel_on)): cancelling via one clone
109/// cancels every command built from any of them, as a shared token should.
110#[derive(Clone)]
111pub struct CliClient<R: ProcessRunner = JobRunner> {
112    program: OsString,
113    runner: R,
114    timeout: Option<Duration>,
115    /// Environment overrides applied to every built command (`None` = remove),
116    /// in registration order — e.g. `GIT_TERMINAL_PROMPT=0` set once instead of
117    /// on every probe.
118    envs: Vec<(OsString, Option<OsString>)>,
119    /// A cancellation token applied to every built command (see
120    /// [`default_cancel_on`](Self::default_cancel_on)).
121    cancel: Option<tokio_util::sync::CancellationToken>,
122    /// A retry policy + classifier applied to every built command (see
123    /// [`default_retry`](Self::default_retry)).
124    retry: Option<RetryConfig>,
125    /// Per-build env resolvers — `(key, resolver)`, evaluated once when each
126    /// command is built and baked into it (see
127    /// [`default_env_fn`](Self::default_env_fn)).
128    env_fns: Vec<(OsString, EnvResolver)>,
129}
130
131/// A closure that computes an environment value when a command is built — backs
132/// [`CliClient::default_env_fn`]. `Arc`-shared so a `CliClient` stays `Clone`.
133type EnvResolver = Arc<dyn Fn() -> OsString + Send + Sync>;
134
135// Manual Debug: no `Debug` bound on R; env values omitted per secret-safety rule.
136impl<R: ProcessRunner> std::fmt::Debug for CliClient<R> {
137    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
138        let mut d = f.debug_struct("CliClient");
139        d.field("program", &self.program)
140            .field("timeout", &self.timeout)
141            .field("env_names", &crate::command::redacted_env_names(&self.envs));
142        d.field("has_default_cancel", &self.cancel.is_some());
143        d.field("has_default_retry", &self.retry.is_some());
144        // Dynamic-env *keys* only (names aren't secret; the resolved values are).
145        if !self.env_fns.is_empty() {
146            let keys: Vec<&OsString> = self.env_fns.iter().map(|(key, _)| key).collect();
147            d.field("dynamic_env_keys", &keys);
148        }
149        d.finish_non_exhaustive()
150    }
151}
152
153impl CliClient<JobRunner> {
154    /// A client driving `program` through the real job-backed runner.
155    pub fn new(program: impl AsRef<OsStr>) -> Self {
156        Self {
157            program: program.as_ref().to_os_string(),
158            runner: JobRunner,
159            timeout: None,
160            envs: Vec::new(),
161            cancel: None,
162            retry: None,
163            env_fns: Vec::new(),
164        }
165    }
166}
167
168impl<R: ProcessRunner> CliClient<R> {
169    /// A client driving `program` through `runner` — pass a fake in tests.
170    pub fn with_runner(program: impl AsRef<OsStr>, runner: R) -> Self {
171        Self {
172            program: program.as_ref().to_os_string(),
173            runner,
174            timeout: None,
175            envs: Vec::new(),
176            cancel: None,
177            retry: None,
178            env_fns: Vec::new(),
179        }
180    }
181
182    /// Apply a default timeout to every command this client builds.
183    #[must_use]
184    pub fn default_timeout(mut self, timeout: Duration) -> Self {
185        self.timeout = Some(timeout);
186        self
187    }
188
189    /// Set an environment variable on every command this client builds — e.g.
190    /// `GIT_TERMINAL_PROMPT=0` so a probe can never block on a credential
191    /// prompt. Per-command [`Command::env`] still works and is layered after.
192    ///
193    /// **Duplicate keys: last registration wins** among the *static* env ops
194    /// (`default_env`/[`default_env_remove`](Self::default_env_remove)), matching
195    /// [`Command::env`]'s later-wins: `default_env("K","a").default_env("K","b")`
196    /// yields `K=b`, and a later `default_env_remove("K")` supersedes an earlier
197    /// set. This is *within* the static channel only — a static `default_env`
198    /// always beats a [`default_env_fn`](Self::default_env_fn) for the same key
199    /// regardless of registration order (the resolver is a fallback, never run when
200    /// a static value is present).
201    #[must_use]
202    pub fn default_env(mut self, key: impl AsRef<OsStr>, value: impl AsRef<OsStr>) -> Self {
203        let key = key.as_ref().to_os_string();
204        self.envs
205            .retain(|(k, _)| !crate::command::env_key_eq(k, &key));
206        self.envs.push((key, Some(value.as_ref().to_os_string())));
207        self
208    }
209
210    /// Remove an inherited environment variable on every command this client
211    /// builds. Last registration wins for a repeated key (see
212    /// [`default_env`](Self::default_env)).
213    #[must_use]
214    pub fn default_env_remove(mut self, key: impl AsRef<OsStr>) -> Self {
215        let key = key.as_ref().to_os_string();
216        self.envs
217            .retain(|(k, _)| !crate::command::env_key_eq(k, &key));
218        self.envs.push((key, None));
219        self
220    }
221
222    /// Set an environment variable on every command this client builds to a value
223    /// **computed once per built command** by `resolver` — the dynamic companion to
224    /// [`default_env`](Self::default_env), for a value that must be refreshed for
225    /// each new operation rather than frozen at client construction (a rotating
226    /// token, a per-invocation request id, the current trace span). `resolver` runs
227    /// when each command is built — i.e. once per `command()` / verb call — and the
228    /// value is gap-filled like the other defaults: a per-command
229    /// [`env`](Command::env) / [`env_remove`](Command::env_remove) for the same key
230    /// wins, and an explicit static [`default_env`](Self::default_env) /
231    /// [`default_env_remove`](Self::default_env_remove) for the same key takes
232    /// precedence over the resolver (in which case the resolver is not run at all).
233    /// Lets a typed wrapper
234    /// drop the boilerplate of re-implementing every verb just to inject the value
235    /// first.
236    ///
237    /// **Scope of "fresh":** the value is resolved when the command is built and
238    /// then **baked into that command** — it is *not* re-resolved per process
239    /// spawn. A built command that is retried (via [`default_retry`] /
240    /// [`Command::retry`]) or run more than once reuses the value captured at build
241    /// time on every attempt. So this refreshes per *operation*, not per *attempt*:
242    /// suitable for a credential whose lifetime comfortably exceeds the retry
243    /// window, not for a strictly single-use token that must differ between two
244    /// attempts of the same command.
245    ///
246    /// `resolver` runs synchronously on the thread building the command (typically
247    /// an async runtime worker), once per build — keep it cheap and non-blocking;
248    /// cache or fall back inside it rather than blocking on I/O. It is infallible
249    /// (returns a value, not a `Result`): do any fallible resolution inside and have
250    /// it fall back. A panic propagates out of the build like any other; it does not
251    /// corrupt the client. The resolved value lands in the command's env exactly
252    /// like a static [`default_env`](Self::default_env) — and is never logged (env
253    /// values are redacted from `Debug`/tracing). See [`Command::env`]'s **Secrets**
254    /// note for typing/zeroizing a secret value at the call site.
255    ///
256    /// [`default_retry`]: Self::default_retry
257    /// [`Command::env`]: crate::Command::env
258    #[must_use]
259    pub fn default_env_fn<V, F>(mut self, key: impl AsRef<OsStr>, resolver: F) -> Self
260    where
261        V: Into<OsString>,
262        F: Fn() -> V + Send + Sync + 'static,
263    {
264        let key = key.as_ref().to_os_string();
265        // Last registration wins for a repeated key, like `default_env`.
266        self.env_fns
267            .retain(|(k, _)| !crate::command::env_key_eq(k, &key));
268        self.env_fns
269            .push((key, Arc::new(move || resolver().into())));
270        self
271    }
272
273    /// Cancel every command this client builds when `token` fires: each built
274    /// command gets [`cancel_on(token.clone())`](Command::cancel_on), so
275    /// cancelling the token kills every in-flight run of **this client** (the
276    /// whole tree, surfacing [`ErrorReason::Cancelled`](crate::ErrorReason::Cancelled) on
277    /// the awaiting call — same semantics as the per-command builder).
278    ///
279    /// **Precedence:** a per-command [`Command::cancel_on`] chained on a built
280    /// command *replaces* the default (an explicit setting beats a default,
281    /// like a per-command [`timeout`](Command::timeout) after
282    /// [`default_timeout`](Self::default_timeout)). When both sources should
283    /// fire, wire it explicitly — derive a child of the default
284    /// (`let c = default.child_token()`), hand the command `cancel_on(c.clone())`,
285    /// and have the second source call `c.cancel()` — or simply build a
286    /// dedicated client per scope.
287    ///
288    /// Scope cancellation by client, not by call: clients are cheap — build
289    /// one per cancellable scope and hand each its own token.
290    #[must_use]
291    pub fn default_cancel_on(mut self, token: tokio_util::sync::CancellationToken) -> Self {
292        self.cancel = Some(token);
293        self
294    }
295
296    /// Retry **every** verb of this client whose failure surfaces as an [`Error`]
297    /// the classifier accepts, on a shared [`RetryPolicy`] (exponential backoff +
298    /// cap + jitter) — the client-wide analogue of the per-call
299    /// [`Command::retry`](crate::Command::retry), filled into each built command
300    /// the same way [`default_timeout`](Self::default_timeout) is. A per-command
301    /// [`Command::retry`] / [`retry_with`](crate::Command::retry_with) **wins**
302    /// (gap-fill, not override); this takes the same [`RetryPolicy`] as
303    /// [`Command::retry_with`](crate::Command::retry_with), applied to every verb.
304    ///
305    /// Honored by the success-checking verbs — [`run`](Self::run) /
306    /// [`run_unit`](Self::run_unit) / [`checked`](Self::checked) /
307    /// [`exit_code`](Self::exit_code) / [`probe`](Self::probe) /
308    /// [`parse`](Self::parse) / [`try_parse`](Self::try_parse), plus
309    /// `output_json` with the `json` feature — the ones that
310    /// surface failure as an [`Error`] the classifier can inspect
311    /// (read it via [`is_transient`](crate::Error::is_transient) /
312    /// [`is_timeout`](crate::Error::is_timeout) / [`combined`](crate::Error::combined)).
313    /// The non-erroring `output_string`/`output_bytes` paths don't retry.
314    ///
315    /// **Each attempt re-executes the whole command** — a fresh process. Gate
316    /// retries on a classifier that matches *pre-effect* failures; see
317    /// [`Command::retry`]'s caveats on replayed side effects and one-shot stdin.
318    #[must_use]
319    pub fn default_retry(
320        mut self,
321        policy: RetryPolicy,
322        retry_if: impl Fn(&Error) -> bool + Send + Sync + 'static,
323    ) -> Self {
324        self.retry = Some(RetryConfig::new(policy, retry_if));
325        self
326    }
327
328    /// The injected runner — for direct [`ProcessRunner`]/[`ProcessRunnerExt`] use.
329    pub fn runner(&self) -> &R {
330        &self.runner
331    }
332
333    /// The default timeout, if one was set.
334    pub fn timeout(&self) -> Option<Duration> {
335        self.timeout
336    }
337
338    /// A [`Command`] for `program <args>` in the current directory, defaults
339    /// (timeout, env) pre-applied. Chain more builders (`.arg`, `.stdin`, …) for
340    /// dynamic-argument commands.
341    pub fn command<I, S>(&self, args: I) -> Command
342    where
343        I: IntoIterator<Item = S>,
344        S: AsRef<OsStr>,
345    {
346        self.apply_defaults(Command::new(&self.program).args(args))
347    }
348
349    /// A [`Command`] for `program <args>` run in `dir`, defaults (timeout, env)
350    /// pre-applied.
351    pub fn command_in<I, S>(&self, dir: &Path, args: I) -> Command
352    where
353        I: IntoIterator<Item = S>,
354        S: AsRef<OsStr>,
355    {
356        self.apply_defaults(Command::new(&self.program).current_dir(dir).args(args))
357    }
358
359    /// Resolve this client's `program` to a concrete executable path **without
360    /// spawning it** — the client-level preflight, for a *doctor* /
361    /// early-diagnosis check ("is this tool installed?") before running any
362    /// command, with **no** side effects (no process is started).
363    ///
364    /// Builds a command for the client's program with the client's defaults
365    /// applied — so a [`default_env`](Self::default_env) that relocates `PATH`
366    /// (or a [`default_env_fn`](Self::default_env_fn) that does) is honored
367    /// exactly as it would be at launch — then resolves it via
368    /// [`Command::resolve_program`](crate::Command::resolve_program), reusing the
369    /// **same** internal PATH/PATHEXT/execute-bit resolution the real spawn uses —
370    /// so a preflight **hit** is exactly what a run of this client would spawn,
371    /// including a bare name reachable only via a non-`.exe` PATHEXT extension on
372    /// Windows (`yarn.cmd`/`npx.cmd` shims), which the launch spawns via its
373    /// resolved path. The one residual gap is a Windows preflight **miss**: the OS
374    /// can still find a bare name through the application/current/system
375    /// directories this `PATH`-based model doesn't cover — see
376    /// [`Command::resolve_program`](crate::Command::resolve_program) for the full
377    /// parity contract.
378    ///
379    /// Returns the resolved **absolute** path on success. A synchronous, cheap
380    /// filesystem probe — no async runtime is required.
381    ///
382    /// # Errors
383    ///
384    /// [`ErrorReason::NotFound`](crate::ErrorReason::NotFound) when the program can't be
385    /// located — see [`Command::resolve_program`] for the full contract
386    /// (`searched` diagnostic, [`is_not_found`](crate::Error::is_not_found)
387    /// classification).
388    pub fn resolve_program(&self) -> Result<std::path::PathBuf> {
389        self.command(std::iter::empty::<&OsStr>()).resolve_program()
390    }
391
392    /// Fill the client's defaults into `command`, but only where the command has
393    /// not set them itself — so a fresh [`command()`](Self::command) (no settings)
394    /// gets every default, while a caller-supplied [`Command`] passed straight to
395    /// a verb keeps its own explicit timeout/cancel/env and only fills the gaps
396    /// (so a client-wide cancel token / timeout / env is not silently dropped
397    /// when you customize a single call). Idempotent — running it twice (a verb
398    /// applies it to a command that `command()` already defaulted) is a no-op
399    /// the second time.
400    fn apply_defaults(&self, mut command: Command) -> Command {
401        if command.accepts_default_timeout()
402            && let Some(timeout) = self.timeout
403        {
404            command = command.timeout(timeout);
405        }
406        if command.cancel_token().is_none()
407            && let Some(token) = &self.cancel
408        {
409            command = command.cancel_on(token.clone());
410        }
411        command.fill_default_envs(&self.envs);
412        // Dynamic env defaults, applied after the static ones: resolve and set each
413        // only when the key is still absent — so a per-command `env` or an explicit
414        // `default_env` wins, AND a resolver (which may do real work — read a vault)
415        // never runs when the key is already set at the moment defaults are applied.
416        for (key, resolver) in &self.env_fns {
417            if !command.has_env_override(key) {
418                command = command.env(key, resolver());
419            }
420        }
421        command.fill_default_retry(&self.retry);
422        command
423    }
424
425    /// Run, returning stdout (trailing whitespace trimmed) on success (errors on
426    /// a non-zero exit) — the same verb, with the same semantics, as
427    /// [`Command::run`](crate::Command::run) and
428    /// [`ProcessRunnerExt::run`]. Trims with
429    /// `trim_end`: the trailing newline is noise, but leading whitespace can be
430    /// significant.
431    ///
432    /// Accepts an argument list (`git.run(["status"])`) or a customized
433    /// [`Command`] (`git.run(git.command(["push"]).timeout(d))`) — see
434    /// [`IntoCommand`].
435    ///
436    /// # Errors
437    ///
438    /// The same surface as [`Command::run`](crate::Command::run): a launch
439    /// failure ([`ErrorReason::NotFound`](crate::ErrorReason::NotFound) / [`ErrorReason::Spawn`](crate::ErrorReason::Spawn) / [`ErrorReason::Unsupported`](crate::ErrorReason::Unsupported) /
440    /// [`ErrorReason::Io`](crate::ErrorReason::Io), and — via the client's runner — [`ErrorReason::Cancelled`](crate::ErrorReason::Cancelled) on a
441    /// pre-cancelled token), a non-accepted exit ([`ErrorReason::Exit`](crate::ErrorReason::Exit)),
442    /// [`ErrorReason::Signalled`](crate::ErrorReason::Signalled), [`ErrorReason::Timeout`](crate::ErrorReason::Timeout), [`ErrorReason::Cancelled`](crate::ErrorReason::Cancelled),
443    /// [`ErrorReason::OutputTooLarge`](crate::ErrorReason::OutputTooLarge) (a fail-loud buffer truncated the presented
444    /// stdout), or [`ErrorReason::Stdin`](crate::ErrorReason::Stdin).
445    pub async fn run(&self, call: impl IntoCommand<R>) -> Result<String> {
446        self.runner.run(&call.into_command(self)).await
447    }
448
449    /// Run, requiring an accepted exit, and return the full
450    /// [`ProcessResult`] (untrimmed) — the [`CliClient`] analogue of
451    /// [`ProcessRunnerExt::checked`]; the
452    /// building block when you need the whole result after success-checking.
453    ///
454    /// # Errors
455    ///
456    /// The same surface as [`Command::checked`](crate::Command::checked): the
457    /// launch failures, plus [`ErrorReason::Exit`](crate::ErrorReason::Exit) / [`ErrorReason::Signalled`](crate::ErrorReason::Signalled) /
458    /// [`ErrorReason::Timeout`](crate::ErrorReason::Timeout) / [`ErrorReason::Cancelled`](crate::ErrorReason::Cancelled) / [`ErrorReason::Stdin`](crate::ErrorReason::Stdin). Being the
459    /// lenient building block, it does not fail loud on a bounded-buffer
460    /// truncation, so it never returns [`ErrorReason::OutputTooLarge`](crate::ErrorReason::OutputTooLarge).
461    pub async fn checked(&self, call: impl IntoCommand<R>) -> Result<ProcessResult<String>> {
462        self.runner.checked(&call.into_command(self)).await
463    }
464
465    /// Run, capturing the full result without erroring on a non-zero exit — the
466    /// same verb as [`ProcessRunner::output_string`].
467    ///
468    /// # Errors
469    ///
470    /// The same surface as
471    /// [`Command::output_string`](crate::Command::output_string): a non-zero
472    /// exit, a timeout, and a signal-kill are *captured* in the returned
473    /// [`ProcessResult`], not raised; beyond the launch failures, only
474    /// [`ErrorReason::Cancelled`](crate::ErrorReason::Cancelled), [`ErrorReason::OutputTooLarge`](crate::ErrorReason::OutputTooLarge) (a fail-loud overflow),
475    /// [`ErrorReason::Stdin`](crate::ErrorReason::Stdin), and [`ErrorReason::Io`](crate::ErrorReason::Io) surface.
476    pub async fn output_string(&self, call: impl IntoCommand<R>) -> Result<ProcessResult<String>> {
477        self.runner.output_string(&call.into_command(self)).await
478    }
479
480    /// Run, capturing stdout as **raw bytes** (stderr as text), without erroring
481    /// on a non-zero exit — the same verb as [`ProcessRunner::output_bytes`].
482    /// For binary tools whose stdout is not UTF-8.
483    ///
484    /// # Errors
485    ///
486    /// Identical to [`output_string`](Self::output_string), except a fail-loud
487    /// [`ErrorReason::OutputTooLarge`](crate::ErrorReason::OutputTooLarge) applies to the raw stdout *byte* ceiling. Note
488    /// that a runner that only implements
489    /// [`output_string`](crate::ProcessRunner::output_string) surfaces
490    /// [`ErrorReason::Unsupported`](crate::ErrorReason::Unsupported) here (byte capture routes through
491    /// [`start`](crate::ProcessRunner::start)).
492    pub async fn output_bytes(&self, call: impl IntoCommand<R>) -> Result<ProcessResult<Vec<u8>>> {
493        self.runner.output_bytes(&call.into_command(self)).await
494    }
495
496    /// Run for the side effect, discarding stdout (errors on a non-zero exit) —
497    /// the same verb as
498    /// [`ProcessRunnerExt::run_unit`].
499    ///
500    /// # Errors
501    ///
502    /// The same surface as [`checked`](Self::checked) (launch failures plus
503    /// [`ErrorReason::Exit`](crate::ErrorReason::Exit) / [`ErrorReason::Signalled`](crate::ErrorReason::Signalled) / [`ErrorReason::Timeout`](crate::ErrorReason::Timeout) /
504    /// [`ErrorReason::Cancelled`](crate::ErrorReason::Cancelled) / [`ErrorReason::Stdin`](crate::ErrorReason::Stdin)); only the output is discarded.
505    pub async fn run_unit(&self, call: impl IntoCommand<R>) -> Result<()> {
506        self.runner.run_unit(&call.into_command(self)).await
507    }
508
509    /// Run and return the exit code (e.g. `git diff --quiet`, `gh auth status`)
510    /// — never errors on a non-zero exit. The same verb as
511    /// [`Command::exit_code`](crate::Command::exit_code).
512    ///
513    /// # Errors
514    ///
515    /// The launch failures, plus — when the run produced no code —
516    /// [`ErrorReason::Timeout`](crate::ErrorReason::Timeout), [`ErrorReason::Signalled`](crate::ErrorReason::Signalled), or [`ErrorReason::Cancelled`](crate::ErrorReason::Cancelled). A
517    /// non-zero exit is returned as the code, not raised.
518    pub async fn exit_code(&self, call: impl IntoCommand<R>) -> Result<i32> {
519        self.runner.exit_code(&call.into_command(self)).await
520    }
521
522    /// Run a predicate and read its exit code as a boolean: exit `0` →
523    /// `Ok(true)`, exit `1` → `Ok(false)`, anything else → `Err`. Collapses the
524    /// `match code { 0 => …, 1 => …, _ => Err }` idiom for commands whose exit
525    /// code is the answer (`git diff --quiet`, `git show-ref --verify --quiet`,
526    /// `grep -q`, …); other codes / timeout / signal-kill all error.
527    ///
528    /// # Errors
529    ///
530    /// Any exit code other than `0`/`1` becomes [`ErrorReason::Exit`](crate::ErrorReason::Exit), and — atop the
531    /// launch failures — a run with no code errors as [`ErrorReason::Timeout`](crate::ErrorReason::Timeout),
532    /// [`ErrorReason::Signalled`](crate::ErrorReason::Signalled), or [`ErrorReason::Cancelled`](crate::ErrorReason::Cancelled).
533    pub async fn probe(&self, call: impl IntoCommand<R>) -> Result<bool> {
534        self.runner.probe(&call.into_command(self)).await
535    }
536
537    /// Stream stdout and return the first line matching `predicate` (`None` if
538    /// the stream ends first) — the [`CliClient`] analogue of
539    /// [`ProcessRunnerExt::first_line`],
540    /// bounded by the command's [`timeout`](crate::Command::timeout).
541    ///
542    /// # Errors
543    ///
544    /// The launch failures, plus [`ErrorReason::Timeout`](crate::ErrorReason::Timeout) when a command
545    /// [`timeout`](crate::Command::timeout) is set and its deadline elapses
546    /// mid-stream (tearing the process down), [`ErrorReason::Cancelled`](crate::ErrorReason::Cancelled), or
547    /// [`ErrorReason::Io`](crate::ErrorReason::Io) while streaming. A stream that ends with no match is
548    /// `Ok(None)`, not an error.
549    pub async fn first_line<F>(
550        &self,
551        call: impl IntoCommand<R>,
552        predicate: F,
553    ) -> Result<Option<String>>
554    where
555        F: Fn(&str) -> bool + Send,
556    {
557        self.runner
558            .first_line(&call.into_command(self), predicate)
559            .await
560    }
561
562    /// Run (errors on a non-zero exit) and feed stdout to an infallible
563    /// `parse` — the shape of git/jj struct-returning commands. Fails loud on a
564    /// bounded-buffer truncation. Delegates to
565    /// [`ProcessRunnerExt::parse`].
566    ///
567    /// # Errors
568    ///
569    /// The success-checking surface of [`run`](Self::run) (launch failures plus
570    /// [`ErrorReason::Exit`](crate::ErrorReason::Exit) / [`ErrorReason::Signalled`](crate::ErrorReason::Signalled) / [`ErrorReason::Timeout`](crate::ErrorReason::Timeout) /
571    /// [`ErrorReason::Cancelled`](crate::ErrorReason::Cancelled) / [`ErrorReason::Stdin`](crate::ErrorReason::Stdin)), plus [`ErrorReason::OutputTooLarge`](crate::ErrorReason::OutputTooLarge)
572    /// when a fail-loud buffer truncated the stdout the parser would see. The
573    /// `parse` closure is infallible, so it adds no error.
574    pub async fn parse<T, F>(&self, call: impl IntoCommand<R>, parse: F) -> Result<T>
575    where
576        T: Send,
577        F: FnOnce(&str) -> T + Send,
578    {
579        self.runner.parse(&call.into_command(self), parse).await
580    }
581
582    /// Run (errors on a non-zero exit) and feed stdout to a *fallible* `parse` —
583    /// the shape of JSON deserialization, where a parse failure becomes
584    /// [`ErrorReason::Parse`](crate::ErrorReason::Parse). Fails loud on a bounded-buffer
585    /// truncation. Delegates to
586    /// [`ProcessRunnerExt::try_parse`].
587    ///
588    /// # Errors
589    ///
590    /// Everything [`parse`](Self::parse) can return, plus whatever the fallible
591    /// `parse` closure yields on malformed output — typically
592    /// [`ErrorReason::Parse`](crate::ErrorReason::Parse).
593    pub async fn try_parse<T, F>(&self, call: impl IntoCommand<R>, parse: F) -> Result<T>
594    where
595        T: Send,
596        F: FnOnce(&str) -> Result<T> + Send,
597    {
598        self.runner.try_parse(&call.into_command(self), parse).await
599    }
600
601    /// Run to an accepted exit and deserialize the complete stdout as JSON.
602    ///
603    /// Delegates to [`ProcessRunnerExt::output_json`], preserving this client's
604    /// defaults and the command's retry/truncation behavior. Malformed JSON or a
605    /// value that does not match `T` becomes
606    /// [`ErrorReason::Parse`](crate::ErrorReason::Parse) with a bounded raw
607    /// fragment and decoded-output location.
608    ///
609    /// # Errors
610    ///
611    /// Everything [`try_parse`](Self::try_parse) can return, plus
612    /// [`ErrorReason::Parse`](crate::ErrorReason::Parse) for deserialization
613    /// failures. Available with the `json` feature.
614    #[cfg(feature = "json")]
615    pub async fn output_json<T>(&self, call: impl IntoCommand<R>) -> Result<T>
616    where
617        T: serde::de::DeserializeOwned + Send,
618    {
619        self.runner.output_json(&call.into_command(self)).await
620    }
621}
622
623/// Scaffold a typed CLI-wrapper struct around a [`CliClient`].
624///
625/// Expands `cli_client!(pub struct Git => "git");` into a
626/// `struct Git<R: ProcessRunner = JobRunner> { core: CliClient<R> }` with
627/// `new()` (real runner), a `Default` impl, `with_runner(runner)`, and
628/// `default_timeout(d)`. Implement the tool's typed methods on it, delegating to
629/// `self.core` — see the *Wrapping a CLI tool* section of the crate's
630/// `docs/testing.md` guide for a worked example.
631///
632/// This macro is **committed public API**. Because it is `#[macro_export]`,
633/// it lives at the crate root and is a stable part of the surface — the
634/// supported scaffold for typed CLI wrappers. The hand-rolled equivalent (a
635/// struct wrapping [`CliClient`]) remains valid and interchangeable.
636#[macro_export]
637macro_rules! cli_client {
638    ($(#[$meta:meta])* $vis:vis struct $name:ident => $binary:expr) => {
639        $(#[$meta])*
640        $vis struct $name<R: $crate::ProcessRunner = $crate::JobRunner> {
641            core: $crate::CliClient<R>,
642        }
643
644        impl $name<$crate::JobRunner> {
645            /// Create a client driving the real job-backed runner.
646            pub fn new() -> Self {
647                Self { core: $crate::CliClient::new($binary) }
648            }
649        }
650
651        impl ::core::default::Default for $name<$crate::JobRunner> {
652            fn default() -> Self {
653                Self::new()
654            }
655        }
656
657        impl<R: $crate::ProcessRunner> $name<R> {
658            /// Create a client driving `runner` — inject a fake in tests.
659            pub fn with_runner(runner: R) -> Self {
660                Self { core: $crate::CliClient::with_runner($binary, runner) }
661            }
662
663            /// Apply a default timeout to every command this client builds.
664            #[must_use]
665            pub fn default_timeout(mut self, timeout: ::core::time::Duration) -> Self {
666                self.core = self.core.default_timeout(timeout);
667                self
668            }
669
670            /// Set an environment variable on every command this client builds
671            /// (e.g. `GIT_TERMINAL_PROMPT=0`).
672            #[must_use]
673            pub fn default_env(
674                mut self,
675                key: impl ::core::convert::AsRef<::std::ffi::OsStr>,
676                value: impl ::core::convert::AsRef<::std::ffi::OsStr>,
677            ) -> Self {
678                self.core = self.core.default_env(key, value);
679                self
680            }
681
682            /// Remove an inherited environment variable on every command this
683            /// client builds.
684            #[must_use]
685            pub fn default_env_remove(
686                mut self,
687                key: impl ::core::convert::AsRef<::std::ffi::OsStr>,
688            ) -> Self {
689                self.core = self.core.default_env_remove(key);
690                self
691            }
692
693            /// Set an env variable on every command to a value computed per built
694            /// command (see `CliClient::default_env_fn`).
695            #[must_use]
696            pub fn default_env_fn<V, F>(
697                mut self,
698                key: impl ::core::convert::AsRef<::std::ffi::OsStr>,
699                resolver: F,
700            ) -> Self
701            where
702                V: ::core::convert::Into<::std::ffi::OsString>,
703                F: ::core::ops::Fn() -> V
704                    + ::core::marker::Send
705                    + ::core::marker::Sync
706                    + 'static,
707            {
708                self.core = self.core.default_env_fn(key, resolver);
709                self
710            }
711        }
712
713        impl<R: $crate::ProcessRunner> $name<R> {
714            /// Cancel every command this client builds when `token` fires (a
715            /// per-command `cancel_on` replaces the default — see
716            /// `CliClient::default_cancel_on`).
717            #[must_use]
718            pub fn default_cancel_on(mut self, token: $crate::CancellationToken) -> Self {
719                self.core = self.core.default_cancel_on(token);
720                self
721            }
722
723            /// Retry every verb on a shared `RetryPolicy` + classifier
724            /// (see `CliClient::default_retry`).
725            #[must_use]
726            pub fn default_retry(
727                mut self,
728                policy: $crate::RetryPolicy,
729                retry_if: impl Fn(&$crate::Error) -> bool
730                    + ::core::marker::Send
731                    + ::core::marker::Sync
732                    + 'static,
733            ) -> Self {
734                self.core = self.core.default_retry(policy, retry_if);
735                self
736            }
737        }
738    };
739}
740
741#[cfg(test)]
742mod tests {
743    use std::path::Path;
744    use std::time::Duration;
745
746    use super::*;
747    use crate::testing::{RecordingRunner, Reply, ScriptedRunner};
748    use crate::{Error, ErrorReason};
749
750    #[test]
751    fn debug_redacts_default_env_values_keeping_names() {
752        let client = CliClient::new("git")
753            .default_env("API_TOKEN", "topsecret-value")
754            .default_env_remove("GIT_PAGER");
755        let dbg = format!("{client:?}");
756        assert!(
757            !dbg.contains("topsecret-value"),
758            "env value must not appear in Debug: {dbg}"
759        );
760        assert!(
761            dbg.contains("API_TOKEN") && dbg.contains("GIT_PAGER"),
762            "env names should appear: {dbg}"
763        );
764    }
765
766    #[test]
767    fn client_is_clone_with_the_default_runner() {
768        // `Command`/`Pipeline` are `Clone`; so is the default-runner `CliClient`,
769        // so the whole CLI-wrapper family clones uniformly (e.g. to own a `'static`
770        // value for a spawned task or an async-runtime bridge).
771        fn assert_clone<T: Clone>() {}
772        assert_clone::<CliClient>();
773
774        let client = CliClient::new("git")
775            .default_timeout(Duration::from_secs(3))
776            .default_env("GIT_TERMINAL_PROMPT", "0")
777            .default_cancel_on(tokio_util::sync::CancellationToken::new());
778        let clone = client.clone();
779        // Every field (program, timeout, env defaults, and the presence of a
780        // shared cancel token) survives the clone verbatim.
781        assert_eq!(format!("{client:?}"), format!("{clone:?}"));
782    }
783
784    crate::cli_client!(struct Demo => "git");
785
786    impl<R: ProcessRunner> Demo<R> {
787        async fn head(&self, dir: &Path) -> Result<String> {
788            self.core
789                .run(self.core.command_in(dir, ["rev-parse", "HEAD"]))
790                .await
791        }
792        async fn is_clean(&self, dir: &Path) -> Result<bool> {
793            Ok(self
794                .core
795                .exit_code(self.core.command_in(dir, ["diff", "--quiet"]))
796                .await?
797                == 0)
798        }
799        async fn branches(&self, dir: &Path) -> Result<Vec<String>> {
800            self.core
801                .parse(self.core.command_in(dir, ["branch"]), |s| {
802                    s.lines().map(|l| l.trim().to_owned()).collect()
803                })
804                .await
805        }
806    }
807
808    #[tokio::test]
809    async fn run_trims_trailing_whitespace_only() {
810        let demo = Demo::with_runner(
811            ScriptedRunner::new().on(["git", "rev-parse"], Reply::ok("  abc123 \n")),
812        );
813        assert_eq!(demo.head(Path::new(".")).await.unwrap(), "  abc123");
814    }
815
816    #[tokio::test]
817    async fn exit_code_maps_exit_status() {
818        let demo = Demo::with_runner(ScriptedRunner::new().on(["git", "diff"], Reply::fail(1, "")));
819        assert!(!demo.is_clean(Path::new(".")).await.unwrap());
820    }
821
822    #[tokio::test]
823    async fn parse_builds_a_typed_value() {
824        let demo = Demo::with_runner(
825            ScriptedRunner::new().on(["git", "branch"], Reply::ok("main\nfeature\n")),
826        );
827        assert_eq!(
828            demo.branches(Path::new(".")).await.unwrap(),
829            vec!["main", "feature"]
830        );
831    }
832
833    #[tokio::test]
834    async fn try_parse_maps_failure_to_parse_error() {
835        let client = CliClient::with_runner(
836            "gh",
837            ScriptedRunner::new().fallback(Reply::ok("not a number")),
838        );
839        let err = client
840            .try_parse::<u32, _>(client.command(["x"]), |s| {
841                s.trim().parse::<u32>().map_err(|e| {
842                    Error::from(ErrorReason::Parse {
843                        program: "gh".into(),
844                        message: e.to_string(),
845                    })
846                })
847            })
848            .await
849            .unwrap_err();
850        assert!(
851            matches!(err.reason(), ErrorReason::Parse { .. }),
852            "got {err:?}"
853        );
854    }
855
856    #[cfg(feature = "json")]
857    #[tokio::test]
858    async fn output_json_uses_the_injected_runner() {
859        #[derive(Debug, serde::Deserialize, PartialEq)]
860        struct Release {
861            tag: String,
862        }
863
864        let client = CliClient::with_runner(
865            "gh",
866            ScriptedRunner::new().on(["gh", "release", "view"], Reply::ok("{\"tag\":\"v3.1.0\"}")),
867        );
868        let release: Release = client
869            .output_json(client.command(["release", "view"]))
870            .await
871            .expect("typed client JSON");
872        assert_eq!(
873            release,
874            Release {
875                tag: "v3.1.0".to_owned()
876            }
877        );
878    }
879
880    #[tokio::test]
881    async fn verbs_accept_args_directly_or_a_customized_command() {
882        use std::time::Duration;
883        let runner = ScriptedRunner::new().on(["git", "status"], Reply::ok("clean"));
884        let client = CliClient::with_runner("git", runner);
885
886        // Argument list — the program comes from the client, defaults applied.
887        assert_eq!(client.run(["status"]).await.unwrap(), "clean");
888        assert_eq!(client.run(vec!["status"]).await.unwrap(), "clean");
889        // A customized Command runs through the same verb (pass-through).
890        let custom = client.command(["status"]).timeout(Duration::from_secs(3));
891        assert_eq!(custom.configured_timeout(), Some(Duration::from_secs(3)));
892        assert_eq!(client.run(custom).await.unwrap(), "clean");
893        let args = ["status"];
894        assert_eq!(client.run(&args).await.unwrap(), "clean");
895        assert_eq!(client.run(&args[..]).await.unwrap(), "clean");
896        let result = client.checked(["status"]).await.unwrap();
897        assert_eq!(result.stdout(), "clean");
898    }
899
900    #[tokio::test]
901    async fn first_line_verb_streams_and_matches() {
902        let runner =
903            ScriptedRunner::new().on(["git", "log"], Reply::lines(["one", "two", "three"]));
904        let client = CliClient::with_runner("git", runner);
905        let found = client
906            .first_line(["log"], |line| line.starts_with('t'))
907            .await
908            .unwrap();
909        assert_eq!(found.as_deref(), Some("two"));
910    }
911
912    #[tokio::test]
913    async fn when_predicate_reads_public_command_accessors() {
914        // Proves `Command`'s accessors are public enough for an external
915        // `ScriptedRunner::when` predicate to inspect the command.
916        let runner = ScriptedRunner::new()
917            .when(
918                |c| c.working_dir() == Some(Path::new("/repo")),
919                Reply::ok("in-repo"),
920            )
921            .fallback(Reply::ok("elsewhere"));
922        let client = CliClient::with_runner("git", runner);
923        assert_eq!(
924            client
925                .run(client.command_in(Path::new("/repo"), ["status"]))
926                .await
927                .unwrap(),
928            "in-repo"
929        );
930        assert_eq!(
931            client.run(client.command(["status"])).await.unwrap(),
932            "elsewhere"
933        );
934    }
935
936    #[tokio::test]
937    async fn recording_runner_captures_args_cwd_and_absence() {
938        let rec = RecordingRunner::replying(Reply::ok("https://gh/pr/2\n"));
939        let client = CliClient::with_runner("gh", &rec);
940        let _ = client
941            .run(client.command_in(Path::new("/repo"), ["pr", "create", "--title", "T"]))
942            .await
943            .unwrap();
944
945        let call = rec.only_call();
946        assert_eq!(call.cwd.as_deref(), Some(std::path::Path::new("/repo")));
947        assert_eq!(call.args_str(), ["pr", "create", "--title", "T"]);
948        assert!(!call.has_flag("--base"), "no --base flag was passed");
949    }
950
951    #[tokio::test]
952    async fn exit_code_errors_on_timeout() {
953        let client = CliClient::with_runner("gh", ScriptedRunner::new().fallback(Reply::timeout()));
954        assert!(matches!(
955            client
956                .exit_code(client.command(["auth", "status"]))
957                .await
958                .unwrap_err()
959                .reason(),
960            ErrorReason::Timeout { .. }
961        ));
962    }
963
964    #[tokio::test]
965    async fn default_timeout_is_applied() {
966        let client = CliClient::new("git").default_timeout(Duration::from_secs(7));
967        assert_eq!(
968            client.command(["status"]).configured_timeout(),
969            Some(Duration::from_secs(7))
970        );
971    }
972
973    #[tokio::test]
974    async fn probe_maps_exit_code_to_bool() {
975        let client = CliClient::with_runner(
976            "git",
977            ScriptedRunner::new()
978                .on(["git", "diff"], Reply::fail(1, ""))
979                .fallback(Reply::ok("")),
980        );
981        // `git diff --quiet` exits 1 (dirty) -> false; anything else (0) -> true.
982        assert!(
983            !client
984                .probe(client.command(["diff", "--quiet"]))
985                .await
986                .unwrap()
987        );
988        assert!(client.probe(client.command(["status"])).await.unwrap());
989    }
990
991    #[tokio::test]
992    async fn default_env_is_applied_to_every_command() {
993        use std::ffi::OsString;
994        let client = CliClient::new("git").default_env("GIT_TERMINAL_PROMPT", "0");
995        for cmd in [
996            client.command(["status"]),
997            client.command_in(Path::new("."), ["fetch"]),
998        ] {
999            assert!(
1000                cmd.env_overrides()
1001                    .iter()
1002                    .any(|(k, v)| k == "GIT_TERMINAL_PROMPT"
1003                        && v.as_deref() == Some(OsString::from("0").as_os_str())),
1004                "default env missing on built command",
1005            );
1006        }
1007    }
1008
1009    #[tokio::test]
1010    async fn default_env_reaches_the_invocation() {
1011        let rec = RecordingRunner::replying(Reply::ok("ok\n"));
1012        let client = CliClient::with_runner("git", &rec).default_env("GIT_TERMINAL_PROMPT", "0");
1013        let _ = client.run(client.command(["status"])).await.unwrap();
1014        let call = rec.only_call();
1015        assert!(
1016            call.envs
1017                .iter()
1018                .any(|(k, v)| k == "GIT_TERMINAL_PROMPT" && v.is_some()),
1019            "env override did not reach the runner: {:?}",
1020            call.envs
1021        );
1022    }
1023
1024    #[test]
1025    fn duplicate_default_env_is_last_registration_wins() {
1026        use std::ffi::OsString;
1027        // G3: matches Command::env's later-wins (was first-wins).
1028        let client = CliClient::new("tool")
1029            .default_env("K", "a")
1030            .default_env("K", "b");
1031        let cmd = client.command(["x"]);
1032        let vals: Vec<_> = cmd
1033            .env_overrides()
1034            .iter()
1035            .filter(|(k, _)| k == "K")
1036            .collect();
1037        assert_eq!(
1038            vals.len(),
1039            1,
1040            "duplicate default_env collapses to one entry"
1041        );
1042        assert_eq!(
1043            vals[0].1.as_deref(),
1044            Some(OsString::from("b").as_os_str()),
1045            "last registration wins"
1046        );
1047        // A later remove of the same key supersedes an earlier set.
1048        let removed = CliClient::new("tool")
1049            .default_env("K", "a")
1050            .default_env_remove("K")
1051            .command(["x"]);
1052        let k: Vec<_> = removed
1053            .env_overrides()
1054            .iter()
1055            .filter(|(k, _)| k == "K")
1056            .collect();
1057        assert_eq!(k.len(), 1);
1058        assert_eq!(k[0].1, None, "the later remove wins");
1059
1060        // Cross-channel: last-wins is *within* a channel. A static `default_env`
1061        // beats a `default_env_fn` for the same key EVEN when the fn is registered
1062        // later — the resolver is a fallback, so static-beats-dynamic is orthogonal
1063        // to registration order.
1064        let static_wins = CliClient::new("tool")
1065            .default_env("K", "static")
1066            .default_env_fn("K", || "dynamic")
1067            .command(["x"]);
1068        assert!(
1069            static_wins.env_overrides().iter().any(
1070                |(k, v)| k == "K" && v.as_deref() == Some(OsString::from("static").as_os_str())
1071            ),
1072            "a static default_env beats a later-registered default_env_fn"
1073        );
1074    }
1075
1076    #[test]
1077    fn no_timeout_command_ignores_a_client_default_timeout() {
1078        // G4: an explicitly-unbounded command opts out of the client gap-fill.
1079        let client = CliClient::new("tail").default_timeout(Duration::from_secs(9));
1080        let bounded = Command::new("tail").into_command(&client);
1081        assert_eq!(
1082            bounded.configured_timeout(),
1083            Some(Duration::from_secs(9)),
1084            "the default fills an unset command"
1085        );
1086        let unbounded = Command::new("tail").no_timeout().into_command(&client);
1087        assert_eq!(
1088            unbounded.configured_timeout(),
1089            None,
1090            "no_timeout opts out of the client default_timeout"
1091        );
1092    }
1093
1094    #[test]
1095    fn env_isolation_ignores_client_env_defaults() {
1096        // G2: a client default_env must not pierce env_clear/inherit_env isolation.
1097        let client = CliClient::new("tool").default_env("LANG", "C");
1098        let isolated = Command::new("tool").env_clear().into_command(&client);
1099        assert!(
1100            !isolated.env_overrides().iter().any(|(k, _)| k == "LANG"),
1101            "env_clear isolates from a client default_env"
1102        );
1103        let plain = Command::new("tool").into_command(&client);
1104        assert!(
1105            plain.env_overrides().iter().any(|(k, _)| k == "LANG"),
1106            "a non-isolated command still gets the client default"
1107        );
1108    }
1109
1110    #[tokio::test]
1111    async fn a_prebuilt_command_passed_to_a_verb_still_gets_client_defaults() {
1112        let token = crate::CancellationToken::new();
1113        let client = CliClient::new("git")
1114            .default_timeout(Duration::from_secs(9))
1115            .default_env("GIT_TERMINAL_PROMPT", "0")
1116            .default_cancel_on(token);
1117
1118        // Built WITHOUT the client (no defaults applied yet).
1119        let raw = Command::new("git").args(["push"]);
1120        let filled = raw.into_command(&client);
1121        assert_eq!(
1122            filled.configured_timeout(),
1123            Some(Duration::from_secs(9)),
1124            "the client default timeout fills the gap"
1125        );
1126        assert!(
1127            filled.cancel_token().is_some(),
1128            "the client cancel token reaches it"
1129        );
1130        assert!(
1131            filled
1132                .env_overrides()
1133                .iter()
1134                .any(|(k, _)| k == "GIT_TERMINAL_PROMPT"),
1135            "the client default env reaches it"
1136        );
1137
1138        let explicit = Command::new("git")
1139            .args(["push"])
1140            .timeout(Duration::from_secs(2))
1141            .env("GIT_TERMINAL_PROMPT", "1");
1142        let filled = explicit.into_command(&client);
1143        assert_eq!(
1144            filled.configured_timeout(),
1145            Some(Duration::from_secs(2)),
1146            "an explicit per-command timeout wins"
1147        );
1148        let prompt: Vec<_> = filled
1149            .env_overrides()
1150            .iter()
1151            .filter(|(k, _)| k == "GIT_TERMINAL_PROMPT")
1152            .collect();
1153        assert_eq!(prompt.len(), 1, "no duplicate env op for the same key");
1154        assert_eq!(
1155            prompt[0].1.as_deref(),
1156            Some(std::ffi::OsStr::new("1")),
1157            "the per-command env value wins over the client default"
1158        );
1159    }
1160
1161    #[tokio::test]
1162    async fn prebuilt_command_env_wins_over_a_case_differing_client_default() {
1163        let client = CliClient::new("git").default_env("Path", "from-client");
1164        let cmd = Command::new("git").env("PATH", "from-command");
1165        let filled = cmd.into_command(&client);
1166        let path_ops: Vec<_> = filled
1167            .env_overrides()
1168            .iter()
1169            .filter(|(k, _)| k.to_str().is_some_and(|k| k.eq_ignore_ascii_case("PATH")))
1170            .collect();
1171        #[cfg(windows)]
1172        {
1173            assert_eq!(
1174                path_ops.len(),
1175                1,
1176                "the case-differing client default for the same var is skipped"
1177            );
1178            assert_eq!(
1179                path_ops[0].1.as_deref(),
1180                Some(std::ffi::OsStr::new("from-command")),
1181                "the explicit per-command value wins"
1182            );
1183        }
1184        #[cfg(not(windows))]
1185        {
1186            assert_eq!(
1187                path_ops.len(),
1188                2,
1189                "on Unix PATH and Path are distinct variables — both kept"
1190            );
1191        }
1192    }
1193
1194    #[tokio::test]
1195    async fn default_cancel_on_is_applied_to_every_command() {
1196        let token = crate::CancellationToken::new();
1197        let client = CliClient::new("git").default_cancel_on(token);
1198        for cmd in [
1199            client.command(["status"]),
1200            client.command_in(Path::new("."), ["fetch"]),
1201        ] {
1202            assert!(
1203                cmd.cancel_token().is_some(),
1204                "default token missing on built command"
1205            );
1206        }
1207        assert!(format!("{client:?}").contains("has_default_cancel: true"));
1208    }
1209
1210    #[tokio::test(start_paused = true)]
1211    async fn per_command_cancel_on_overrides_the_default() {
1212        use crate::CancellationToken;
1213        let default_token = CancellationToken::new();
1214        let explicit = CancellationToken::new();
1215        let client = CliClient::with_runner("gh", ScriptedRunner::new().fallback(Reply::pending()))
1216            .default_cancel_on(default_token.clone());
1217        let cmd = client.command(["run", "watch"]).cancel_on(explicit.clone());
1218
1219        let call = client.output_string(cmd);
1220        tokio::pin!(call);
1221        default_token.cancel();
1222        assert!(
1223            tokio::time::timeout(Duration::from_secs(3600), &mut call)
1224                .await
1225                .is_err(),
1226            "the replaced default token must not cancel the call"
1227        );
1228        explicit.cancel();
1229        let err = tokio::time::timeout(Duration::from_secs(3600), call)
1230            .await
1231            .expect("the explicit token must resolve the call")
1232            .expect_err("explicit token cancels");
1233        assert!(
1234            matches!(err.reason(), ErrorReason::Cancelled { .. }),
1235            "got {err:?}"
1236        );
1237    }
1238
1239    #[tokio::test(start_paused = true)]
1240    async fn acceptance_pending_reply_with_client_default_cancel() {
1241        use crate::CancellationToken;
1242        let token = CancellationToken::new();
1243        let rec = RecordingRunner::new(
1244            ScriptedRunner::new().on(["gh", "run", "watch"], Reply::pending()),
1245        );
1246        let client = CliClient::with_runner("gh", &rec).default_cancel_on(token.clone());
1247
1248        let call = client.output_string(client.command(["run", "watch", "123"]));
1249        tokio::pin!(call);
1250        assert!(
1251            tokio::time::timeout(Duration::from_secs(3600), &mut call)
1252                .await
1253                .is_err(),
1254            "must not resolve before the token fires"
1255        );
1256        token.cancel();
1257        match tokio::time::timeout(Duration::from_secs(3600), call)
1258            .await
1259            .expect("the cancelled token must resolve the call")
1260            .map_err(|e| e.into_reason())
1261        {
1262            Err(ErrorReason::Cancelled { program }) => assert_eq!(program, "gh"),
1263            other => panic!("expected ErrorReason::Cancelled, got {other:?}"),
1264        }
1265        assert_eq!(rec.only_call().args_str(), ["run", "watch", "123"]);
1266    }
1267
1268    #[tokio::test(start_paused = true)]
1269    async fn clone_shares_the_default_cancel_token() {
1270        // The load-bearing half of `CliClient: Clone`'s doc: a clone shares the
1271        // SAME default cancel token, not a copy. A command built from the *clone*
1272        // must therefore respond to the token the *original* was built with —
1273        // parking until it fires, then resolving as `Cancelled`.
1274        use crate::CancellationToken;
1275        let token = CancellationToken::new();
1276        let rec = RecordingRunner::new(
1277            ScriptedRunner::new().on(["gh", "run", "watch"], Reply::pending()),
1278        );
1279        let original = CliClient::with_runner("gh", &rec).default_cancel_on(token.clone());
1280        let clone = original.clone();
1281
1282        let call = clone.output_string(clone.command(["run", "watch", "123"]));
1283        tokio::pin!(call);
1284        assert!(
1285            tokio::time::timeout(Duration::from_secs(3600), &mut call)
1286                .await
1287                .is_err(),
1288            "the clone's command must park on the shared token, not resolve early"
1289        );
1290        token.cancel();
1291        match tokio::time::timeout(Duration::from_secs(3600), call)
1292            .await
1293            .expect("cancelling the shared token must resolve the clone's call")
1294            .map_err(|e| e.into_reason())
1295        {
1296            Err(ErrorReason::Cancelled { program }) => assert_eq!(program, "gh"),
1297            other => panic!("expected ErrorReason::Cancelled, got {other:?}"),
1298        }
1299    }
1300
1301    #[test]
1302    fn macro_emits_default_cancel_on() {
1303        let _client = Demo::with_runner(ScriptedRunner::new())
1304            .default_cancel_on(crate::CancellationToken::new());
1305    }
1306
1307    #[test]
1308    fn macro_emits_default_env_fn() {
1309        let _client = Demo::with_runner(ScriptedRunner::new()).default_env_fn("TOKEN", || "x");
1310    }
1311
1312    #[test]
1313    fn default_env_fn_resolves_per_build_and_respects_precedence() {
1314        use crate::testing::Invocation;
1315        use std::sync::Arc;
1316        use std::sync::atomic::{AtomicU32, Ordering};
1317
1318        let counter = Arc::new(AtomicU32::new(0));
1319        let c = Arc::clone(&counter);
1320        let client = CliClient::new("git").default_env_fn("TOKEN", move || {
1321            format!("t{}", c.fetch_add(1, Ordering::SeqCst))
1322        });
1323
1324        // Each built command resolves a FRESH value.
1325        let inv1 = Invocation::from_command(&client.command(["status"]));
1326        let inv2 = Invocation::from_command(&client.command(["status"]));
1327        assert!(inv1.env_is("TOKEN", "t0"));
1328        assert!(inv2.env_is("TOKEN", "t1"));
1329
1330        // A pre-built command that sets TOKEN keeps its own value, and the resolver
1331        // is NOT invoked for it (the counter stays at the 2 resolves above).
1332        let filled = client.apply_defaults(Command::new("git").env("TOKEN", "explicit"));
1333        assert!(Invocation::from_command(&filled).env_is("TOKEN", "explicit"));
1334        assert_eq!(
1335            counter.load(Ordering::SeqCst),
1336            2,
1337            "the resolver must be skipped when the key is already set"
1338        );
1339    }
1340
1341    #[test]
1342    fn default_env_fn_precedence_against_static_and_self() {
1343        use crate::testing::Invocation;
1344        use std::sync::Arc;
1345        use std::sync::atomic::{AtomicU32, Ordering};
1346
1347        // (a) A static `default_env` for the same key wins, and the resolver is
1348        //     skipped entirely (the doc's stated precedence).
1349        let calls = Arc::new(AtomicU32::new(0));
1350        let c = Arc::clone(&calls);
1351        let client = CliClient::new("git")
1352            .default_env("TOKEN", "static")
1353            .default_env_fn("TOKEN", move || {
1354                c.fetch_add(1, Ordering::SeqCst);
1355                "dynamic"
1356            });
1357        let inv = Invocation::from_command(&client.command(["status"]));
1358        assert!(inv.env_is("TOKEN", "static"));
1359        assert_eq!(
1360            calls.load(Ordering::SeqCst),
1361            0,
1362            "a static default_env must shadow the resolver, which then never runs"
1363        );
1364
1365        // (b) A per-command `env_remove` for the key suppresses the resolver too —
1366        //     an explicit unset beats the dynamic default, like the static one.
1367        let calls = Arc::new(AtomicU32::new(0));
1368        let c = Arc::clone(&calls);
1369        let client = CliClient::new("git").default_env_fn("TOKEN", move || {
1370            c.fetch_add(1, Ordering::SeqCst);
1371            "dynamic"
1372        });
1373        let removed = client.apply_defaults(Command::new("git").env_remove("TOKEN"));
1374        assert!(matches!(
1375            Invocation::from_command(&removed).env("TOKEN"),
1376            Some(None)
1377        ));
1378        assert_eq!(calls.load(Ordering::SeqCst), 0);
1379
1380        // (c) Two resolvers for the same key: the LAST registered wins (G3 —
1381        //     consistent with `default_env`'s later-wins), and the superseded
1382        //     first resolver never runs (it is dropped at registration time).
1383        let first_ran = Arc::new(AtomicU32::new(0));
1384        let f = Arc::clone(&first_ran);
1385        let client = CliClient::new("git")
1386            .default_env_fn("TOKEN", move || {
1387                f.fetch_add(1, Ordering::SeqCst);
1388                "first"
1389            })
1390            .default_env_fn("TOKEN", || "second");
1391        let inv = Invocation::from_command(&client.command(["status"]));
1392        assert!(
1393            inv.env_is("TOKEN", "second"),
1394            "the last-registered resolver wins"
1395        );
1396        assert_eq!(
1397            first_ran.load(Ordering::SeqCst),
1398            0,
1399            "the superseded resolver never runs"
1400        );
1401    }
1402
1403    #[test]
1404    fn default_env_fn_value_is_baked_in_and_stable_across_clone_and_reuse() {
1405        use crate::testing::Invocation;
1406        use std::sync::Arc;
1407        use std::sync::atomic::{AtomicU32, Ordering};
1408
1409        let counter = Arc::new(AtomicU32::new(0));
1410        let c = Arc::clone(&counter);
1411        let client = CliClient::new("git").default_env_fn("TOKEN", move || {
1412            format!("t{}", c.fetch_add(1, Ordering::SeqCst))
1413        });
1414
1415        // A built command captures its value; re-running it (here: re-deriving the
1416        // Invocation) does not re-resolve — the retry loop reuses this same value.
1417        let built = client.command(["status"]);
1418        assert!(Invocation::from_command(&built).env_is("TOKEN", "t0"));
1419        assert!(Invocation::from_command(&built).env_is("TOKEN", "t0"));
1420
1421        // A clone shares the resolver's state (Arc), so it continues the sequence
1422        // rather than restarting it — documents the shared-state semantics.
1423        let clone = client.clone();
1424        assert!(Invocation::from_command(&clone.command(["status"])).env_is("TOKEN", "t1"));
1425        assert_eq!(counter.load(Ordering::SeqCst), 2);
1426    }
1427
1428    #[tokio::test]
1429    async fn default_retry_retries_client_verbs() {
1430        use crate::RetryPolicy;
1431        // on_sequence: a retryable failure then success — the client-wide retry
1432        // re-runs the verb. Exercises the macro-generated `default_retry`, the
1433        // apply_defaults gap-fill, and the retry loop end-to-end.
1434        let demo = Demo::with_runner(ScriptedRunner::new().on_sequence(
1435            ["git", "rev-parse"],
1436            [
1437                Reply::fail(128, "could not read from remote"),
1438                Reply::ok("abc123\n"),
1439            ],
1440        ))
1441        .default_retry(RetryPolicy::new().initial_backoff(Duration::ZERO), |_e| {
1442            true
1443        });
1444        // `head` runs `git rev-parse HEAD`: attempt 1 fails, the retry succeeds.
1445        assert_eq!(demo.head(Path::new(".")).await.unwrap(), "abc123");
1446    }
1447
1448    #[tokio::test]
1449    async fn default_retry_classifier_can_decline() {
1450        use crate::RetryPolicy;
1451        // A classifier that rejects the error → no retry; the first failure surfaces.
1452        let demo = Demo::with_runner(ScriptedRunner::new().on_sequence(
1453            ["git", "rev-parse"],
1454            [Reply::fail(128, "fatal"), Reply::ok("abc\n")],
1455        ))
1456        .default_retry(RetryPolicy::new().initial_backoff(Duration::ZERO), |_e| {
1457            false
1458        });
1459        assert!(demo.head(Path::new(".")).await.is_err());
1460    }
1461
1462    #[test]
1463    fn resolve_program_locates_the_clients_program_or_reports_not_found() {
1464        // A path-form program that exists (an executable temp file) resolves to
1465        // its own path, without spawning — the client-level preflight delegating
1466        // to `Command::resolve_program`.
1467        let dir = tempfile::tempdir().expect("temp dir");
1468        let exe = {
1469            #[cfg(unix)]
1470            {
1471                use std::os::unix::fs::PermissionsExt;
1472                let p = dir.path().join("pk-client-doctor");
1473                std::fs::write(&p, b"#!/bin/sh\nexit 0\n").expect("write stub");
1474                std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755))
1475                    .expect("chmod +x");
1476                p
1477            }
1478            #[cfg(not(unix))]
1479            {
1480                let p = dir.path().join("pk-client-doctor.exe");
1481                std::fs::write(&p, b"stub").expect("write stub");
1482                p
1483            }
1484        };
1485        let resolved = CliClient::new(&exe)
1486            .resolve_program()
1487            .expect("an existing program resolves via the client preflight");
1488        assert!(
1489            resolved
1490                .to_string_lossy()
1491                .eq_ignore_ascii_case(&exe.to_string_lossy()),
1492            "expected {exe:?}, got {resolved:?}"
1493        );
1494
1495        // A missing bare program surfaces the typed NotFound, attributed to the
1496        // client's program.
1497        let err = CliClient::new("pk-client-absent-tool-101")
1498            .resolve_program()
1499            .expect_err("a missing program must not resolve");
1500        assert!(err.is_not_found(), "must classify as not-found: {err:?}");
1501        assert_eq!(err.program(), Some("pk-client-absent-tool-101"));
1502    }
1503
1504    #[test]
1505    fn macro_generates_all_constructors() {
1506        let _real = Demo::new();
1507        let _default = Demo::default();
1508        let _fake = Demo::with_runner(ScriptedRunner::new())
1509            .default_timeout(Duration::from_secs(1))
1510            .default_env("GIT_TERMINAL_PROMPT", "0")
1511            .default_env_remove("GIT_PAGER");
1512    }
1513}