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) — the ones that
309 /// surface failure as an [`Error`] the classifier can inspect
310 /// (read it via [`is_transient`](crate::Error::is_transient) /
311 /// [`is_timeout`](crate::Error::is_timeout) / [`combined`](crate::Error::combined)).
312 /// The non-erroring `output_string`/`output_bytes` paths don't retry.
313 ///
314 /// **Each attempt re-executes the whole command** — a fresh process. Gate
315 /// retries on a classifier that matches *pre-effect* failures; see
316 /// [`Command::retry`]'s caveats on replayed side effects and one-shot stdin.
317 #[must_use]
318 pub fn default_retry(
319 mut self,
320 policy: RetryPolicy,
321 retry_if: impl Fn(&Error) -> bool + Send + Sync + 'static,
322 ) -> Self {
323 self.retry = Some(RetryConfig::new(policy, retry_if));
324 self
325 }
326
327 /// The injected runner — for direct [`ProcessRunner`]/[`ProcessRunnerExt`] use.
328 pub fn runner(&self) -> &R {
329 &self.runner
330 }
331
332 /// The default timeout, if one was set.
333 pub fn timeout(&self) -> Option<Duration> {
334 self.timeout
335 }
336
337 /// A [`Command`] for `program <args>` in the current directory, defaults
338 /// (timeout, env) pre-applied. Chain more builders (`.arg`, `.stdin`, …) for
339 /// dynamic-argument commands.
340 pub fn command<I, S>(&self, args: I) -> Command
341 where
342 I: IntoIterator<Item = S>,
343 S: AsRef<OsStr>,
344 {
345 self.apply_defaults(Command::new(&self.program).args(args))
346 }
347
348 /// A [`Command`] for `program <args>` run in `dir`, defaults (timeout, env)
349 /// pre-applied.
350 pub fn command_in<I, S>(&self, dir: &Path, args: I) -> Command
351 where
352 I: IntoIterator<Item = S>,
353 S: AsRef<OsStr>,
354 {
355 self.apply_defaults(Command::new(&self.program).current_dir(dir).args(args))
356 }
357
358 /// Resolve this client's `program` to a concrete executable path **without
359 /// spawning it** — the client-level preflight, for a *doctor* /
360 /// early-diagnosis check ("is this tool installed?") before running any
361 /// command, with **no** side effects (no process is started).
362 ///
363 /// Builds a command for the client's program with the client's defaults
364 /// applied — so a [`default_env`](Self::default_env) that relocates `PATH`
365 /// (or a [`default_env_fn`](Self::default_env_fn) that does) is honored
366 /// exactly as it would be at launch — then resolves it via
367 /// [`Command::resolve_program`](crate::Command::resolve_program), reusing the
368 /// **same** internal PATH/PATHEXT/execute-bit resolution the real spawn uses —
369 /// so a preflight **hit** is exactly what a run of this client would spawn,
370 /// including a bare name reachable only via a non-`.exe` PATHEXT extension on
371 /// Windows (`yarn.cmd`/`npx.cmd` shims), which the launch spawns via its
372 /// resolved path. The one residual gap is a Windows preflight **miss**: the OS
373 /// can still find a bare name through the application/current/system
374 /// directories this `PATH`-based model doesn't cover — see
375 /// [`Command::resolve_program`](crate::Command::resolve_program) for the full
376 /// parity contract.
377 ///
378 /// Returns the resolved **absolute** path on success. A synchronous, cheap
379 /// filesystem probe — no async runtime is required.
380 ///
381 /// # Errors
382 ///
383 /// [`ErrorReason::NotFound`](crate::ErrorReason::NotFound) when the program can't be
384 /// located — see [`Command::resolve_program`] for the full contract
385 /// (`searched` diagnostic, [`is_not_found`](crate::Error::is_not_found)
386 /// classification).
387 pub fn resolve_program(&self) -> Result<std::path::PathBuf> {
388 self.command(std::iter::empty::<&OsStr>()).resolve_program()
389 }
390
391 /// Fill the client's defaults into `command`, but only where the command has
392 /// not set them itself — so a fresh [`command()`](Self::command) (no settings)
393 /// gets every default, while a caller-supplied [`Command`] passed straight to
394 /// a verb keeps its own explicit timeout/cancel/env and only fills the gaps
395 /// (so a client-wide cancel token / timeout / env is not silently dropped
396 /// when you customize a single call). Idempotent — running it twice (a verb
397 /// applies it to a command that `command()` already defaulted) is a no-op
398 /// the second time.
399 fn apply_defaults(&self, mut command: Command) -> Command {
400 if command.accepts_default_timeout()
401 && let Some(timeout) = self.timeout
402 {
403 command = command.timeout(timeout);
404 }
405 if command.cancel_token().is_none()
406 && let Some(token) = &self.cancel
407 {
408 command = command.cancel_on(token.clone());
409 }
410 command.fill_default_envs(&self.envs);
411 // Dynamic env defaults, applied after the static ones: resolve and set each
412 // only when the key is still absent — so a per-command `env` or an explicit
413 // `default_env` wins, AND a resolver (which may do real work — read a vault)
414 // never runs when the key is already set at the moment defaults are applied.
415 for (key, resolver) in &self.env_fns {
416 if !command.has_env_override(key) {
417 command = command.env(key, resolver());
418 }
419 }
420 command.fill_default_retry(&self.retry);
421 command
422 }
423
424 /// Run, returning stdout (trailing whitespace trimmed) on success (errors on
425 /// a non-zero exit) — the same verb, with the same semantics, as
426 /// [`Command::run`](crate::Command::run) and
427 /// [`ProcessRunnerExt::run`]. Trims with
428 /// `trim_end`: the trailing newline is noise, but leading whitespace can be
429 /// significant.
430 ///
431 /// Accepts an argument list (`git.run(["status"])`) or a customized
432 /// [`Command`] (`git.run(git.command(["push"]).timeout(d))`) — see
433 /// [`IntoCommand`].
434 ///
435 /// # Errors
436 ///
437 /// The same surface as [`Command::run`](crate::Command::run): a launch
438 /// failure ([`ErrorReason::NotFound`](crate::ErrorReason::NotFound) / [`ErrorReason::Spawn`](crate::ErrorReason::Spawn) / [`ErrorReason::Unsupported`](crate::ErrorReason::Unsupported) /
439 /// [`ErrorReason::Io`](crate::ErrorReason::Io), and — via the client's runner — [`ErrorReason::Cancelled`](crate::ErrorReason::Cancelled) on a
440 /// pre-cancelled token), a non-accepted exit ([`ErrorReason::Exit`](crate::ErrorReason::Exit)),
441 /// [`ErrorReason::Signalled`](crate::ErrorReason::Signalled), [`ErrorReason::Timeout`](crate::ErrorReason::Timeout), [`ErrorReason::Cancelled`](crate::ErrorReason::Cancelled),
442 /// [`ErrorReason::OutputTooLarge`](crate::ErrorReason::OutputTooLarge) (a fail-loud buffer truncated the presented
443 /// stdout), or [`ErrorReason::Stdin`](crate::ErrorReason::Stdin).
444 pub async fn run(&self, call: impl IntoCommand<R>) -> Result<String> {
445 self.runner.run(&call.into_command(self)).await
446 }
447
448 /// Run, requiring an accepted exit, and return the full
449 /// [`ProcessResult`] (untrimmed) — the [`CliClient`] analogue of
450 /// [`ProcessRunnerExt::checked`]; the
451 /// building block when you need the whole result after success-checking.
452 ///
453 /// # Errors
454 ///
455 /// The same surface as [`Command::checked`](crate::Command::checked): the
456 /// launch failures, plus [`ErrorReason::Exit`](crate::ErrorReason::Exit) / [`ErrorReason::Signalled`](crate::ErrorReason::Signalled) /
457 /// [`ErrorReason::Timeout`](crate::ErrorReason::Timeout) / [`ErrorReason::Cancelled`](crate::ErrorReason::Cancelled) / [`ErrorReason::Stdin`](crate::ErrorReason::Stdin). Being the
458 /// lenient building block, it does not fail loud on a bounded-buffer
459 /// truncation, so it never returns [`ErrorReason::OutputTooLarge`](crate::ErrorReason::OutputTooLarge).
460 pub async fn checked(&self, call: impl IntoCommand<R>) -> Result<ProcessResult<String>> {
461 self.runner.checked(&call.into_command(self)).await
462 }
463
464 /// Run, capturing the full result without erroring on a non-zero exit — the
465 /// same verb as [`ProcessRunner::output_string`].
466 ///
467 /// # Errors
468 ///
469 /// The same surface as
470 /// [`Command::output_string`](crate::Command::output_string): a non-zero
471 /// exit, a timeout, and a signal-kill are *captured* in the returned
472 /// [`ProcessResult`], not raised; beyond the launch failures, only
473 /// [`ErrorReason::Cancelled`](crate::ErrorReason::Cancelled), [`ErrorReason::OutputTooLarge`](crate::ErrorReason::OutputTooLarge) (a fail-loud overflow),
474 /// [`ErrorReason::Stdin`](crate::ErrorReason::Stdin), and [`ErrorReason::Io`](crate::ErrorReason::Io) surface.
475 pub async fn output_string(&self, call: impl IntoCommand<R>) -> Result<ProcessResult<String>> {
476 self.runner.output_string(&call.into_command(self)).await
477 }
478
479 /// Run, capturing stdout as **raw bytes** (stderr as text), without erroring
480 /// on a non-zero exit — the same verb as [`ProcessRunner::output_bytes`].
481 /// For binary tools whose stdout is not UTF-8.
482 ///
483 /// # Errors
484 ///
485 /// Identical to [`output_string`](Self::output_string), except a fail-loud
486 /// [`ErrorReason::OutputTooLarge`](crate::ErrorReason::OutputTooLarge) applies to the raw stdout *byte* ceiling. Note
487 /// that a runner that only implements
488 /// [`output_string`](crate::ProcessRunner::output_string) surfaces
489 /// [`ErrorReason::Unsupported`](crate::ErrorReason::Unsupported) here (byte capture routes through
490 /// [`start`](crate::ProcessRunner::start)).
491 pub async fn output_bytes(&self, call: impl IntoCommand<R>) -> Result<ProcessResult<Vec<u8>>> {
492 self.runner.output_bytes(&call.into_command(self)).await
493 }
494
495 /// Run for the side effect, discarding stdout (errors on a non-zero exit) —
496 /// the same verb as
497 /// [`ProcessRunnerExt::run_unit`].
498 ///
499 /// # Errors
500 ///
501 /// The same surface as [`checked`](Self::checked) (launch failures plus
502 /// [`ErrorReason::Exit`](crate::ErrorReason::Exit) / [`ErrorReason::Signalled`](crate::ErrorReason::Signalled) / [`ErrorReason::Timeout`](crate::ErrorReason::Timeout) /
503 /// [`ErrorReason::Cancelled`](crate::ErrorReason::Cancelled) / [`ErrorReason::Stdin`](crate::ErrorReason::Stdin)); only the output is discarded.
504 pub async fn run_unit(&self, call: impl IntoCommand<R>) -> Result<()> {
505 self.runner.run_unit(&call.into_command(self)).await
506 }
507
508 /// Run and return the exit code (e.g. `git diff --quiet`, `gh auth status`)
509 /// — never errors on a non-zero exit. The same verb as
510 /// [`Command::exit_code`](crate::Command::exit_code).
511 ///
512 /// # Errors
513 ///
514 /// The launch failures, plus — when the run produced no code —
515 /// [`ErrorReason::Timeout`](crate::ErrorReason::Timeout), [`ErrorReason::Signalled`](crate::ErrorReason::Signalled), or [`ErrorReason::Cancelled`](crate::ErrorReason::Cancelled). A
516 /// non-zero exit is returned as the code, not raised.
517 pub async fn exit_code(&self, call: impl IntoCommand<R>) -> Result<i32> {
518 self.runner.exit_code(&call.into_command(self)).await
519 }
520
521 /// Run a predicate and read its exit code as a boolean: exit `0` →
522 /// `Ok(true)`, exit `1` → `Ok(false)`, anything else → `Err`. Collapses the
523 /// `match code { 0 => …, 1 => …, _ => Err }` idiom for commands whose exit
524 /// code is the answer (`git diff --quiet`, `git show-ref --verify --quiet`,
525 /// `grep -q`, …); other codes / timeout / signal-kill all error.
526 ///
527 /// # Errors
528 ///
529 /// Any exit code other than `0`/`1` becomes [`ErrorReason::Exit`](crate::ErrorReason::Exit), and — atop the
530 /// launch failures — a run with no code errors as [`ErrorReason::Timeout`](crate::ErrorReason::Timeout),
531 /// [`ErrorReason::Signalled`](crate::ErrorReason::Signalled), or [`ErrorReason::Cancelled`](crate::ErrorReason::Cancelled).
532 pub async fn probe(&self, call: impl IntoCommand<R>) -> Result<bool> {
533 self.runner.probe(&call.into_command(self)).await
534 }
535
536 /// Stream stdout and return the first line matching `predicate` (`None` if
537 /// the stream ends first) — the [`CliClient`] analogue of
538 /// [`ProcessRunnerExt::first_line`],
539 /// bounded by the command's [`timeout`](crate::Command::timeout).
540 ///
541 /// # Errors
542 ///
543 /// The launch failures, plus [`ErrorReason::Timeout`](crate::ErrorReason::Timeout) when a command
544 /// [`timeout`](crate::Command::timeout) is set and its deadline elapses
545 /// mid-stream (tearing the process down), [`ErrorReason::Cancelled`](crate::ErrorReason::Cancelled), or
546 /// [`ErrorReason::Io`](crate::ErrorReason::Io) while streaming. A stream that ends with no match is
547 /// `Ok(None)`, not an error.
548 pub async fn first_line<F>(
549 &self,
550 call: impl IntoCommand<R>,
551 predicate: F,
552 ) -> Result<Option<String>>
553 where
554 F: Fn(&str) -> bool + Send,
555 {
556 self.runner
557 .first_line(&call.into_command(self), predicate)
558 .await
559 }
560
561 /// Run (errors on a non-zero exit) and feed stdout to an infallible
562 /// `parse` — the shape of git/jj struct-returning commands. Fails loud on a
563 /// bounded-buffer truncation. Delegates to
564 /// [`ProcessRunnerExt::parse`].
565 ///
566 /// # Errors
567 ///
568 /// The success-checking surface of [`run`](Self::run) (launch failures plus
569 /// [`ErrorReason::Exit`](crate::ErrorReason::Exit) / [`ErrorReason::Signalled`](crate::ErrorReason::Signalled) / [`ErrorReason::Timeout`](crate::ErrorReason::Timeout) /
570 /// [`ErrorReason::Cancelled`](crate::ErrorReason::Cancelled) / [`ErrorReason::Stdin`](crate::ErrorReason::Stdin)), plus [`ErrorReason::OutputTooLarge`](crate::ErrorReason::OutputTooLarge)
571 /// when a fail-loud buffer truncated the stdout the parser would see. The
572 /// `parse` closure is infallible, so it adds no error.
573 pub async fn parse<T, F>(&self, call: impl IntoCommand<R>, parse: F) -> Result<T>
574 where
575 T: Send,
576 F: FnOnce(&str) -> T + Send,
577 {
578 self.runner.parse(&call.into_command(self), parse).await
579 }
580
581 /// Run (errors on a non-zero exit) and feed stdout to a *fallible* `parse` —
582 /// the shape of JSON deserialization, where a parse failure becomes
583 /// [`ErrorReason::Parse`](crate::ErrorReason::Parse). Fails loud on a bounded-buffer
584 /// truncation. Delegates to
585 /// [`ProcessRunnerExt::try_parse`].
586 ///
587 /// # Errors
588 ///
589 /// Everything [`parse`](Self::parse) can return, plus whatever the fallible
590 /// `parse` closure yields on malformed output — typically
591 /// [`ErrorReason::Parse`](crate::ErrorReason::Parse).
592 pub async fn try_parse<T, F>(&self, call: impl IntoCommand<R>, parse: F) -> Result<T>
593 where
594 T: Send,
595 F: FnOnce(&str) -> Result<T> + Send,
596 {
597 self.runner.try_parse(&call.into_command(self), parse).await
598 }
599}
600
601/// Scaffold a typed CLI-wrapper struct around a [`CliClient`].
602///
603/// Expands `cli_client!(pub struct Git => "git");` into a
604/// `struct Git<R: ProcessRunner = JobRunner> { core: CliClient<R> }` with
605/// `new()` (real runner), a `Default` impl, `with_runner(runner)`, and
606/// `default_timeout(d)`. Implement the tool's typed methods on it, delegating to
607/// `self.core` — see the *Wrapping a CLI tool* section of the crate's
608/// `docs/testing.md` guide for a worked example.
609///
610/// This macro is **committed public API**. Because it is `#[macro_export]`,
611/// it lives at the crate root and is a stable part of the surface — the
612/// supported scaffold for typed CLI wrappers. The hand-rolled equivalent (a
613/// struct wrapping [`CliClient`]) remains valid and interchangeable.
614#[macro_export]
615macro_rules! cli_client {
616 ($(#[$meta:meta])* $vis:vis struct $name:ident => $binary:expr) => {
617 $(#[$meta])*
618 $vis struct $name<R: $crate::ProcessRunner = $crate::JobRunner> {
619 core: $crate::CliClient<R>,
620 }
621
622 impl $name<$crate::JobRunner> {
623 /// Create a client driving the real job-backed runner.
624 pub fn new() -> Self {
625 Self { core: $crate::CliClient::new($binary) }
626 }
627 }
628
629 impl ::core::default::Default for $name<$crate::JobRunner> {
630 fn default() -> Self {
631 Self::new()
632 }
633 }
634
635 impl<R: $crate::ProcessRunner> $name<R> {
636 /// Create a client driving `runner` — inject a fake in tests.
637 pub fn with_runner(runner: R) -> Self {
638 Self { core: $crate::CliClient::with_runner($binary, runner) }
639 }
640
641 /// Apply a default timeout to every command this client builds.
642 #[must_use]
643 pub fn default_timeout(mut self, timeout: ::core::time::Duration) -> Self {
644 self.core = self.core.default_timeout(timeout);
645 self
646 }
647
648 /// Set an environment variable on every command this client builds
649 /// (e.g. `GIT_TERMINAL_PROMPT=0`).
650 #[must_use]
651 pub fn default_env(
652 mut self,
653 key: impl ::core::convert::AsRef<::std::ffi::OsStr>,
654 value: impl ::core::convert::AsRef<::std::ffi::OsStr>,
655 ) -> Self {
656 self.core = self.core.default_env(key, value);
657 self
658 }
659
660 /// Remove an inherited environment variable on every command this
661 /// client builds.
662 #[must_use]
663 pub fn default_env_remove(
664 mut self,
665 key: impl ::core::convert::AsRef<::std::ffi::OsStr>,
666 ) -> Self {
667 self.core = self.core.default_env_remove(key);
668 self
669 }
670
671 /// Set an env variable on every command to a value computed per built
672 /// command (see `CliClient::default_env_fn`).
673 #[must_use]
674 pub fn default_env_fn<V, F>(
675 mut self,
676 key: impl ::core::convert::AsRef<::std::ffi::OsStr>,
677 resolver: F,
678 ) -> Self
679 where
680 V: ::core::convert::Into<::std::ffi::OsString>,
681 F: ::core::ops::Fn() -> V
682 + ::core::marker::Send
683 + ::core::marker::Sync
684 + 'static,
685 {
686 self.core = self.core.default_env_fn(key, resolver);
687 self
688 }
689 }
690
691 impl<R: $crate::ProcessRunner> $name<R> {
692 /// Cancel every command this client builds when `token` fires (a
693 /// per-command `cancel_on` replaces the default — see
694 /// `CliClient::default_cancel_on`).
695 #[must_use]
696 pub fn default_cancel_on(mut self, token: $crate::CancellationToken) -> Self {
697 self.core = self.core.default_cancel_on(token);
698 self
699 }
700
701 /// Retry every verb on a shared `RetryPolicy` + classifier
702 /// (see `CliClient::default_retry`).
703 #[must_use]
704 pub fn default_retry(
705 mut self,
706 policy: $crate::RetryPolicy,
707 retry_if: impl Fn(&$crate::Error) -> bool
708 + ::core::marker::Send
709 + ::core::marker::Sync
710 + 'static,
711 ) -> Self {
712 self.core = self.core.default_retry(policy, retry_if);
713 self
714 }
715 }
716 };
717}
718
719#[cfg(test)]
720mod tests {
721 use std::path::Path;
722 use std::time::Duration;
723
724 use super::*;
725 use crate::testing::{RecordingRunner, Reply, ScriptedRunner};
726 use crate::{Error, ErrorReason};
727
728 #[test]
729 fn debug_redacts_default_env_values_keeping_names() {
730 let client = CliClient::new("git")
731 .default_env("API_TOKEN", "topsecret-value")
732 .default_env_remove("GIT_PAGER");
733 let dbg = format!("{client:?}");
734 assert!(
735 !dbg.contains("topsecret-value"),
736 "env value must not appear in Debug: {dbg}"
737 );
738 assert!(
739 dbg.contains("API_TOKEN") && dbg.contains("GIT_PAGER"),
740 "env names should appear: {dbg}"
741 );
742 }
743
744 #[test]
745 fn client_is_clone_with_the_default_runner() {
746 // `Command`/`Pipeline` are `Clone`; so is the default-runner `CliClient`,
747 // so the whole CLI-wrapper family clones uniformly (e.g. to own a `'static`
748 // value for a spawned task or an async-runtime bridge).
749 fn assert_clone<T: Clone>() {}
750 assert_clone::<CliClient>();
751
752 let client = CliClient::new("git")
753 .default_timeout(Duration::from_secs(3))
754 .default_env("GIT_TERMINAL_PROMPT", "0")
755 .default_cancel_on(tokio_util::sync::CancellationToken::new());
756 let clone = client.clone();
757 // Every field (program, timeout, env defaults, and the presence of a
758 // shared cancel token) survives the clone verbatim.
759 assert_eq!(format!("{client:?}"), format!("{clone:?}"));
760 }
761
762 crate::cli_client!(struct Demo => "git");
763
764 impl<R: ProcessRunner> Demo<R> {
765 async fn head(&self, dir: &Path) -> Result<String> {
766 self.core
767 .run(self.core.command_in(dir, ["rev-parse", "HEAD"]))
768 .await
769 }
770 async fn is_clean(&self, dir: &Path) -> Result<bool> {
771 Ok(self
772 .core
773 .exit_code(self.core.command_in(dir, ["diff", "--quiet"]))
774 .await?
775 == 0)
776 }
777 async fn branches(&self, dir: &Path) -> Result<Vec<String>> {
778 self.core
779 .parse(self.core.command_in(dir, ["branch"]), |s| {
780 s.lines().map(|l| l.trim().to_owned()).collect()
781 })
782 .await
783 }
784 }
785
786 #[tokio::test]
787 async fn run_trims_trailing_whitespace_only() {
788 let demo = Demo::with_runner(
789 ScriptedRunner::new().on(["git", "rev-parse"], Reply::ok(" abc123 \n")),
790 );
791 assert_eq!(demo.head(Path::new(".")).await.unwrap(), " abc123");
792 }
793
794 #[tokio::test]
795 async fn exit_code_maps_exit_status() {
796 let demo = Demo::with_runner(ScriptedRunner::new().on(["git", "diff"], Reply::fail(1, "")));
797 assert!(!demo.is_clean(Path::new(".")).await.unwrap());
798 }
799
800 #[tokio::test]
801 async fn parse_builds_a_typed_value() {
802 let demo = Demo::with_runner(
803 ScriptedRunner::new().on(["git", "branch"], Reply::ok("main\nfeature\n")),
804 );
805 assert_eq!(
806 demo.branches(Path::new(".")).await.unwrap(),
807 vec!["main", "feature"]
808 );
809 }
810
811 #[tokio::test]
812 async fn try_parse_maps_failure_to_parse_error() {
813 let client = CliClient::with_runner(
814 "gh",
815 ScriptedRunner::new().fallback(Reply::ok("not a number")),
816 );
817 let err = client
818 .try_parse::<u32, _>(client.command(["x"]), |s| {
819 s.trim().parse::<u32>().map_err(|e| {
820 Error::from(ErrorReason::Parse {
821 program: "gh".into(),
822 message: e.to_string(),
823 })
824 })
825 })
826 .await
827 .unwrap_err();
828 assert!(
829 matches!(err.reason(), ErrorReason::Parse { .. }),
830 "got {err:?}"
831 );
832 }
833
834 #[tokio::test]
835 async fn verbs_accept_args_directly_or_a_customized_command() {
836 use std::time::Duration;
837 let runner = ScriptedRunner::new().on(["git", "status"], Reply::ok("clean"));
838 let client = CliClient::with_runner("git", runner);
839
840 // Argument list — the program comes from the client, defaults applied.
841 assert_eq!(client.run(["status"]).await.unwrap(), "clean");
842 assert_eq!(client.run(vec!["status"]).await.unwrap(), "clean");
843 // A customized Command runs through the same verb (pass-through).
844 let custom = client.command(["status"]).timeout(Duration::from_secs(3));
845 assert_eq!(custom.configured_timeout(), Some(Duration::from_secs(3)));
846 assert_eq!(client.run(custom).await.unwrap(), "clean");
847 let args = ["status"];
848 assert_eq!(client.run(&args).await.unwrap(), "clean");
849 assert_eq!(client.run(&args[..]).await.unwrap(), "clean");
850 let result = client.checked(["status"]).await.unwrap();
851 assert_eq!(result.stdout(), "clean");
852 }
853
854 #[tokio::test]
855 async fn first_line_verb_streams_and_matches() {
856 let runner =
857 ScriptedRunner::new().on(["git", "log"], Reply::lines(["one", "two", "three"]));
858 let client = CliClient::with_runner("git", runner);
859 let found = client
860 .first_line(["log"], |line| line.starts_with('t'))
861 .await
862 .unwrap();
863 assert_eq!(found.as_deref(), Some("two"));
864 }
865
866 #[tokio::test]
867 async fn when_predicate_reads_public_command_accessors() {
868 // Proves `Command`'s accessors are public enough for an external
869 // `ScriptedRunner::when` predicate to inspect the command.
870 let runner = ScriptedRunner::new()
871 .when(
872 |c| c.working_dir() == Some(Path::new("/repo")),
873 Reply::ok("in-repo"),
874 )
875 .fallback(Reply::ok("elsewhere"));
876 let client = CliClient::with_runner("git", runner);
877 assert_eq!(
878 client
879 .run(client.command_in(Path::new("/repo"), ["status"]))
880 .await
881 .unwrap(),
882 "in-repo"
883 );
884 assert_eq!(
885 client.run(client.command(["status"])).await.unwrap(),
886 "elsewhere"
887 );
888 }
889
890 #[tokio::test]
891 async fn recording_runner_captures_args_cwd_and_absence() {
892 let rec = RecordingRunner::replying(Reply::ok("https://gh/pr/2\n"));
893 let client = CliClient::with_runner("gh", &rec);
894 let _ = client
895 .run(client.command_in(Path::new("/repo"), ["pr", "create", "--title", "T"]))
896 .await
897 .unwrap();
898
899 let call = rec.only_call();
900 assert_eq!(call.cwd.as_deref(), Some(std::path::Path::new("/repo")));
901 assert_eq!(call.args_str(), ["pr", "create", "--title", "T"]);
902 assert!(!call.has_flag("--base"), "no --base flag was passed");
903 }
904
905 #[tokio::test]
906 async fn exit_code_errors_on_timeout() {
907 let client = CliClient::with_runner("gh", ScriptedRunner::new().fallback(Reply::timeout()));
908 assert!(matches!(
909 client
910 .exit_code(client.command(["auth", "status"]))
911 .await
912 .unwrap_err()
913 .reason(),
914 ErrorReason::Timeout { .. }
915 ));
916 }
917
918 #[tokio::test]
919 async fn default_timeout_is_applied() {
920 let client = CliClient::new("git").default_timeout(Duration::from_secs(7));
921 assert_eq!(
922 client.command(["status"]).configured_timeout(),
923 Some(Duration::from_secs(7))
924 );
925 }
926
927 #[tokio::test]
928 async fn probe_maps_exit_code_to_bool() {
929 let client = CliClient::with_runner(
930 "git",
931 ScriptedRunner::new()
932 .on(["git", "diff"], Reply::fail(1, ""))
933 .fallback(Reply::ok("")),
934 );
935 // `git diff --quiet` exits 1 (dirty) -> false; anything else (0) -> true.
936 assert!(
937 !client
938 .probe(client.command(["diff", "--quiet"]))
939 .await
940 .unwrap()
941 );
942 assert!(client.probe(client.command(["status"])).await.unwrap());
943 }
944
945 #[tokio::test]
946 async fn default_env_is_applied_to_every_command() {
947 use std::ffi::OsString;
948 let client = CliClient::new("git").default_env("GIT_TERMINAL_PROMPT", "0");
949 for cmd in [
950 client.command(["status"]),
951 client.command_in(Path::new("."), ["fetch"]),
952 ] {
953 assert!(
954 cmd.env_overrides()
955 .iter()
956 .any(|(k, v)| k == "GIT_TERMINAL_PROMPT"
957 && v.as_deref() == Some(OsString::from("0").as_os_str())),
958 "default env missing on built command",
959 );
960 }
961 }
962
963 #[tokio::test]
964 async fn default_env_reaches_the_invocation() {
965 let rec = RecordingRunner::replying(Reply::ok("ok\n"));
966 let client = CliClient::with_runner("git", &rec).default_env("GIT_TERMINAL_PROMPT", "0");
967 let _ = client.run(client.command(["status"])).await.unwrap();
968 let call = rec.only_call();
969 assert!(
970 call.envs
971 .iter()
972 .any(|(k, v)| k == "GIT_TERMINAL_PROMPT" && v.is_some()),
973 "env override did not reach the runner: {:?}",
974 call.envs
975 );
976 }
977
978 #[test]
979 fn duplicate_default_env_is_last_registration_wins() {
980 use std::ffi::OsString;
981 // G3: matches Command::env's later-wins (was first-wins).
982 let client = CliClient::new("tool")
983 .default_env("K", "a")
984 .default_env("K", "b");
985 let cmd = client.command(["x"]);
986 let vals: Vec<_> = cmd
987 .env_overrides()
988 .iter()
989 .filter(|(k, _)| k == "K")
990 .collect();
991 assert_eq!(
992 vals.len(),
993 1,
994 "duplicate default_env collapses to one entry"
995 );
996 assert_eq!(
997 vals[0].1.as_deref(),
998 Some(OsString::from("b").as_os_str()),
999 "last registration wins"
1000 );
1001 // A later remove of the same key supersedes an earlier set.
1002 let removed = CliClient::new("tool")
1003 .default_env("K", "a")
1004 .default_env_remove("K")
1005 .command(["x"]);
1006 let k: Vec<_> = removed
1007 .env_overrides()
1008 .iter()
1009 .filter(|(k, _)| k == "K")
1010 .collect();
1011 assert_eq!(k.len(), 1);
1012 assert_eq!(k[0].1, None, "the later remove wins");
1013
1014 // Cross-channel: last-wins is *within* a channel. A static `default_env`
1015 // beats a `default_env_fn` for the same key EVEN when the fn is registered
1016 // later — the resolver is a fallback, so static-beats-dynamic is orthogonal
1017 // to registration order.
1018 let static_wins = CliClient::new("tool")
1019 .default_env("K", "static")
1020 .default_env_fn("K", || "dynamic")
1021 .command(["x"]);
1022 assert!(
1023 static_wins.env_overrides().iter().any(
1024 |(k, v)| k == "K" && v.as_deref() == Some(OsString::from("static").as_os_str())
1025 ),
1026 "a static default_env beats a later-registered default_env_fn"
1027 );
1028 }
1029
1030 #[test]
1031 fn no_timeout_command_ignores_a_client_default_timeout() {
1032 // G4: an explicitly-unbounded command opts out of the client gap-fill.
1033 let client = CliClient::new("tail").default_timeout(Duration::from_secs(9));
1034 let bounded = Command::new("tail").into_command(&client);
1035 assert_eq!(
1036 bounded.configured_timeout(),
1037 Some(Duration::from_secs(9)),
1038 "the default fills an unset command"
1039 );
1040 let unbounded = Command::new("tail").no_timeout().into_command(&client);
1041 assert_eq!(
1042 unbounded.configured_timeout(),
1043 None,
1044 "no_timeout opts out of the client default_timeout"
1045 );
1046 }
1047
1048 #[test]
1049 fn env_isolation_ignores_client_env_defaults() {
1050 // G2: a client default_env must not pierce env_clear/inherit_env isolation.
1051 let client = CliClient::new("tool").default_env("LANG", "C");
1052 let isolated = Command::new("tool").env_clear().into_command(&client);
1053 assert!(
1054 !isolated.env_overrides().iter().any(|(k, _)| k == "LANG"),
1055 "env_clear isolates from a client default_env"
1056 );
1057 let plain = Command::new("tool").into_command(&client);
1058 assert!(
1059 plain.env_overrides().iter().any(|(k, _)| k == "LANG"),
1060 "a non-isolated command still gets the client default"
1061 );
1062 }
1063
1064 #[tokio::test]
1065 async fn a_prebuilt_command_passed_to_a_verb_still_gets_client_defaults() {
1066 let token = crate::CancellationToken::new();
1067 let client = CliClient::new("git")
1068 .default_timeout(Duration::from_secs(9))
1069 .default_env("GIT_TERMINAL_PROMPT", "0")
1070 .default_cancel_on(token);
1071
1072 // Built WITHOUT the client (no defaults applied yet).
1073 let raw = Command::new("git").args(["push"]);
1074 let filled = raw.into_command(&client);
1075 assert_eq!(
1076 filled.configured_timeout(),
1077 Some(Duration::from_secs(9)),
1078 "the client default timeout fills the gap"
1079 );
1080 assert!(
1081 filled.cancel_token().is_some(),
1082 "the client cancel token reaches it"
1083 );
1084 assert!(
1085 filled
1086 .env_overrides()
1087 .iter()
1088 .any(|(k, _)| k == "GIT_TERMINAL_PROMPT"),
1089 "the client default env reaches it"
1090 );
1091
1092 let explicit = Command::new("git")
1093 .args(["push"])
1094 .timeout(Duration::from_secs(2))
1095 .env("GIT_TERMINAL_PROMPT", "1");
1096 let filled = explicit.into_command(&client);
1097 assert_eq!(
1098 filled.configured_timeout(),
1099 Some(Duration::from_secs(2)),
1100 "an explicit per-command timeout wins"
1101 );
1102 let prompt: Vec<_> = filled
1103 .env_overrides()
1104 .iter()
1105 .filter(|(k, _)| k == "GIT_TERMINAL_PROMPT")
1106 .collect();
1107 assert_eq!(prompt.len(), 1, "no duplicate env op for the same key");
1108 assert_eq!(
1109 prompt[0].1.as_deref(),
1110 Some(std::ffi::OsStr::new("1")),
1111 "the per-command env value wins over the client default"
1112 );
1113 }
1114
1115 #[tokio::test]
1116 async fn prebuilt_command_env_wins_over_a_case_differing_client_default() {
1117 let client = CliClient::new("git").default_env("Path", "from-client");
1118 let cmd = Command::new("git").env("PATH", "from-command");
1119 let filled = cmd.into_command(&client);
1120 let path_ops: Vec<_> = filled
1121 .env_overrides()
1122 .iter()
1123 .filter(|(k, _)| k.to_str().is_some_and(|k| k.eq_ignore_ascii_case("PATH")))
1124 .collect();
1125 #[cfg(windows)]
1126 {
1127 assert_eq!(
1128 path_ops.len(),
1129 1,
1130 "the case-differing client default for the same var is skipped"
1131 );
1132 assert_eq!(
1133 path_ops[0].1.as_deref(),
1134 Some(std::ffi::OsStr::new("from-command")),
1135 "the explicit per-command value wins"
1136 );
1137 }
1138 #[cfg(not(windows))]
1139 {
1140 assert_eq!(
1141 path_ops.len(),
1142 2,
1143 "on Unix PATH and Path are distinct variables — both kept"
1144 );
1145 }
1146 }
1147
1148 #[tokio::test]
1149 async fn default_cancel_on_is_applied_to_every_command() {
1150 let token = crate::CancellationToken::new();
1151 let client = CliClient::new("git").default_cancel_on(token);
1152 for cmd in [
1153 client.command(["status"]),
1154 client.command_in(Path::new("."), ["fetch"]),
1155 ] {
1156 assert!(
1157 cmd.cancel_token().is_some(),
1158 "default token missing on built command"
1159 );
1160 }
1161 assert!(format!("{client:?}").contains("has_default_cancel: true"));
1162 }
1163
1164 #[tokio::test(start_paused = true)]
1165 async fn per_command_cancel_on_overrides_the_default() {
1166 use crate::CancellationToken;
1167 let default_token = CancellationToken::new();
1168 let explicit = CancellationToken::new();
1169 let client = CliClient::with_runner("gh", ScriptedRunner::new().fallback(Reply::pending()))
1170 .default_cancel_on(default_token.clone());
1171 let cmd = client.command(["run", "watch"]).cancel_on(explicit.clone());
1172
1173 let call = client.output_string(cmd);
1174 tokio::pin!(call);
1175 default_token.cancel();
1176 assert!(
1177 tokio::time::timeout(Duration::from_secs(3600), &mut call)
1178 .await
1179 .is_err(),
1180 "the replaced default token must not cancel the call"
1181 );
1182 explicit.cancel();
1183 let err = tokio::time::timeout(Duration::from_secs(3600), call)
1184 .await
1185 .expect("the explicit token must resolve the call")
1186 .expect_err("explicit token cancels");
1187 assert!(
1188 matches!(err.reason(), ErrorReason::Cancelled { .. }),
1189 "got {err:?}"
1190 );
1191 }
1192
1193 #[tokio::test(start_paused = true)]
1194 async fn acceptance_pending_reply_with_client_default_cancel() {
1195 use crate::CancellationToken;
1196 let token = CancellationToken::new();
1197 let rec = RecordingRunner::new(
1198 ScriptedRunner::new().on(["gh", "run", "watch"], Reply::pending()),
1199 );
1200 let client = CliClient::with_runner("gh", &rec).default_cancel_on(token.clone());
1201
1202 let call = client.output_string(client.command(["run", "watch", "123"]));
1203 tokio::pin!(call);
1204 assert!(
1205 tokio::time::timeout(Duration::from_secs(3600), &mut call)
1206 .await
1207 .is_err(),
1208 "must not resolve before the token fires"
1209 );
1210 token.cancel();
1211 match tokio::time::timeout(Duration::from_secs(3600), call)
1212 .await
1213 .expect("the cancelled token must resolve the call")
1214 .map_err(|e| e.into_reason())
1215 {
1216 Err(ErrorReason::Cancelled { program }) => assert_eq!(program, "gh"),
1217 other => panic!("expected ErrorReason::Cancelled, got {other:?}"),
1218 }
1219 assert_eq!(rec.only_call().args_str(), ["run", "watch", "123"]);
1220 }
1221
1222 #[tokio::test(start_paused = true)]
1223 async fn clone_shares_the_default_cancel_token() {
1224 // The load-bearing half of `CliClient: Clone`'s doc: a clone shares the
1225 // SAME default cancel token, not a copy. A command built from the *clone*
1226 // must therefore respond to the token the *original* was built with —
1227 // parking until it fires, then resolving as `Cancelled`.
1228 use crate::CancellationToken;
1229 let token = CancellationToken::new();
1230 let rec = RecordingRunner::new(
1231 ScriptedRunner::new().on(["gh", "run", "watch"], Reply::pending()),
1232 );
1233 let original = CliClient::with_runner("gh", &rec).default_cancel_on(token.clone());
1234 let clone = original.clone();
1235
1236 let call = clone.output_string(clone.command(["run", "watch", "123"]));
1237 tokio::pin!(call);
1238 assert!(
1239 tokio::time::timeout(Duration::from_secs(3600), &mut call)
1240 .await
1241 .is_err(),
1242 "the clone's command must park on the shared token, not resolve early"
1243 );
1244 token.cancel();
1245 match tokio::time::timeout(Duration::from_secs(3600), call)
1246 .await
1247 .expect("cancelling the shared token must resolve the clone's call")
1248 .map_err(|e| e.into_reason())
1249 {
1250 Err(ErrorReason::Cancelled { program }) => assert_eq!(program, "gh"),
1251 other => panic!("expected ErrorReason::Cancelled, got {other:?}"),
1252 }
1253 }
1254
1255 #[test]
1256 fn macro_emits_default_cancel_on() {
1257 let _client = Demo::with_runner(ScriptedRunner::new())
1258 .default_cancel_on(crate::CancellationToken::new());
1259 }
1260
1261 #[test]
1262 fn macro_emits_default_env_fn() {
1263 let _client = Demo::with_runner(ScriptedRunner::new()).default_env_fn("TOKEN", || "x");
1264 }
1265
1266 #[test]
1267 fn default_env_fn_resolves_per_build_and_respects_precedence() {
1268 use crate::testing::Invocation;
1269 use std::sync::Arc;
1270 use std::sync::atomic::{AtomicU32, Ordering};
1271
1272 let counter = Arc::new(AtomicU32::new(0));
1273 let c = Arc::clone(&counter);
1274 let client = CliClient::new("git").default_env_fn("TOKEN", move || {
1275 format!("t{}", c.fetch_add(1, Ordering::SeqCst))
1276 });
1277
1278 // Each built command resolves a FRESH value.
1279 let inv1 = Invocation::from_command(&client.command(["status"]));
1280 let inv2 = Invocation::from_command(&client.command(["status"]));
1281 assert!(inv1.env_is("TOKEN", "t0"));
1282 assert!(inv2.env_is("TOKEN", "t1"));
1283
1284 // A pre-built command that sets TOKEN keeps its own value, and the resolver
1285 // is NOT invoked for it (the counter stays at the 2 resolves above).
1286 let filled = client.apply_defaults(Command::new("git").env("TOKEN", "explicit"));
1287 assert!(Invocation::from_command(&filled).env_is("TOKEN", "explicit"));
1288 assert_eq!(
1289 counter.load(Ordering::SeqCst),
1290 2,
1291 "the resolver must be skipped when the key is already set"
1292 );
1293 }
1294
1295 #[test]
1296 fn default_env_fn_precedence_against_static_and_self() {
1297 use crate::testing::Invocation;
1298 use std::sync::Arc;
1299 use std::sync::atomic::{AtomicU32, Ordering};
1300
1301 // (a) A static `default_env` for the same key wins, and the resolver is
1302 // skipped entirely (the doc's stated precedence).
1303 let calls = Arc::new(AtomicU32::new(0));
1304 let c = Arc::clone(&calls);
1305 let client = CliClient::new("git")
1306 .default_env("TOKEN", "static")
1307 .default_env_fn("TOKEN", move || {
1308 c.fetch_add(1, Ordering::SeqCst);
1309 "dynamic"
1310 });
1311 let inv = Invocation::from_command(&client.command(["status"]));
1312 assert!(inv.env_is("TOKEN", "static"));
1313 assert_eq!(
1314 calls.load(Ordering::SeqCst),
1315 0,
1316 "a static default_env must shadow the resolver, which then never runs"
1317 );
1318
1319 // (b) A per-command `env_remove` for the key suppresses the resolver too —
1320 // an explicit unset beats the dynamic default, like the static one.
1321 let calls = Arc::new(AtomicU32::new(0));
1322 let c = Arc::clone(&calls);
1323 let client = CliClient::new("git").default_env_fn("TOKEN", move || {
1324 c.fetch_add(1, Ordering::SeqCst);
1325 "dynamic"
1326 });
1327 let removed = client.apply_defaults(Command::new("git").env_remove("TOKEN"));
1328 assert!(matches!(
1329 Invocation::from_command(&removed).env("TOKEN"),
1330 Some(None)
1331 ));
1332 assert_eq!(calls.load(Ordering::SeqCst), 0);
1333
1334 // (c) Two resolvers for the same key: the LAST registered wins (G3 —
1335 // consistent with `default_env`'s later-wins), and the superseded
1336 // first resolver never runs (it is dropped at registration time).
1337 let first_ran = Arc::new(AtomicU32::new(0));
1338 let f = Arc::clone(&first_ran);
1339 let client = CliClient::new("git")
1340 .default_env_fn("TOKEN", move || {
1341 f.fetch_add(1, Ordering::SeqCst);
1342 "first"
1343 })
1344 .default_env_fn("TOKEN", || "second");
1345 let inv = Invocation::from_command(&client.command(["status"]));
1346 assert!(
1347 inv.env_is("TOKEN", "second"),
1348 "the last-registered resolver wins"
1349 );
1350 assert_eq!(
1351 first_ran.load(Ordering::SeqCst),
1352 0,
1353 "the superseded resolver never runs"
1354 );
1355 }
1356
1357 #[test]
1358 fn default_env_fn_value_is_baked_in_and_stable_across_clone_and_reuse() {
1359 use crate::testing::Invocation;
1360 use std::sync::Arc;
1361 use std::sync::atomic::{AtomicU32, Ordering};
1362
1363 let counter = Arc::new(AtomicU32::new(0));
1364 let c = Arc::clone(&counter);
1365 let client = CliClient::new("git").default_env_fn("TOKEN", move || {
1366 format!("t{}", c.fetch_add(1, Ordering::SeqCst))
1367 });
1368
1369 // A built command captures its value; re-running it (here: re-deriving the
1370 // Invocation) does not re-resolve — the retry loop reuses this same value.
1371 let built = client.command(["status"]);
1372 assert!(Invocation::from_command(&built).env_is("TOKEN", "t0"));
1373 assert!(Invocation::from_command(&built).env_is("TOKEN", "t0"));
1374
1375 // A clone shares the resolver's state (Arc), so it continues the sequence
1376 // rather than restarting it — documents the shared-state semantics.
1377 let clone = client.clone();
1378 assert!(Invocation::from_command(&clone.command(["status"])).env_is("TOKEN", "t1"));
1379 assert_eq!(counter.load(Ordering::SeqCst), 2);
1380 }
1381
1382 #[tokio::test]
1383 async fn default_retry_retries_client_verbs() {
1384 use crate::RetryPolicy;
1385 // on_sequence: a retryable failure then success — the client-wide retry
1386 // re-runs the verb. Exercises the macro-generated `default_retry`, the
1387 // apply_defaults gap-fill, and the retry loop end-to-end.
1388 let demo = Demo::with_runner(ScriptedRunner::new().on_sequence(
1389 ["git", "rev-parse"],
1390 [
1391 Reply::fail(128, "could not read from remote"),
1392 Reply::ok("abc123\n"),
1393 ],
1394 ))
1395 .default_retry(RetryPolicy::new().initial_backoff(Duration::ZERO), |_e| {
1396 true
1397 });
1398 // `head` runs `git rev-parse HEAD`: attempt 1 fails, the retry succeeds.
1399 assert_eq!(demo.head(Path::new(".")).await.unwrap(), "abc123");
1400 }
1401
1402 #[tokio::test]
1403 async fn default_retry_classifier_can_decline() {
1404 use crate::RetryPolicy;
1405 // A classifier that rejects the error → no retry; the first failure surfaces.
1406 let demo = Demo::with_runner(ScriptedRunner::new().on_sequence(
1407 ["git", "rev-parse"],
1408 [Reply::fail(128, "fatal"), Reply::ok("abc\n")],
1409 ))
1410 .default_retry(RetryPolicy::new().initial_backoff(Duration::ZERO), |_e| {
1411 false
1412 });
1413 assert!(demo.head(Path::new(".")).await.is_err());
1414 }
1415
1416 #[test]
1417 fn resolve_program_locates_the_clients_program_or_reports_not_found() {
1418 // A path-form program that exists (an executable temp file) resolves to
1419 // its own path, without spawning — the client-level preflight delegating
1420 // to `Command::resolve_program`.
1421 let dir = tempfile::tempdir().expect("temp dir");
1422 let exe = {
1423 #[cfg(unix)]
1424 {
1425 use std::os::unix::fs::PermissionsExt;
1426 let p = dir.path().join("pk-client-doctor");
1427 std::fs::write(&p, b"#!/bin/sh\nexit 0\n").expect("write stub");
1428 std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755))
1429 .expect("chmod +x");
1430 p
1431 }
1432 #[cfg(not(unix))]
1433 {
1434 let p = dir.path().join("pk-client-doctor.exe");
1435 std::fs::write(&p, b"stub").expect("write stub");
1436 p
1437 }
1438 };
1439 let resolved = CliClient::new(&exe)
1440 .resolve_program()
1441 .expect("an existing program resolves via the client preflight");
1442 assert!(
1443 resolved
1444 .to_string_lossy()
1445 .eq_ignore_ascii_case(&exe.to_string_lossy()),
1446 "expected {exe:?}, got {resolved:?}"
1447 );
1448
1449 // A missing bare program surfaces the typed NotFound, attributed to the
1450 // client's program.
1451 let err = CliClient::new("pk-client-absent-tool-101")
1452 .resolve_program()
1453 .expect_err("a missing program must not resolve");
1454 assert!(err.is_not_found(), "must classify as not-found: {err:?}");
1455 assert_eq!(err.program(), Some("pk-client-absent-tool-101"));
1456 }
1457
1458 #[test]
1459 fn macro_generates_all_constructors() {
1460 let _real = Demo::new();
1461 let _default = Demo::default();
1462 let _fake = Demo::with_runner(ScriptedRunner::new())
1463 .default_timeout(Duration::from_secs(1))
1464 .default_env("GIT_TERMINAL_PROMPT", "0")
1465 .default_env_remove("GIT_PAGER");
1466 }
1467}