1use std::ffi::{OsStr, OsString};
15use std::path::Path;
16use std::time::Duration;
17
18use crate::command::Command;
19use crate::error::Result;
20use crate::result::ProcessResult;
21use crate::runner::{JobRunner, ProcessRunner, ProcessRunnerExt};
22
23mod sealed {
24 use std::ffi::OsStr;
25 pub trait Sealed {}
26 impl Sealed for crate::Command {}
27 impl<S: AsRef<OsStr>, const N: usize> Sealed for [S; N] {}
28 impl<S: AsRef<OsStr>> Sealed for Vec<S> {}
29 impl<S: AsRef<OsStr>> Sealed for &[S] {}
30}
31
32pub trait IntoCommand<R: ProcessRunner>: sealed::Sealed {
49 #[doc(hidden)]
51 fn into_command(self, client: &CliClient<R>) -> Command;
52}
53
54impl<R: ProcessRunner> IntoCommand<R> for Command {
55 fn into_command(self, client: &CliClient<R>) -> Command {
56 client.apply_defaults(self)
59 }
60}
61
62impl<R: ProcessRunner, S: AsRef<OsStr>, const N: usize> IntoCommand<R> for [S; N] {
63 fn into_command(self, client: &CliClient<R>) -> Command {
64 client.command(self)
65 }
66}
67
68impl<R: ProcessRunner, S: AsRef<OsStr>> IntoCommand<R> for Vec<S> {
69 fn into_command(self, client: &CliClient<R>) -> Command {
70 client.command(self)
71 }
72}
73
74impl<R: ProcessRunner, S: AsRef<OsStr>> IntoCommand<R> for &[S] {
75 fn into_command(self, client: &CliClient<R>) -> Command {
76 client.command(self)
77 }
78}
79
80pub struct CliClient<R: ProcessRunner = JobRunner> {
86 program: OsString,
87 runner: R,
88 timeout: Option<Duration>,
89 envs: Vec<(OsString, Option<OsString>)>,
93 cancel: Option<tokio_util::sync::CancellationToken>,
96}
97
98impl<R: ProcessRunner> std::fmt::Debug for CliClient<R> {
100 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
101 let mut d = f.debug_struct("CliClient");
102 d.field("program", &self.program)
103 .field("timeout", &self.timeout)
104 .field("env_names", &crate::command::redacted_env_names(&self.envs));
105 d.field("has_default_cancel", &self.cancel.is_some());
106 d.finish_non_exhaustive()
107 }
108}
109
110impl CliClient<JobRunner> {
111 pub fn new(program: impl AsRef<OsStr>) -> Self {
113 Self {
114 program: program.as_ref().to_os_string(),
115 runner: JobRunner,
116 timeout: None,
117 envs: Vec::new(),
118 cancel: None,
119 }
120 }
121}
122
123impl<R: ProcessRunner> CliClient<R> {
124 pub fn with_runner(program: impl AsRef<OsStr>, runner: R) -> Self {
126 Self {
127 program: program.as_ref().to_os_string(),
128 runner,
129 timeout: None,
130 envs: Vec::new(),
131 cancel: None,
132 }
133 }
134
135 #[must_use]
137 pub fn default_timeout(mut self, timeout: Duration) -> Self {
138 self.timeout = Some(timeout);
139 self
140 }
141
142 #[must_use]
146 pub fn default_env(mut self, key: impl AsRef<OsStr>, value: impl AsRef<OsStr>) -> Self {
147 self.envs.push((
148 key.as_ref().to_os_string(),
149 Some(value.as_ref().to_os_string()),
150 ));
151 self
152 }
153
154 #[must_use]
157 pub fn default_env_remove(mut self, key: impl AsRef<OsStr>) -> Self {
158 self.envs.push((key.as_ref().to_os_string(), None));
159 self
160 }
161
162 #[must_use]
180 pub fn default_cancel_on(mut self, token: tokio_util::sync::CancellationToken) -> Self {
181 self.cancel = Some(token);
182 self
183 }
184
185 pub fn runner(&self) -> &R {
187 &self.runner
188 }
189
190 pub fn timeout(&self) -> Option<Duration> {
192 self.timeout
193 }
194
195 pub fn command<I, S>(&self, args: I) -> Command
199 where
200 I: IntoIterator<Item = S>,
201 S: AsRef<OsStr>,
202 {
203 self.apply_defaults(Command::new(&self.program).args(args))
204 }
205
206 pub fn command_in<I, S>(&self, dir: &Path, args: I) -> Command
209 where
210 I: IntoIterator<Item = S>,
211 S: AsRef<OsStr>,
212 {
213 self.apply_defaults(Command::new(&self.program).current_dir(dir).args(args))
214 }
215
216 fn apply_defaults(&self, mut command: Command) -> Command {
225 if command.configured_timeout().is_none()
226 && let Some(timeout) = self.timeout
227 {
228 command = command.timeout(timeout);
229 }
230 if command.cancel_token().is_none()
231 && let Some(token) = &self.cancel
232 {
233 command = command.cancel_on(token.clone());
234 }
235 command.fill_default_envs(&self.envs);
236 command
237 }
238
239 pub async fn run(&self, call: impl IntoCommand<R>) -> Result<String> {
250 let command = call.into_command(self);
251 let result = self.runner.checked(&command).await?;
252 let policy = command.output_buffer_policy();
253 result.reject_if_truncated(policy.max_lines, policy.max_bytes)?;
254 Ok(result.into_stdout().trim_end().to_owned())
255 }
256
257 pub async fn checked(&self, call: impl IntoCommand<R>) -> Result<ProcessResult<String>> {
262 self.runner.checked(&call.into_command(self)).await
263 }
264
265 pub async fn output_string(&self, call: impl IntoCommand<R>) -> Result<ProcessResult<String>> {
268 self.runner.output_string(&call.into_command(self)).await
269 }
270
271 pub async fn output_bytes(&self, call: impl IntoCommand<R>) -> Result<ProcessResult<Vec<u8>>> {
275 self.runner.output_bytes(&call.into_command(self)).await
276 }
277
278 pub async fn run_unit(&self, call: impl IntoCommand<R>) -> Result<()> {
282 self.runner.run_unit(&call.into_command(self)).await
283 }
284
285 pub async fn exit_code(&self, call: impl IntoCommand<R>) -> Result<i32> {
289 self.runner.exit_code(&call.into_command(self)).await
290 }
291
292 pub async fn probe(&self, call: impl IntoCommand<R>) -> Result<bool> {
298 self.runner.probe(&call.into_command(self)).await
299 }
300
301 pub async fn first_line<F>(
306 &self,
307 call: impl IntoCommand<R>,
308 predicate: F,
309 ) -> Result<Option<String>>
310 where
311 F: Fn(&str) -> bool + Send,
312 {
313 self.runner
314 .first_line(&call.into_command(self), predicate)
315 .await
316 }
317
318 pub async fn parse<T, F>(&self, call: impl IntoCommand<R>, parse: F) -> Result<T>
323 where
324 T: Send,
325 F: FnOnce(&str) -> T + Send,
326 {
327 self.runner.parse(&call.into_command(self), parse).await
328 }
329
330 pub async fn try_parse<T, F>(&self, call: impl IntoCommand<R>, parse: F) -> Result<T>
336 where
337 T: Send,
338 F: FnOnce(&str) -> Result<T> + Send,
339 {
340 self.runner.try_parse(&call.into_command(self), parse).await
341 }
342}
343
344#[macro_export]
357macro_rules! cli_client {
358 ($(#[$meta:meta])* $vis:vis struct $name:ident => $binary:expr) => {
359 $(#[$meta])*
360 $vis struct $name<R: $crate::ProcessRunner = $crate::JobRunner> {
361 core: $crate::CliClient<R>,
362 }
363
364 impl $name<$crate::JobRunner> {
365 pub fn new() -> Self {
367 Self { core: $crate::CliClient::new($binary) }
368 }
369 }
370
371 impl ::core::default::Default for $name<$crate::JobRunner> {
372 fn default() -> Self {
373 Self::new()
374 }
375 }
376
377 impl<R: $crate::ProcessRunner> $name<R> {
378 pub fn with_runner(runner: R) -> Self {
380 Self { core: $crate::CliClient::with_runner($binary, runner) }
381 }
382
383 pub fn default_timeout(mut self, timeout: ::core::time::Duration) -> Self {
385 self.core = self.core.default_timeout(timeout);
386 self
387 }
388
389 pub fn default_env(
392 mut self,
393 key: impl ::core::convert::AsRef<::std::ffi::OsStr>,
394 value: impl ::core::convert::AsRef<::std::ffi::OsStr>,
395 ) -> Self {
396 self.core = self.core.default_env(key, value);
397 self
398 }
399
400 pub fn default_env_remove(
403 mut self,
404 key: impl ::core::convert::AsRef<::std::ffi::OsStr>,
405 ) -> Self {
406 self.core = self.core.default_env_remove(key);
407 self
408 }
409 }
410
411 impl<R: $crate::ProcessRunner> $name<R> {
412 pub fn default_cancel_on(mut self, token: $crate::CancellationToken) -> Self {
416 self.core = self.core.default_cancel_on(token);
417 self
418 }
419 }
420 };
421}
422
423#[cfg(test)]
424mod tests {
425 use std::path::Path;
426 use std::time::Duration;
427
428 use super::*;
429 use crate::Error;
430 use crate::testing::{RecordingRunner, Reply, ScriptedRunner};
431
432 #[test]
433 fn debug_redacts_default_env_values_keeping_names() {
434 let client = CliClient::new("git")
435 .default_env("API_TOKEN", "topsecret-value")
436 .default_env_remove("GIT_PAGER");
437 let dbg = format!("{client:?}");
438 assert!(
439 !dbg.contains("topsecret-value"),
440 "env value must not appear in Debug: {dbg}"
441 );
442 assert!(
443 dbg.contains("API_TOKEN") && dbg.contains("GIT_PAGER"),
444 "env names should appear: {dbg}"
445 );
446 }
447
448 crate::cli_client!(struct Demo => "git");
449
450 impl<R: ProcessRunner> Demo<R> {
451 async fn head(&self, dir: &Path) -> Result<String> {
452 self.core
453 .run(self.core.command_in(dir, ["rev-parse", "HEAD"]))
454 .await
455 }
456 async fn is_clean(&self, dir: &Path) -> Result<bool> {
457 Ok(self
458 .core
459 .exit_code(self.core.command_in(dir, ["diff", "--quiet"]))
460 .await?
461 == 0)
462 }
463 async fn branches(&self, dir: &Path) -> Result<Vec<String>> {
464 self.core
465 .parse(self.core.command_in(dir, ["branch"]), |s| {
466 s.lines().map(|l| l.trim().to_owned()).collect()
467 })
468 .await
469 }
470 }
471
472 #[tokio::test]
473 async fn run_trims_trailing_whitespace_only() {
474 let demo = Demo::with_runner(
475 ScriptedRunner::new().on(["git", "rev-parse"], Reply::ok(" abc123 \n")),
476 );
477 assert_eq!(demo.head(Path::new(".")).await.unwrap(), " abc123");
478 }
479
480 #[tokio::test]
481 async fn exit_code_maps_exit_status() {
482 let demo = Demo::with_runner(ScriptedRunner::new().on(["git", "diff"], Reply::fail(1, "")));
483 assert!(!demo.is_clean(Path::new(".")).await.unwrap());
484 }
485
486 #[tokio::test]
487 async fn parse_builds_a_typed_value() {
488 let demo = Demo::with_runner(
489 ScriptedRunner::new().on(["git", "branch"], Reply::ok("main\nfeature\n")),
490 );
491 assert_eq!(
492 demo.branches(Path::new(".")).await.unwrap(),
493 vec!["main", "feature"]
494 );
495 }
496
497 #[tokio::test]
498 async fn try_parse_maps_failure_to_parse_error() {
499 let client = CliClient::with_runner(
500 "gh",
501 ScriptedRunner::new().fallback(Reply::ok("not a number")),
502 );
503 let err = client
504 .try_parse::<u32, _>(client.command(["x"]), |s| {
505 s.trim().parse::<u32>().map_err(|e| Error::Parse {
506 program: "gh".into(),
507 message: e.to_string(),
508 })
509 })
510 .await
511 .unwrap_err();
512 assert!(matches!(err, Error::Parse { .. }), "got {err:?}");
513 }
514
515 #[tokio::test]
516 async fn verbs_accept_args_directly_or_a_customized_command() {
517 use std::time::Duration;
518 let runner = ScriptedRunner::new().on(["git", "status"], Reply::ok("clean"));
519 let client = CliClient::with_runner("git", runner);
520
521 assert_eq!(client.run(["status"]).await.unwrap(), "clean");
523 assert_eq!(client.run(vec!["status"]).await.unwrap(), "clean");
524 let custom = client.command(["status"]).timeout(Duration::from_secs(3));
526 assert_eq!(custom.configured_timeout(), Some(Duration::from_secs(3)));
527 assert_eq!(client.run(custom).await.unwrap(), "clean");
528 let args = ["status"];
529 assert_eq!(client.run(&args[..]).await.unwrap(), "clean");
530 let result = client.checked(["status"]).await.unwrap();
531 assert_eq!(result.stdout(), "clean");
532 }
533
534 #[tokio::test]
535 async fn first_line_verb_streams_and_matches() {
536 let runner =
537 ScriptedRunner::new().on(["git", "log"], Reply::lines(["one", "two", "three"]));
538 let client = CliClient::with_runner("git", runner);
539 let found = client
540 .first_line(["log"], |line| line.starts_with('t'))
541 .await
542 .unwrap();
543 assert_eq!(found.as_deref(), Some("two"));
544 }
545
546 #[tokio::test]
547 async fn when_predicate_reads_public_command_accessors() {
548 let runner = ScriptedRunner::new()
551 .when(
552 |c| c.working_dir() == Some(Path::new("/repo")),
553 Reply::ok("in-repo"),
554 )
555 .fallback(Reply::ok("elsewhere"));
556 let client = CliClient::with_runner("git", runner);
557 assert_eq!(
558 client
559 .run(client.command_in(Path::new("/repo"), ["status"]))
560 .await
561 .unwrap(),
562 "in-repo"
563 );
564 assert_eq!(
565 client.run(client.command(["status"])).await.unwrap(),
566 "elsewhere"
567 );
568 }
569
570 #[tokio::test]
571 async fn recording_runner_captures_args_cwd_and_absence() {
572 let rec = RecordingRunner::replying(Reply::ok("https://gh/pr/2\n"));
573 let client = CliClient::with_runner("gh", &rec);
574 let _ = client
575 .run(client.command_in(Path::new("/repo"), ["pr", "create", "--title", "T"]))
576 .await
577 .unwrap();
578
579 let call = rec.only_call();
580 assert_eq!(call.cwd.as_deref(), Some(std::path::Path::new("/repo")));
581 assert_eq!(call.args_str(), ["pr", "create", "--title", "T"]);
582 assert!(!call.has_flag("--base"), "no --base flag was passed");
583 }
584
585 #[tokio::test]
586 async fn exit_code_errors_on_timeout() {
587 let client = CliClient::with_runner("gh", ScriptedRunner::new().fallback(Reply::timeout()));
588 assert!(matches!(
589 client
590 .exit_code(client.command(["auth", "status"]))
591 .await
592 .unwrap_err(),
593 Error::Timeout { .. }
594 ));
595 }
596
597 #[tokio::test]
598 async fn default_timeout_is_applied() {
599 let client = CliClient::new("git").default_timeout(Duration::from_secs(7));
600 assert_eq!(
601 client.command(["status"]).configured_timeout(),
602 Some(Duration::from_secs(7))
603 );
604 }
605
606 #[tokio::test]
607 async fn probe_maps_exit_code_to_bool() {
608 let client = CliClient::with_runner(
609 "git",
610 ScriptedRunner::new()
611 .on(["git", "diff"], Reply::fail(1, ""))
612 .fallback(Reply::ok("")),
613 );
614 assert!(
616 !client
617 .probe(client.command(["diff", "--quiet"]))
618 .await
619 .unwrap()
620 );
621 assert!(client.probe(client.command(["status"])).await.unwrap());
622 }
623
624 #[tokio::test]
625 async fn default_env_is_applied_to_every_command() {
626 use std::ffi::OsString;
627 let client = CliClient::new("git").default_env("GIT_TERMINAL_PROMPT", "0");
628 for cmd in [
629 client.command(["status"]),
630 client.command_in(Path::new("."), ["fetch"]),
631 ] {
632 assert!(
633 cmd.env_overrides()
634 .iter()
635 .any(|(k, v)| k == "GIT_TERMINAL_PROMPT"
636 && v.as_deref() == Some(OsString::from("0").as_os_str())),
637 "default env missing on built command",
638 );
639 }
640 }
641
642 #[tokio::test]
643 async fn default_env_reaches_the_invocation() {
644 let rec = RecordingRunner::replying(Reply::ok("ok\n"));
645 let client = CliClient::with_runner("git", &rec).default_env("GIT_TERMINAL_PROMPT", "0");
646 let _ = client.run(client.command(["status"])).await.unwrap();
647 let call = rec.only_call();
648 assert!(
649 call.envs
650 .iter()
651 .any(|(k, v)| k == "GIT_TERMINAL_PROMPT" && v.is_some()),
652 "env override did not reach the runner: {:?}",
653 call.envs
654 );
655 }
656
657 #[tokio::test]
658 async fn a_prebuilt_command_passed_to_a_verb_still_gets_client_defaults() {
659 let token = crate::CancellationToken::new();
660 let client = CliClient::new("git")
661 .default_timeout(Duration::from_secs(9))
662 .default_env("GIT_TERMINAL_PROMPT", "0")
663 .default_cancel_on(token);
664
665 let raw = Command::new("git").args(["push"]);
667 let filled = raw.into_command(&client);
668 assert_eq!(
669 filled.configured_timeout(),
670 Some(Duration::from_secs(9)),
671 "the client default timeout fills the gap"
672 );
673 assert!(
674 filled.cancel_token().is_some(),
675 "the client cancel token reaches it"
676 );
677 assert!(
678 filled
679 .env_overrides()
680 .iter()
681 .any(|(k, _)| k == "GIT_TERMINAL_PROMPT"),
682 "the client default env reaches it"
683 );
684
685 let explicit = Command::new("git")
686 .args(["push"])
687 .timeout(Duration::from_secs(2))
688 .env("GIT_TERMINAL_PROMPT", "1");
689 let filled = explicit.into_command(&client);
690 assert_eq!(
691 filled.configured_timeout(),
692 Some(Duration::from_secs(2)),
693 "an explicit per-command timeout wins"
694 );
695 let prompt: Vec<_> = filled
696 .env_overrides()
697 .iter()
698 .filter(|(k, _)| k == "GIT_TERMINAL_PROMPT")
699 .collect();
700 assert_eq!(prompt.len(), 1, "no duplicate env op for the same key");
701 assert_eq!(
702 prompt[0].1.as_deref(),
703 Some(std::ffi::OsStr::new("1")),
704 "the per-command env value wins over the client default"
705 );
706 }
707
708 #[tokio::test]
709 async fn prebuilt_command_env_wins_over_a_case_differing_client_default() {
710 let client = CliClient::new("git").default_env("Path", "from-client");
711 let cmd = Command::new("git").env("PATH", "from-command");
712 let filled = cmd.into_command(&client);
713 let path_ops: Vec<_> = filled
714 .env_overrides()
715 .iter()
716 .filter(|(k, _)| k.to_str().is_some_and(|k| k.eq_ignore_ascii_case("PATH")))
717 .collect();
718 #[cfg(windows)]
719 {
720 assert_eq!(
721 path_ops.len(),
722 1,
723 "the case-differing client default for the same var is skipped"
724 );
725 assert_eq!(
726 path_ops[0].1.as_deref(),
727 Some(std::ffi::OsStr::new("from-command")),
728 "the explicit per-command value wins"
729 );
730 }
731 #[cfg(not(windows))]
732 {
733 assert_eq!(
734 path_ops.len(),
735 2,
736 "on Unix PATH and Path are distinct variables — both kept"
737 );
738 }
739 }
740
741 #[tokio::test]
742 async fn default_cancel_on_is_applied_to_every_command() {
743 let token = crate::CancellationToken::new();
744 let client = CliClient::new("git").default_cancel_on(token);
745 for cmd in [
746 client.command(["status"]),
747 client.command_in(Path::new("."), ["fetch"]),
748 ] {
749 assert!(
750 cmd.cancel_token().is_some(),
751 "default token missing on built command"
752 );
753 }
754 assert!(format!("{client:?}").contains("has_default_cancel: true"));
755 }
756
757 #[tokio::test(start_paused = true)]
758 async fn per_command_cancel_on_overrides_the_default() {
759 use crate::CancellationToken;
760 let default_token = CancellationToken::new();
761 let explicit = CancellationToken::new();
762 let client = CliClient::with_runner("gh", ScriptedRunner::new().fallback(Reply::pending()))
763 .default_cancel_on(default_token.clone());
764 let cmd = client.command(["run", "watch"]).cancel_on(explicit.clone());
765
766 let call = client.output_string(cmd);
767 tokio::pin!(call);
768 default_token.cancel();
769 assert!(
770 tokio::time::timeout(Duration::from_secs(3600), &mut call)
771 .await
772 .is_err(),
773 "the replaced default token must not cancel the call"
774 );
775 explicit.cancel();
776 let err = tokio::time::timeout(Duration::from_secs(3600), call)
777 .await
778 .expect("the explicit token must resolve the call")
779 .expect_err("explicit token cancels");
780 assert!(matches!(err, Error::Cancelled { .. }), "got {err:?}");
781 }
782
783 #[tokio::test(start_paused = true)]
784 async fn acceptance_pending_reply_with_client_default_cancel() {
785 use crate::CancellationToken;
786 let token = CancellationToken::new();
787 let rec = RecordingRunner::new(
788 ScriptedRunner::new().on(["gh", "run", "watch"], Reply::pending()),
789 );
790 let client = CliClient::with_runner("gh", &rec).default_cancel_on(token.clone());
791
792 let call = client.output_string(client.command(["run", "watch", "123"]));
793 tokio::pin!(call);
794 assert!(
795 tokio::time::timeout(Duration::from_secs(3600), &mut call)
796 .await
797 .is_err(),
798 "must not resolve before the token fires"
799 );
800 token.cancel();
801 match tokio::time::timeout(Duration::from_secs(3600), call)
802 .await
803 .expect("the cancelled token must resolve the call")
804 {
805 Err(Error::Cancelled { program }) => assert_eq!(program, "gh"),
806 other => panic!("expected Error::Cancelled, got {other:?}"),
807 }
808 assert_eq!(rec.only_call().args_str(), ["run", "watch", "123"]);
809 }
810
811 #[test]
812 fn macro_emits_default_cancel_on() {
813 let _client = Demo::with_runner(ScriptedRunner::new())
814 .default_cancel_on(crate::CancellationToken::new());
815 }
816
817 #[test]
818 fn macro_generates_all_constructors() {
819 let _real = Demo::new();
820 let _default = Demo::default();
821 let _fake = Demo::with_runner(ScriptedRunner::new())
822 .default_timeout(Duration::from_secs(1))
823 .default_env("GIT_TERMINAL_PROMPT", "0")
824 .default_env_remove("GIT_PAGER");
825 }
826}