Skip to main content

waterui_cli/runtime/
utils.rs

1//! Utility functions for the CLI.
2
3use std::ffi::OsStr;
4use std::{
5    io,
6    path::{Path, PathBuf},
7    process::{ExitStatus, Stdio},
8    sync::atomic::{AtomicBool, Ordering},
9};
10
11use semver::Version;
12use smol::{process::Command, unblock};
13use thiserror::Error;
14
15use crate::toolchain::Host;
16
17/// An external command could not be executed or exited unsuccessfully.
18#[derive(Debug, Error)]
19pub enum CommandError {
20    /// The command could not be spawned.
21    #[error("failed to spawn `{program}`: {source}")]
22    Spawn {
23        /// The program that was invoked.
24        program: String,
25        /// The underlying I/O error.
26        #[source]
27        source: io::Error,
28    },
29    /// The command exited with a non-zero status.
30    #[error("command `{program}` failed with status {status}{report}")]
31    Failed {
32        /// The program that was invoked.
33        program: String,
34        /// The process exit status.
35        status: ExitStatus,
36        /// Formatted diagnostic tail of the captured output streams.
37        report: String,
38    },
39}
40
41/// Locate an executable in the real host's PATH.
42///
43/// Return the path to the executable if found.
44///
45/// # Errors
46/// - If the executable is not found in the PATH.
47pub(crate) async fn which(name: &'static str) -> Result<PathBuf, which::Error> {
48    Host::current().which(name).await
49}
50
51/// Enable or disable standard output for command executions.
52///
53/// By default, standard output is disabled.
54static STD_OUTPUT: AtomicBool = AtomicBool::new(false);
55
56/// Enable or disable standard output for command executions.
57pub fn set_std_output(enabled: bool) {
58    STD_OUTPUT.store(enabled, std::sync::atomic::Ordering::SeqCst);
59}
60
61/// Whether captured command output is also echoed to the terminal.
62pub(crate) fn std_output_enabled() -> bool {
63    STD_OUTPUT.load(Ordering::SeqCst)
64}
65
66/// Returns a platform-appropriate installation hint for sccache.
67#[must_use]
68pub const fn sccache_install_hint() -> &'static str {
69    if cfg!(target_os = "macos") {
70        "brew install sccache"
71    } else if cfg!(target_os = "linux") {
72        "your distro package manager (e.g. apt/dnf/pacman) or cargo install sccache"
73    } else if cfg!(target_os = "windows") {
74        "winget install Mozilla.sccache or cargo install sccache"
75    } else {
76        "cargo install sccache"
77    }
78}
79
80/// Returns a platform-appropriate upgrade hint for an already-installed
81/// sccache that is too old — `install` is a no-op on an existing package.
82#[must_use]
83pub const fn sccache_upgrade_hint() -> &'static str {
84    if cfg!(target_os = "macos") {
85        "brew upgrade sccache"
86    } else if cfg!(target_os = "linux") {
87        "your distro package manager or cargo install sccache --force"
88    } else if cfg!(target_os = "windows") {
89        "winget upgrade Mozilla.sccache or cargo install sccache --force"
90    } else {
91        "cargo install sccache --force"
92    }
93}
94
95// Warn: You will lose stdout/stderr piping if you modify this function!
96pub(crate) fn command(command: &mut Command) -> &mut Command {
97    command
98        .kill_on_drop(true)
99        .stdout(if std_output_enabled() {
100            Stdio::inherit()
101        } else {
102            Stdio::piped()
103        })
104        .stderr(if std_output_enabled() {
105            Stdio::inherit()
106        } else {
107            Stdio::piped()
108        })
109}
110
111/// Run a command with the specified name and arguments.
112///
113/// Always captures output. When `STD_OUTPUT` is enabled, also prints to terminal.
114///
115/// Return the standard output as a `String` if successful.
116/// # Errors
117/// - [`CommandError::Spawn`] if the command cannot be spawned.
118/// - [`CommandError::Failed`] if the command exits with a non-zero status.
119pub(crate) async fn run_command(
120    name: &str,
121    args: impl IntoIterator<Item = &str>,
122) -> Result<String, CommandError> {
123    run_command_os(name, args).await
124}
125
126/// Run a command with the specified name and arguments.
127///
128/// Like `run_command`, but supports non-UTF8 executable paths and arguments.
129///
130/// # Errors
131/// - [`CommandError::Spawn`] if the command cannot be spawned.
132/// - [`CommandError::Failed`] if the command exits with a non-zero status.
133pub(crate) async fn run_command_os<N, A, S>(name: N, args: A) -> Result<String, CommandError>
134where
135    N: AsRef<OsStr>,
136    A: IntoIterator<Item = S>,
137    S: AsRef<OsStr>,
138{
139    Host::current().run(name, args).await
140}
141
142/// Number of trailing lines reported from each captured stream when a command fails.
143const MAX_REPORTED_OUTPUT_LINES: usize = 200;
144
145/// Whether a build tool marked this output line as a diagnostic.
146///
147/// Covers the compiler form `path:line:col: error: message` (swiftc, clang,
148/// rustc, `xcodebuild` relaying any of them) and the bare `error: message` of
149/// cargo, `swift build`, and linkers.
150fn is_diagnostic_line(line: &str) -> bool {
151    line.contains("error: ")
152}
153
154/// Render one captured stream for a command-failure report.
155///
156/// Both streams are always reported: build tools do not agree on which one carries
157/// diagnostics, and `xcodebuild` in particular writes compiler and linker errors to
158/// stdout while stdout is also where its progress noise goes. The tail is shown in
159/// full, and the number of elided lines is stated rather than silently dropped.
160///
161/// The tail alone is not enough: `xcodebuild` keeps going after a compile error to
162/// finish the targets that do not depend on it, and a run-script phase dumps its
163/// whole environment on the way, so the diagnostic that explains the failure can sit
164/// well over a thousand lines before the end (#345). Every diagnostic line that falls
165/// outside the tail is therefore reported ahead of it.
166pub(crate) fn format_failure_stream(label: &str, bytes: &[u8]) -> String {
167    use std::fmt::Write as _;
168
169    let text = String::from_utf8_lossy(bytes);
170    let trimmed = text.trim_end();
171    if trimmed.is_empty() {
172        return String::new();
173    }
174
175    let lines: Vec<&str> = trimmed.lines().collect();
176    let elided = lines.len().saturating_sub(MAX_REPORTED_OUTPUT_LINES);
177    let body = lines[elided..].join("\n");
178    if elided == 0 {
179        return format!("\n{label}:\n{body}");
180    }
181
182    let mut report = String::new();
183    let diagnostics: Vec<&str> = lines[..elided]
184        .iter()
185        .copied()
186        .filter(|line| is_diagnostic_line(line))
187        .collect();
188    if !diagnostics.is_empty() {
189        write!(
190            report,
191            "\n{label} diagnostics before the reported tail ({} lines):\n{}",
192            diagnostics.len(),
193            diagnostics.join("\n")
194        )
195        .expect("writing to a String cannot fail");
196    }
197    write!(
198        report,
199        "\n{label} (last {MAX_REPORTED_OUTPUT_LINES} of {} lines):\n{body}",
200        lines.len()
201    )
202    .expect("writing to a String cannot fail");
203    report
204}
205
206/// Parse a version that may omit the minor and/or patch components.
207///
208/// `semver::Version` requires all three components, but version reporters
209/// commonly provide only major.minor — `rustc` accepts `1.88`, and
210/// `simctl`/`IPHONEOS_DEPLOYMENT_TARGET` use `26.0`-style iOS versions. Missing
211/// trailing components are padded with zeros. A leading `v` and a
212/// `-prerelease` suffix are also accepted.
213///
214/// # Errors
215/// - If the input is empty, has more than three numeric components, or is not
216///   valid semver after normalization.
217pub fn parse_semver_version(input: &str) -> Result<Version, VersionParseError> {
218    let trimmed = input.trim();
219    if trimmed.is_empty() {
220        return Err(VersionParseError::EmptyVersion);
221    }
222
223    let normalized_input = trimmed.strip_prefix('v').unwrap_or(trimmed);
224    let mut split = normalized_input.splitn(2, '-');
225    let core = split.next().ok_or(VersionParseError::MissingCoreVersion)?;
226    let prerelease = split.next();
227
228    let mut components: Vec<&str> = core.split('.').collect();
229    match components.len() {
230        1 => {
231            components.push("0");
232            components.push("0");
233        }
234        2 => {
235            components.push("0");
236        }
237        3 => {}
238        count => {
239            return Err(VersionParseError::InvalidComponentCount {
240                count,
241                input: input.to_owned(),
242            });
243        }
244    }
245
246    let mut normalized = components.join(".");
247    if let Some(prerelease) = prerelease {
248        normalized.push('-');
249        normalized.push_str(prerelease);
250    }
251
252    Version::parse(&normalized).map_err(|source| VersionParseError::InvalidVersion {
253        input: input.to_owned(),
254        normalized,
255        source,
256    })
257}
258
259/// A version string `parse_semver_version` could not normalize.
260#[derive(Debug, Error)]
261pub enum VersionParseError {
262    /// The version string was empty.
263    #[error("version is empty")]
264    EmptyVersion,
265    /// The version string had no numeric core.
266    #[error("missing numeric core version")]
267    MissingCoreVersion,
268    /// The version had an unsupported component count.
269    #[error("expected 1-3 numeric components, found {count} in `{input}`")]
270    InvalidComponentCount {
271        /// The number of dotted components found.
272        count: usize,
273        /// The offending input.
274        input: String,
275    },
276    /// The normalized version failed semver parsing.
277    #[error("failed to parse version `{input}` as `{normalized}`: {source}")]
278    InvalidVersion {
279        /// The offending input.
280        input: String,
281        /// The normalized form that was attempted.
282        normalized: String,
283        /// The semver parse error.
284        #[source]
285        source: semver::Error,
286    },
287}
288
289/// Parse whitespace-separated u32 values (e.g., process IDs).
290pub(crate) fn parse_whitespace_separated_u32s(input: &str) -> Vec<u32> {
291    input
292        .split_whitespace()
293        .filter_map(|part| part.parse::<u32>().ok())
294        .collect()
295}
296
297/// Async file copy using reflink when available, falling back to regular copy.
298///
299/// This is more efficient than regular copy on filesystems that support reflinks (APFS, Btrfs).
300///
301/// # Errors
302/// - If the copy operation fails.
303pub async fn copy_file(from: impl AsRef<Path>, to: impl AsRef<Path>) -> io::Result<()> {
304    let from = from.as_ref().to_path_buf();
305    let to = to.as_ref().to_path_buf();
306    unblock(move || reflink::reflink_or_copy(from, to).map(|_| ())).await
307}
308
309#[cfg(test)]
310mod tests {
311    use semver::Version;
312
313    use super::{
314        MAX_REPORTED_OUTPUT_LINES, format_failure_stream, parse_semver_version,
315        parse_whitespace_separated_u32s,
316    };
317
318    #[test]
319    fn parse_semver_version_accepts_major_minor() {
320        let parsed = parse_semver_version("1.88").expect("version should parse");
321        assert_eq!(parsed, Version::new(1, 88, 0));
322    }
323
324    #[test]
325    fn parse_semver_version_pads_deployment_target_style() {
326        let parsed = parse_semver_version("26.0").expect("version should parse");
327        assert_eq!(parsed, Version::new(26, 0, 0));
328    }
329
330    #[test]
331    fn parse_semver_version_orders_release_lines() {
332        let ios_18 = parse_semver_version("18.5").expect("version should parse");
333        let ios_26 = parse_semver_version("26.5").expect("version should parse");
334        assert!(ios_26 > ios_18);
335    }
336
337    #[test]
338    fn parse_semver_version_rejects_extra_components() {
339        assert!(parse_semver_version("1.2.3.4").is_err());
340    }
341
342    #[test]
343    fn failure_report_surfaces_diagnostics_elided_from_the_tail() {
344        let diagnostic =
345            "Sources/WuiMapView.swift:137:21: error: cannot find 'makeRegionWatcher' in scope";
346        let mut lines = vec!["CompileSwift normal arm64", diagnostic];
347        let noise = "    export SDKROOT=/Applications/Xcode.app";
348        lines.extend(std::iter::repeat_n(noise, MAX_REPORTED_OUTPUT_LINES * 3));
349        lines.push("** BUILD FAILED **");
350        let report = format_failure_stream("stdout", lines.join("\n").as_bytes());
351
352        assert!(
353            report.contains(diagnostic),
354            "the elided compiler error must be reported: {report}"
355        );
356        assert!(report.contains("stdout diagnostics before the reported tail (1 lines):"));
357        assert!(report.contains(&format!(
358            "stdout (last {MAX_REPORTED_OUTPUT_LINES} of {} lines):",
359            lines.len()
360        )));
361        assert!(report.ends_with("** BUILD FAILED **"));
362        assert_eq!(
363            report.matches(diagnostic).count(),
364            1,
365            "a diagnostic outside the tail is reported once"
366        );
367    }
368
369    #[test]
370    fn failure_report_shows_short_output_whole() {
371        let report = format_failure_stream("stderr", b"error: linking failed\n");
372        assert_eq!(report, "\nstderr:\nerror: linking failed");
373    }
374
375    #[test]
376    fn parses_pidof_output_with_multiple_pids() {
377        let parsed = parse_whitespace_separated_u32s("123 456\n");
378        assert_eq!(parsed, vec![123, 456]);
379    }
380
381    #[test]
382    fn ignores_non_numeric_tokens() {
383        let parsed = parse_whitespace_separated_u32s("foo 42 bar\n");
384        assert_eq!(parsed, vec![42]);
385    }
386}