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
80pub(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
96pub(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
111pub(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
127const MAX_REPORTED_OUTPUT_LINES: usize = 200;
129
130fn is_diagnostic_line(line: &str) -> bool {
136 line.contains("error: ")
137}
138
139pub(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
191pub 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#[derive(Debug, Error)]
246pub enum VersionParseError {
247 #[error("version is empty")]
249 EmptyVersion,
250 #[error("missing numeric core version")]
252 MissingCoreVersion,
253 #[error("expected 1-3 numeric components, found {count} in `{input}`")]
255 InvalidComponentCount {
256 count: usize,
258 input: String,
260 },
261 #[error("failed to parse version `{input}` as `{normalized}`: {source}")]
263 InvalidVersion {
264 input: String,
266 normalized: String,
268 #[source]
270 source: semver::Error,
271 },
272}
273
274pub(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
282pub 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}