Skip to main content

command_stream/zx/
goods.rs

1//! zx "goods": small helpers for scripts (`sleep`, `retry`, `spinner`, ...).
2
3use std::future::Future;
4use std::io::{IsTerminal, Write};
5use std::path::{Path, PathBuf};
6use std::time::Duration;
7
8use super::error::ZxError;
9use super::log::{log, LogEntry};
10use super::shell::{configure, current_options, within};
11use super::util::random_id;
12
13/// Pause for `duration` (zx `sleep`).
14pub async fn sleep(duration: Duration) {
15    tokio::time::sleep(duration).await;
16}
17
18/// Pause for a zx duration string such as `"100ms"` or `"1s"`.
19pub async fn sleep_for(duration: &str) -> Result<(), ZxError> {
20    let duration = super::util::parse_duration(duration)?;
21    tokio::time::sleep(duration).await;
22    Ok(())
23}
24
25/// Call `f` until it succeeds, at most `count` times, without delays.
26///
27/// Returns the last error when every attempt fails. `count` is clamped to at
28/// least one attempt.
29pub async fn retry<T, E, F, Fut>(count: usize, f: F) -> Result<T, E>
30where
31    F: FnMut() -> Fut,
32    Fut: Future<Output = Result<T, E>>,
33{
34    retry_with_backoff(count, std::iter::repeat(Duration::ZERO), f).await
35}
36
37/// Like [`retry`], sleeping `delay` between attempts.
38pub async fn retry_with_delay<T, E, F, Fut>(count: usize, delay: Duration, f: F) -> Result<T, E>
39where
40    F: FnMut() -> Fut,
41    Fut: Future<Output = Result<T, E>>,
42{
43    retry_with_backoff(count, std::iter::repeat(delay), f).await
44}
45
46/// Like [`retry`], taking the delay after each failure from `delays` (for
47/// example [`exp_backoff`]). An exhausted iterator means no delay.
48pub async fn retry_with_backoff<T, E, F, Fut, I>(count: usize, delays: I, mut f: F) -> Result<T, E>
49where
50    F: FnMut() -> Fut,
51    Fut: Future<Output = Result<T, E>>,
52    I: IntoIterator<Item = Duration>,
53{
54    let total = count.max(1);
55    let mut delays = delays.into_iter();
56    let mut attempt = 0;
57    loop {
58        attempt += 1;
59        let err = match f().await {
60            Ok(value) => return Ok(value),
61            Err(err) => err,
62        };
63        if attempt >= total {
64            return Err(err);
65        }
66        let delay = delays.next().unwrap_or(Duration::ZERO);
67        let opts = current_options();
68        let entry = LogEntry::Retry {
69            attempt,
70            total: Some(total),
71            delay,
72        };
73        log(&entry, opts.verbose && !opts.quiet);
74        if !delay.is_zero() {
75            tokio::time::sleep(delay).await;
76        }
77    }
78}
79
80/// Exponential backoff delays: `min(delay * 2^n, max)` for n = 0, 1, 2, ...
81pub fn exp_backoff(max: Duration, delay: Duration) -> impl Iterator<Item = Duration> {
82    (0u32..).map(move |n| {
83        let factor = 2u32.checked_pow(n.min(31)).unwrap_or(u32::MAX);
84        delay.checked_mul(factor).map_or(max, |d| d.min(max))
85    })
86}
87
88/// [`exp_backoff`] with zx's defaults (`max = 60s`, `delay = 100ms`).
89pub fn exp_backoff_default() -> impl Iterator<Item = Duration> {
90    exp_backoff(Duration::from_secs(60), Duration::from_millis(100))
91}
92
93/// Frames drawn by [`spinner`].
94pub const SPINNER_FRAMES: [char; 10] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
95
96/// Whether [`spinner`] would draw anything: stderr must be a terminal, `CI`
97/// unset and the current scope not quiet.
98pub fn spinner_enabled() -> bool {
99    !current_options().quiet && std::env::var_os("CI").is_none() && std::io::stderr().is_terminal()
100}
101
102/// Run `fut` while showing a spinner titled `title` on stderr (zx
103/// `spinner`). Verbose logging is disabled for the duration of `fut`.
104pub async fn spinner<F: Future>(title: &str, fut: F) -> F::Output {
105    if !spinner_enabled() {
106        return fut.await;
107    }
108    let title = title.to_string();
109    let (stop_tx, mut stop_rx) = tokio::sync::oneshot::channel::<()>();
110    let ticker = tokio::spawn(async move {
111        let mut frame = 0usize;
112        let mut interval = tokio::time::interval(Duration::from_millis(100));
113        interval.tick().await;
114        loop {
115            tokio::select! {
116                _ = &mut stop_rx => break,
117                _ = interval.tick() => {
118                    let mut err = std::io::stderr();
119                    let _ = write!(err, "  {} {title}\r", SPINNER_FRAMES[frame % 10]);
120                    let _ = err.flush();
121                    frame += 1;
122                }
123            }
124        }
125        let width = title.chars().count() + 4;
126        let mut err = std::io::stderr();
127        let _ = write!(err, "{}\r", " ".repeat(width));
128        let _ = err.flush();
129    });
130    let output = within(async {
131        configure(|o| o.verbose = false);
132        fut.await
133    })
134    .await;
135    let _ = stop_tx.send(());
136    let _ = ticker.await;
137    output
138}
139
140/// Print the arguments joined by spaces followed by a newline (zx `echo`).
141pub fn echo<I, S>(parts: I)
142where
143    I: IntoIterator<Item = S>,
144    S: std::fmt::Display,
145{
146    println!("{}", echo_line(parts));
147}
148
149/// The line [`echo`] would print (without the trailing newline).
150pub fn echo_line<I, S>(parts: I) -> String
151where
152    I: IntoIterator<Item = S>,
153    S: std::fmt::Display,
154{
155    parts
156        .into_iter()
157        .map(|p| p.to_string())
158        .collect::<Vec<_>>()
159        .join(" ")
160}
161
162/// Create (like `mkdir -p`) and return a directory in the system temp dir.
163/// `prefix` defaults to `zx-<random id>`.
164pub fn tempdir(prefix: Option<&str>) -> Result<PathBuf, ZxError> {
165    let name = prefix.map_or_else(|| format!("zx-{}", random_id()), str::to_string);
166    let dir = std::env::temp_dir().join(name);
167    std::fs::create_dir_all(&dir)?;
168    Ok(dir)
169}
170
171/// Create a temp file and return its path (zx `tempfile`).
172///
173/// With a `name` the file is placed in a fresh [`tempdir`]; otherwise it is
174/// `zx-<random id>` in the system temp dir. The file is written with `data`
175/// or created empty.
176pub fn tempfile(name: Option<&str>, data: Option<&[u8]>) -> Result<PathBuf, ZxError> {
177    let path = match name {
178        Some(name) => tempdir(None)?.join(name),
179        None => std::env::temp_dir().join(format!("zx-{}", random_id())),
180    };
181    std::fs::write(&path, data.unwrap_or_default())?;
182    Ok(path)
183}
184
185/// Locate an executable on `PATH` (zx `which`).
186pub fn which(name: &str) -> Option<PathBuf> {
187    ::which::which(name).ok()
188}
189
190/// Expand a glob pattern into sorted paths (zx `glob`, subset: `*`, `?`,
191/// `[...]` and `**`). Unreadable entries are skipped.
192pub fn glob(pattern: &str) -> Result<Vec<PathBuf>, ZxError> {
193    let entries = ::glob::glob(pattern).map_err(|e| ZxError::new(e.to_string()))?;
194    let mut paths: Vec<PathBuf> = entries.filter_map(Result::ok).collect();
195    paths.sort();
196    Ok(paths)
197}
198
199/// Expand a glob pattern relative to `cwd`, returning paths relative to it.
200pub fn glob_in(cwd: impl AsRef<Path>, pattern: &str) -> Result<Vec<PathBuf>, ZxError> {
201    let cwd = cwd.as_ref();
202    let full = cwd.join(pattern);
203    let found = glob(&full.to_string_lossy())?;
204    Ok(found
205        .into_iter()
206        .map(|p| p.strip_prefix(cwd).map(Path::to_path_buf).unwrap_or(p))
207        .collect())
208}