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