use std::ffi::OsStr;
use std::{
io,
path::{Path, PathBuf},
process::{ExitStatus, Stdio},
sync::atomic::{AtomicBool, Ordering},
};
use semver::Version;
use smol::{process::Command, unblock};
use thiserror::Error;
use crate::toolchain::Host;
#[derive(Debug, Error)]
pub enum CommandError {
#[error("failed to spawn `{program}`: {source}")]
Spawn {
program: String,
#[source]
source: io::Error,
},
#[error("command `{program}` failed with status {status}{report}")]
Failed {
program: String,
status: ExitStatus,
report: String,
},
}
pub(crate) async fn which(name: &'static str) -> Result<PathBuf, which::Error> {
Host::current().which(name).await
}
static STD_OUTPUT: AtomicBool = AtomicBool::new(false);
pub fn set_std_output(enabled: bool) {
STD_OUTPUT.store(enabled, std::sync::atomic::Ordering::SeqCst);
}
pub(crate) fn std_output_enabled() -> bool {
STD_OUTPUT.load(Ordering::SeqCst)
}
#[must_use]
pub const fn sccache_install_hint() -> &'static str {
if cfg!(target_os = "macos") {
"brew install sccache"
} else if cfg!(target_os = "linux") {
"your distro package manager (e.g. apt/dnf/pacman) or cargo install sccache"
} else if cfg!(target_os = "windows") {
"winget install Mozilla.sccache or cargo install sccache"
} else {
"cargo install sccache"
}
}
#[must_use]
pub const fn sccache_upgrade_hint() -> &'static str {
if cfg!(target_os = "macos") {
"brew upgrade sccache"
} else if cfg!(target_os = "linux") {
"your distro package manager or cargo install sccache --force"
} else if cfg!(target_os = "windows") {
"winget upgrade Mozilla.sccache or cargo install sccache --force"
} else {
"cargo install sccache --force"
}
}
pub(crate) fn command(command: &mut Command) -> &mut Command {
command
.kill_on_drop(true)
.stdout(if std_output_enabled() {
Stdio::inherit()
} else {
Stdio::piped()
})
.stderr(if std_output_enabled() {
Stdio::inherit()
} else {
Stdio::piped()
})
}
pub(crate) async fn run_command(
name: &str,
args: impl IntoIterator<Item = &str>,
) -> Result<String, CommandError> {
run_command_os(name, args).await
}
pub(crate) async fn run_command_os<N, A, S>(name: N, args: A) -> Result<String, CommandError>
where
N: AsRef<OsStr>,
A: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
Host::current().run(name, args).await
}
const MAX_REPORTED_OUTPUT_LINES: usize = 200;
fn is_diagnostic_line(line: &str) -> bool {
line.contains("error: ")
}
pub(crate) fn format_failure_stream(label: &str, bytes: &[u8]) -> String {
use std::fmt::Write as _;
let text = String::from_utf8_lossy(bytes);
let trimmed = text.trim_end();
if trimmed.is_empty() {
return String::new();
}
let lines: Vec<&str> = trimmed.lines().collect();
let elided = lines.len().saturating_sub(MAX_REPORTED_OUTPUT_LINES);
let body = lines[elided..].join("\n");
if elided == 0 {
return format!("\n{label}:\n{body}");
}
let mut report = String::new();
let diagnostics: Vec<&str> = lines[..elided]
.iter()
.copied()
.filter(|line| is_diagnostic_line(line))
.collect();
if !diagnostics.is_empty() {
write!(
report,
"\n{label} diagnostics before the reported tail ({} lines):\n{}",
diagnostics.len(),
diagnostics.join("\n")
)
.expect("writing to a String cannot fail");
}
write!(
report,
"\n{label} (last {MAX_REPORTED_OUTPUT_LINES} of {} lines):\n{body}",
lines.len()
)
.expect("writing to a String cannot fail");
report
}
pub fn parse_semver_version(input: &str) -> Result<Version, VersionParseError> {
let trimmed = input.trim();
if trimmed.is_empty() {
return Err(VersionParseError::EmptyVersion);
}
let normalized_input = trimmed.strip_prefix('v').unwrap_or(trimmed);
let mut split = normalized_input.splitn(2, '-');
let core = split.next().ok_or(VersionParseError::MissingCoreVersion)?;
let prerelease = split.next();
let mut components: Vec<&str> = core.split('.').collect();
match components.len() {
1 => {
components.push("0");
components.push("0");
}
2 => {
components.push("0");
}
3 => {}
count => {
return Err(VersionParseError::InvalidComponentCount {
count,
input: input.to_owned(),
});
}
}
let mut normalized = components.join(".");
if let Some(prerelease) = prerelease {
normalized.push('-');
normalized.push_str(prerelease);
}
Version::parse(&normalized).map_err(|source| VersionParseError::InvalidVersion {
input: input.to_owned(),
normalized,
source,
})
}
#[derive(Debug, Error)]
pub enum VersionParseError {
#[error("version is empty")]
EmptyVersion,
#[error("missing numeric core version")]
MissingCoreVersion,
#[error("expected 1-3 numeric components, found {count} in `{input}`")]
InvalidComponentCount {
count: usize,
input: String,
},
#[error("failed to parse version `{input}` as `{normalized}`: {source}")]
InvalidVersion {
input: String,
normalized: String,
#[source]
source: semver::Error,
},
}
pub(crate) fn parse_whitespace_separated_u32s(input: &str) -> Vec<u32> {
input
.split_whitespace()
.filter_map(|part| part.parse::<u32>().ok())
.collect()
}
pub async fn copy_file(from: impl AsRef<Path>, to: impl AsRef<Path>) -> io::Result<()> {
let from = from.as_ref().to_path_buf();
let to = to.as_ref().to_path_buf();
unblock(move || reflink::reflink_or_copy(from, to).map(|_| ())).await
}
#[cfg(test)]
mod tests {
use semver::Version;
use super::{
MAX_REPORTED_OUTPUT_LINES, format_failure_stream, parse_semver_version,
parse_whitespace_separated_u32s,
};
#[test]
fn parse_semver_version_accepts_major_minor() {
let parsed = parse_semver_version("1.88").expect("version should parse");
assert_eq!(parsed, Version::new(1, 88, 0));
}
#[test]
fn parse_semver_version_pads_deployment_target_style() {
let parsed = parse_semver_version("26.0").expect("version should parse");
assert_eq!(parsed, Version::new(26, 0, 0));
}
#[test]
fn parse_semver_version_orders_release_lines() {
let ios_18 = parse_semver_version("18.5").expect("version should parse");
let ios_26 = parse_semver_version("26.5").expect("version should parse");
assert!(ios_26 > ios_18);
}
#[test]
fn parse_semver_version_rejects_extra_components() {
assert!(parse_semver_version("1.2.3.4").is_err());
}
#[test]
fn failure_report_surfaces_diagnostics_elided_from_the_tail() {
let diagnostic =
"Sources/WuiMapView.swift:137:21: error: cannot find 'makeRegionWatcher' in scope";
let mut lines = vec!["CompileSwift normal arm64", diagnostic];
let noise = " export SDKROOT=/Applications/Xcode.app";
lines.extend(std::iter::repeat_n(noise, MAX_REPORTED_OUTPUT_LINES * 3));
lines.push("** BUILD FAILED **");
let report = format_failure_stream("stdout", lines.join("\n").as_bytes());
assert!(
report.contains(diagnostic),
"the elided compiler error must be reported: {report}"
);
assert!(report.contains("stdout diagnostics before the reported tail (1 lines):"));
assert!(report.contains(&format!(
"stdout (last {MAX_REPORTED_OUTPUT_LINES} of {} lines):",
lines.len()
)));
assert!(report.ends_with("** BUILD FAILED **"));
assert_eq!(
report.matches(diagnostic).count(),
1,
"a diagnostic outside the tail is reported once"
);
}
#[test]
fn failure_report_shows_short_output_whole() {
let report = format_failure_stream("stderr", b"error: linking failed\n");
assert_eq!(report, "\nstderr:\nerror: linking failed");
}
#[test]
fn parses_pidof_output_with_multiple_pids() {
let parsed = parse_whitespace_separated_u32s("123 456\n");
assert_eq!(parsed, vec![123, 456]);
}
#[test]
fn ignores_non_numeric_tokens() {
let parsed = parse_whitespace_separated_u32s("foo 42 bar\n");
assert_eq!(parsed, vec![42]);
}
}