use anyhow::{Context, Result, bail};
use semver::Version;
use serde::Deserialize;
use std::collections::BTreeMap;
use std::fs;
#[cfg(feature = "tui")]
use std::os::unix::fs::OpenOptionsExt;
#[cfg(feature = "tui")]
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::process::Command;
const CRATES_IO_SOURCE: &str = "registry+https://github.com/rust-lang/crates.io-index";
#[derive(Debug)]
pub struct Built {
pub version: Version,
pub bins: Vec<String>,
pub bin_paths: Vec<PathBuf>,
}
#[derive(Deserialize)]
struct Crates2 {
installs: BTreeMap<String, InstallInfo>,
}
#[derive(Deserialize)]
struct InstallInfo {
bins: Vec<String>,
}
#[cfg(all(test, feature = "tui"))]
static CARGO_PROGRAM: std::sync::RwLock<Option<PathBuf>> = std::sync::RwLock::new(None);
#[cfg(all(test, feature = "tui"))]
static FAKE_CARGO_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[cfg(all(test, feature = "tui"))]
pub(crate) struct FakeCargo {
_serial: std::sync::MutexGuard<'static, ()>,
}
#[cfg(all(test, feature = "tui"))]
impl FakeCargo {
pub(crate) fn install(script: &Path) -> Self {
let serial = FAKE_CARGO_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
*CARGO_PROGRAM.write().unwrap() = Some(script.to_path_buf());
Self { _serial: serial }
}
}
#[cfg(all(test, feature = "tui"))]
impl Drop for FakeCargo {
fn drop(&mut self) {
*CARGO_PROGRAM.write().unwrap() = None;
}
}
fn cargo_program() -> std::ffi::OsString {
#[cfg(all(test, feature = "tui"))]
if let Some(p) = CARGO_PROGRAM.read().unwrap().clone() {
return p.into_os_string();
}
std::ffi::OsString::from("cargo")
}
fn command(name: &str, version: Option<&Version>, locked: bool, stage: &Path) -> Command {
let mut cmd = Command::new(cargo_program());
cmd.arg("install").arg(name).arg("--root").arg(stage);
if let Some(version) = version {
cmd.arg("--version").arg(format!("={version}"));
}
if locked {
cmd.arg("--locked");
}
if let Some(path) = std::env::var_os("PATH") {
let mut dirs: Vec<PathBuf> = std::env::split_paths(&path).collect();
dirs.push(stage.join("bin"));
if let Ok(joined) = std::env::join_paths(dirs) {
cmd.env("PATH", joined);
}
}
cmd
}
pub fn build(name: &str, version: Option<&Version>, locked: bool, stage: &Path) -> Result<Built> {
fs::create_dir_all(stage).with_context(|| format!("creating {}", stage.display()))?;
let status = command(name, version, locked, stage)
.status()
.context("failed to spawn cargo")?;
if !status.success() {
bail!("cargo install {name} failed with {status}");
}
verified_info(name, version, stage)
}
#[cfg(feature = "tui")]
pub fn build_captured(
name: &str,
version: Option<&Version>,
locked: bool,
stage: &Path,
log_dir: &Path,
on_line: &mut dyn FnMut(&str),
) -> Result<Built> {
fs::create_dir_all(stage).with_context(|| format!("creating {}", stage.display()))?;
let mut cmd = command(name, version, locked, stage);
cmd.env("CARGO_TERM_COLOR", "never");
let mut child = cmd
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped())
.spawn()
.context("failed to spawn cargo")?;
let stderr = child
.stderr
.take()
.context("cargo spawned without a stderr pipe")?;
let mut reader = std::io::BufReader::new(stderr);
let mut lines: Vec<String> = Vec::new();
let mut read_error: Option<std::io::Error> = None;
let mut buf: Vec<u8> = Vec::new();
loop {
buf.clear();
match std::io::BufRead::read_until(&mut reader, b'\n', &mut buf) {
Ok(0) => break,
Ok(_) => {
while matches!(buf.last(), Some(b'\n' | b'\r')) {
buf.pop();
}
let line = crate::text::sanitize(&String::from_utf8_lossy(&buf));
on_line(&line);
lines.push(line);
}
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {}
Err(e) => {
read_error = Some(e);
break;
}
}
}
if read_error.is_some() {
let _ = child.kill();
}
let status = child.wait().context("waiting for cargo")?;
if let Some(e) = read_error {
return Err(failure_with_log(
log_dir,
name,
&lines,
format!("reading cargo output failed: {e}"),
&tail_from(&lines, lines.len().saturating_sub(TAIL_LINES)),
));
}
if !status.success() {
let start = lines
.iter()
.position(|l| {
matches!(
crate::progress::parse_line(l),
crate::progress::BuildEvent::Error
)
})
.unwrap_or(lines.len().saturating_sub(TAIL_LINES));
return Err(failure_with_log(
log_dir,
name,
&lines,
format!("cargo install {name} failed with {status}"),
&tail_from(&lines, start),
));
}
verified_info(name, version, stage).map_err(|e| {
failure_with_log(
log_dir,
name,
&lines,
format!("cargo exited successfully, but: {e:#}"),
&tail_from(&lines, lines.len().saturating_sub(TAIL_LINES)),
)
})
}
#[cfg(feature = "tui")]
fn tail_from(lines: &[String], start: usize) -> Vec<&str> {
lines[start..]
.iter()
.take(TAIL_LINES)
.map(String::as_str)
.collect()
}
#[cfg(feature = "tui")]
fn failure_with_log(
log_dir: &Path,
name: &str,
lines: &[String],
headline: String,
tail: &[&str],
) -> anyhow::Error {
let log = write_build_log(log_dir, name, lines);
let mut msg = headline;
match log {
Ok(path) => {
msg.push_str("\nfull log: ");
msg.push_str(&path.display().to_string());
}
Err(e) => {
use std::fmt::Write as _;
let _ = write!(msg, "\n(could not write the full log: {e:#})");
}
}
for l in tail {
msg.push_str("\n ");
msg.push_str(l);
}
anyhow::anyhow!(msg)
}
#[cfg(feature = "tui")]
const TAIL_LINES: usize = 12;
#[cfg(feature = "tui")]
fn write_build_log(log_dir: &Path, name: &str, lines: &[String]) -> Result<PathBuf> {
fs::create_dir_all(log_dir)
.with_context(|| format!("creating log directory {}", log_dir.display()))?;
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos());
let path = log_dir.join(format!("build-{name}-{}-{stamp}.log", std::process::id()));
let mut file = fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&path)
.with_context(|| format!("creating build log {}", path.display()))?;
file.set_permissions(fs::Permissions::from_mode(0o600))
.with_context(|| format!("setting permissions on build log {}", path.display()))?;
let mut body = lines.join("\n");
body.push('\n');
std::io::Write::write_all(&mut file, body.as_bytes())
.with_context(|| format!("writing build log {}", path.display()))?;
Ok(path)
}
fn verified_info(name: &str, version: Option<&Version>, stage: &Path) -> Result<Built> {
let built = staged_info(name, stage)?;
if let Some(version) = version
&& built.version != *version
{
bail!(
"asked for {name} {version} but the stage holds {}",
built.version
);
}
Ok(built)
}
fn staged_info(name: &str, stage: &Path) -> Result<Built> {
let path = stage.join(".crates2.json");
let raw = fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?;
let parsed: Crates2 =
serde_json::from_str(&raw).with_context(|| format!("parsing {}", path.display()))?;
let mut best: Option<Built> = None;
for (key, info) in &parsed.installs {
let mut parts = key.split_whitespace();
let (Some(key_name), Some(key_version), Some(key_source)) =
(parts.next(), parts.next(), parts.next())
else {
continue;
};
if key_name != name || !key_source.contains(CRATES_IO_SOURCE) {
continue;
}
let version = Version::parse(key_version)
.with_context(|| format!("unparsable staged version `{key_version}`"))?;
let replace = match &best {
Some(b) => version > b.version,
None => true,
};
if replace {
crate::validate::validate_bin_list(&info.bins)
.with_context(|| format!("stage bookkeeping for `{name}`"))?;
let bin_dir = stage.join("bin");
best = Some(Built {
bin_paths: info.bins.iter().map(|b| bin_dir.join(b)).collect(),
bins: info.bins.clone(),
version,
});
}
}
best.with_context(|| format!("`{name}` missing from stage bookkeeping after build"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn staged_info_parses_crates2() {
let dir = std::env::temp_dir().join("cargo-lbin-test-stage");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
fs::write(
dir.join(".crates2.json"),
r#"{"installs":{
"hexyl 0.14.0 (registry+https://github.com/rust-lang/crates.io-index)":
{"bins":["hexyl"]},
"other 1.0.0 (git+https://example.com/other#abc)":
{"bins":["other"]}
}}"#,
)
.unwrap();
let built = staged_info("hexyl", &dir).unwrap();
assert_eq!(built.version, Version::parse("0.14.0").unwrap());
assert_eq!(built.bins, vec!["hexyl"]);
assert!(
staged_info("other", &dir).is_err(),
"git source must not match"
);
assert!(staged_info("absent", &dir).is_err());
fs::write(
dir.join(".crates2.json"),
r#"{"installs":{
"dupes 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)":
{"bins":["foo","foo"]}
}}"#,
)
.unwrap();
let err = format!("{:#}", staged_info("dupes", &dir).unwrap_err());
assert!(err.contains("listed twice"), "{err}");
let _ = fs::remove_dir_all(&dir);
}
#[cfg(feature = "tui")]
#[test]
fn captured_build_forwards_lines_and_logs_failures() {
use std::os::unix::fs::PermissionsExt;
let root = std::env::temp_dir().join("cargo-lbin-test-captured");
let _ = fs::remove_dir_all(&root);
let fake_bin = root.join("bin");
let stage = root.join("stage");
let logs = root.join("logs");
fs::create_dir_all(&fake_bin).unwrap();
let script = fake_bin.join("cargo");
fs::write(
&script,
"#!/bin/sh\n\
echo ' Compiling one v1.0.0' >&2\n\
echo ' Compiling two v2.0.0' >&2\n\
echo 'error[E0308]: mismatched types' >&2\n\
echo 'note: expected u8' >&2\n\
exit 101\n",
)
.unwrap();
fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
let _fake = FakeCargo::install(&script);
let mut seen: Vec<String> = Vec::new();
let err = build_captured("boomcrate", None, false, &stage, &logs, &mut |l| {
seen.push(l.to_owned());
})
.unwrap_err();
let msg = format!("{err:#}");
assert_eq!(seen.len(), 4, "every stderr line reaches the frontend");
assert!(
msg.contains("error[E0308]") && msg.contains("note: expected u8"),
"tail starts at the first compiler error: {msg}"
);
assert!(
!msg.contains("Compiling one"),
"successful units stay out of the tail: {msg}"
);
assert!(msg.contains("full log:"), "log path travels in the error");
let log = fs::read_dir(&logs).unwrap().next().unwrap().unwrap().path();
let mode = fs::metadata(&log).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "the build log is private to the user");
let full = fs::read_to_string(&log).unwrap();
assert!(
full.contains("Compiling one") && full.contains("note: expected u8"),
"the log holds everything the tail dropped"
);
fs::write(
&script,
"#!/bin/sh\n\
echo ' Compiling okcrate v0.1.0' >&2\n\
echo ' Finished release [optimized]' >&2\n\
mkdir -p \"$4\"\n\
printf '%s' '{\"installs\":{\"okcrate 0.1.0 (registry+https://github.com/rust-lang/crates.io-index)\":{\"bins\":[\"okcrate\"]}}}' > \"$4/.crates2.json\"\n\
exit 0\n",
)
.unwrap();
let mut count = 0usize;
let built = build_captured("okcrate", None, false, &stage, &logs, &mut |l| {
if matches!(
crate::progress::parse_line(l),
crate::progress::BuildEvent::Compiling { .. }
) {
count += 1;
}
})
.unwrap();
assert_eq!(count, 1);
assert_eq!(built.bins, vec!["okcrate"]);
fs::write(&script, "#!/bin/sh\nexit 0\n").unwrap();
let _ = fs::remove_dir_all(&logs);
let err = build_captured("ghost", None, false, &stage, &logs, &mut |_| {}).unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("cargo exited successfully, but:"),
"verification failure names itself: {msg}"
);
assert!(
msg.contains("full log:"),
"verification failure still writes and names the log: {msg}"
);
let _ = fs::remove_dir_all(&root);
}
}