waterui_cli/runtime/
utils.rs1use 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#[derive(Debug, Error)]
19pub enum CommandError {
20 #[error("failed to spawn `{program}`: {source}")]
22 Spawn {
23 program: String,
25 #[source]
27 source: io::Error,
28 },
29 #[error("command `{program}` failed with status {status}{report}")]
31 Failed {
32 program: String,
34 status: ExitStatus,
36 report: String,
38 },
39}
40
41pub(crate) async fn which(name: &'static str) -> Result<PathBuf, which::Error> {
48 Host::current().which(name).await
49}
50
51static STD_OUTPUT: AtomicBool = AtomicBool::new(false);
55
56pub fn set_std_output(enabled: bool) {
58 STD_OUTPUT.store(enabled, std::sync::atomic::Ordering::SeqCst);
59}
60
61pub(crate) fn std_output_enabled() -> bool {
63 STD_OUTPUT.load(Ordering::SeqCst)
64}
65
66#[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#[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
95pub(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
111pub(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
126pub(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
142const MAX_REPORTED_OUTPUT_LINES: usize = 200;
144
145fn is_diagnostic_line(line: &str) -> bool {
151 line.contains("error: ")
152}
153
154pub(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
206pub 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#[derive(Debug, Error)]
261pub enum VersionParseError {
262 #[error("version is empty")]
264 EmptyVersion,
265 #[error("missing numeric core version")]
267 MissingCoreVersion,
268 #[error("expected 1-3 numeric components, found {count} in `{input}`")]
270 InvalidComponentCount {
271 count: usize,
273 input: String,
275 },
276 #[error("failed to parse version `{input}` as `{normalized}`: {source}")]
278 InvalidVersion {
279 input: String,
281 normalized: String,
283 #[source]
285 source: semver::Error,
286 },
287}
288
289pub(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
297pub 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}