Skip to main content

command_stream/zx/
util.rs

1//! Small helpers shared by the zx-compatible API: quoting, duration parsing,
2//! command assembly and name normalisation.
3
4use std::path::{Path, PathBuf};
5use std::time::Duration;
6
7use super::error::ZxError;
8use super::output::ProcessOutput;
9pub use crate::local_bin::{path_key, prefer_local_bin, PATH_DELIMITER};
10
11/// Signature of the quoting function used to interpolate arguments.
12pub type QuoteFn = fn(&str) -> String;
13
14/// Returns `true` when `arg` only consists of characters that never need
15/// quoting in a POSIX shell (letters, digits and `_/.-+@:=,%`).
16pub fn is_safe_word(arg: &str) -> bool {
17    !arg.is_empty()
18        && arg.chars().all(|c| {
19            c.is_ascii_alphanumeric()
20                || matches!(c, '_' | '/' | '.' | '-' | '+' | '@' | ':' | '=' | ',' | '%')
21        })
22}
23
24/// Quote a value for bash the way zx does.
25///
26/// * the empty string becomes `$''`;
27/// * "safe" words are returned untouched;
28/// * everything else is wrapped into an ANSI-C `$'...'` string where
29///   backslashes, single quotes and control characters are escaped.
30pub fn quote(arg: &str) -> String {
31    if arg.is_empty() {
32        return "$''".to_string();
33    }
34    if is_safe_word(arg) {
35        return arg.to_string();
36    }
37    let mut out = String::with_capacity(arg.len() + 3);
38    out.push_str("$'");
39    for c in arg.chars() {
40        match c {
41            '\\' => out.push_str("\\\\"),
42            '\'' => out.push_str("\\'"),
43            '\u{0C}' => out.push_str("\\f"),
44            '\n' => out.push_str("\\n"),
45            '\r' => out.push_str("\\r"),
46            '\t' => out.push_str("\\t"),
47            '\u{0B}' => out.push_str("\\v"),
48            '\0' => out.push_str("\\0"),
49            other => out.push(other),
50        }
51    }
52    out.push('\'');
53    out
54}
55
56/// Quote a value for PowerShell the way zx does: empty -> `''`, safe words
57/// unchanged, otherwise single-quoted with embedded quotes doubled.
58pub fn quote_powershell(arg: &str) -> String {
59    if arg.is_empty() {
60        return "''".to_string();
61    }
62    if is_safe_word(arg) {
63        return arg.to_string();
64    }
65    format!("'{}'", arg.replace('\'', "''"))
66}
67
68/// Convert milliseconds into a [`Duration`], rejecting negative and NaN values.
69pub fn duration_from_millis(ms: f64) -> Result<Duration, ZxError> {
70    if ms.is_nan() || ms < 0.0 || ms.is_infinite() {
71        return Err(ZxError::new(format!("Invalid duration: \"{ms}\".")));
72    }
73    Ok(Duration::from_secs_f64(ms / 1000.0))
74}
75
76/// Parse a zx duration string: `"100"` (ms), `"500ms"`, `"2s"` or `"2m"`.
77pub fn parse_duration(input: &str) -> Result<Duration, ZxError> {
78    let digits_end = input
79        .find(|c: char| !c.is_ascii_digit())
80        .unwrap_or(input.len());
81    let (digits, unit) = input.split_at(digits_end);
82    let factor = match unit {
83        "" | "ms" => 1u64,
84        "s" => 1_000,
85        "m" => 60_000,
86        _ => return Err(ZxError::new(format!("Unknown duration: \"{input}\"."))),
87    };
88    if digits.is_empty() {
89        return Err(ZxError::new(format!("Unknown duration: \"{input}\".")));
90    }
91    let value: u64 = digits
92        .parse()
93        .map_err(|_| ZxError::new(format!("Invalid duration: \"{input}\".")))?;
94    Ok(Duration::from_millis(value.saturating_mul(factor)))
95}
96
97/// Convert `SOME_NAME` / `kebab-name` into `someName` / `kebabName`.
98///
99/// Mirrors zx: the input is lower-cased and every `[a-z][_-]+[a-z]` sequence
100/// is collapsed with the right-hand letter upper-cased (non-overlapping).
101pub fn to_camel_case(input: &str) -> String {
102    let chars: Vec<char> = input.to_lowercase().chars().collect();
103    let mut out = String::with_capacity(chars.len());
104    let mut i = 0;
105    while i < chars.len() {
106        let c = chars[i];
107        if c.is_ascii_lowercase() {
108            let mut j = i + 1;
109            while j < chars.len() && matches!(chars[j], '_' | '-') {
110                j += 1;
111            }
112            if j > i + 1 && j < chars.len() && chars[j].is_ascii_lowercase() {
113                out.push(c);
114                out.push(chars[j].to_ascii_uppercase());
115                i = j + 1;
116                continue;
117            }
118        }
119        out.push(c);
120        i += 1;
121    }
122    out
123}
124
125/// Parse `"true"` / `"false"`; any other value yields `None`.
126pub fn parse_bool(value: &str) -> Option<bool> {
127    match value {
128        "true" => Some(true),
129        "false" => Some(false),
130        _ => None,
131    }
132}
133
134/// Random lower-case alphanumeric identifier (used for temp names and ids).
135pub fn random_id() -> String {
136    use std::collections::hash_map::RandomState;
137    use std::hash::{BuildHasher, Hasher};
138    use std::sync::atomic::{AtomicU64, Ordering};
139    static COUNTER: AtomicU64 = AtomicU64::new(0);
140    let mut hasher = RandomState::new().build_hasher();
141    hasher.write_u64(COUNTER.fetch_add(1, Ordering::Relaxed));
142    hasher.write_u128(
143        std::time::SystemTime::now()
144            .duration_since(std::time::UNIX_EPOCH)
145            .map(|d| d.as_nanos())
146            .unwrap_or_default(),
147    );
148    let mut n = hasher.finish();
149    let alphabet = b"0123456789abcdefghijklmnopqrstuvwxyz";
150    let mut out = String::new();
151    for _ in 0..10 {
152        out.push(alphabet[(n % 36) as usize] as char);
153        n /= 36;
154    }
155    out
156}
157
158/// A value interpolated into a command template.
159#[derive(Debug, Clone, PartialEq, Eq)]
160pub enum ZxArg {
161    /// A single word, quoted as a whole.
162    One(String),
163    /// A list of words, each quoted and joined with spaces.
164    Many(Vec<String>),
165}
166
167impl ZxArg {
168    /// Render the argument with the given quoting function.
169    pub fn render(&self, quote_fn: QuoteFn) -> String {
170        match self {
171            ZxArg::One(s) => quote_fn(s),
172            ZxArg::Many(items) => items
173                .iter()
174                .map(|s| quote_fn(s))
175                .collect::<Vec<_>>()
176                .join(" "),
177        }
178    }
179
180    fn into_words(self) -> Vec<String> {
181        match self {
182            ZxArg::One(s) => vec![s],
183            ZxArg::Many(v) => v,
184        }
185    }
186}
187
188/// Conversion of Rust values into command template arguments.
189///
190/// Strings, numbers, booleans and paths become a single word; a
191/// [`ProcessOutput`] contributes its stdout without the trailing newline;
192/// vectors, slices and arrays expand to several words.
193pub trait IntoZxArg {
194    /// Perform the conversion.
195    fn into_zx_arg(self) -> ZxArg;
196}
197
198impl IntoZxArg for ZxArg {
199    fn into_zx_arg(self) -> ZxArg {
200        self
201    }
202}
203
204macro_rules! display_args {
205    ($($t:ty),* $(,)?) => {
206        $(
207            impl IntoZxArg for $t {
208                fn into_zx_arg(self) -> ZxArg {
209                    ZxArg::One(self.to_string())
210                }
211            }
212            impl IntoZxArg for &$t {
213                fn into_zx_arg(self) -> ZxArg {
214                    ZxArg::One(self.to_string())
215                }
216            }
217        )*
218    };
219}
220
221display_args!(
222    String, char, bool, i8, i16, i32, i64, i128, isize, u8, u16, u32, u64, u128, usize, f32, f64
223);
224
225impl IntoZxArg for &str {
226    fn into_zx_arg(self) -> ZxArg {
227        ZxArg::One(self.to_string())
228    }
229}
230
231impl IntoZxArg for &&str {
232    fn into_zx_arg(self) -> ZxArg {
233        ZxArg::One((*self).to_string())
234    }
235}
236
237impl IntoZxArg for &Path {
238    fn into_zx_arg(self) -> ZxArg {
239        ZxArg::One(self.to_string_lossy().into_owned())
240    }
241}
242
243impl IntoZxArg for PathBuf {
244    fn into_zx_arg(self) -> ZxArg {
245        ZxArg::One(self.to_string_lossy().into_owned())
246    }
247}
248
249impl IntoZxArg for &PathBuf {
250    fn into_zx_arg(self) -> ZxArg {
251        ZxArg::One(self.to_string_lossy().into_owned())
252    }
253}
254
255impl IntoZxArg for &ProcessOutput {
256    fn into_zx_arg(self) -> ZxArg {
257        let out = self.stdout.strip_suffix('\n').unwrap_or(&self.stdout);
258        ZxArg::One(out.to_string())
259    }
260}
261
262impl IntoZxArg for ProcessOutput {
263    fn into_zx_arg(self) -> ZxArg {
264        (&self).into_zx_arg()
265    }
266}
267
268impl<T: IntoZxArg> IntoZxArg for Vec<T> {
269    fn into_zx_arg(self) -> ZxArg {
270        ZxArg::Many(
271            self.into_iter()
272                .flat_map(|item| item.into_zx_arg().into_words())
273                .collect(),
274        )
275    }
276}
277
278impl<T: IntoZxArg + Clone> IntoZxArg for &Vec<T> {
279    fn into_zx_arg(self) -> ZxArg {
280        self.as_slice().into_zx_arg()
281    }
282}
283
284impl<T: IntoZxArg + Clone> IntoZxArg for &[T] {
285    fn into_zx_arg(self) -> ZxArg {
286        self.to_vec().into_zx_arg()
287    }
288}
289
290impl<T: IntoZxArg, const N: usize> IntoZxArg for [T; N] {
291    fn into_zx_arg(self) -> ZxArg {
292        Vec::from(self).into_zx_arg()
293    }
294}
295
296/// Convert any supported value into a [`ZxArg`].
297pub fn zx_arg<T: IntoZxArg>(value: T) -> ZxArg {
298    value.into_zx_arg()
299}
300
301/// Interleave template `pieces` with quoted `args`.
302///
303/// `pieces` must contain exactly one more element than `args`.
304pub fn build_cmd(quote_fn: QuoteFn, pieces: &[&str], args: &[ZxArg]) -> Result<String, ZxError> {
305    if pieces.len() != args.len() + 1 {
306        return Err(ZxError::new(format!(
307            "Malformed command: {} template pieces for {} arguments",
308            pieces.len(),
309            args.len()
310        )));
311    }
312    let mut cmd = pieces[0].to_string();
313    for (arg, piece) in args.iter().zip(&pieces[1..]) {
314        cmd.push_str(&arg.render(quote_fn));
315        cmd.push_str(piece);
316    }
317    Ok(cmd)
318}
319
320/// Split a `zx!` format string on `{}` placeholders (`{{}}` is a literal `{}`).
321pub fn split_template(template: &str) -> Vec<String> {
322    let mut pieces = vec![String::new()];
323    let mut rest = template;
324    while !rest.is_empty() {
325        if let Some(tail) = rest.strip_prefix("{{}}") {
326            pieces.last_mut().unwrap().push_str("{}");
327            rest = tail;
328        } else if let Some(tail) = rest.strip_prefix("{}") {
329            pieces.push(String::new());
330            rest = tail;
331        } else {
332            let c = rest.chars().next().unwrap();
333            pieces.last_mut().unwrap().push(c);
334            rest = &rest[c.len_utf8()..];
335        }
336    }
337    pieces
338}