use std::{
env,
ffi::OsString,
fs, path,
path::{Path, PathBuf},
process::Command,
};
use anyhow::{Context, Result, bail, ensure};
use crate::{Artifact, StdMode, args::Args};
const TOOLCHAIN: Option<&str> = option_env!("RUSTUP_TOOLCHAIN");
const BUILT_AGAINST: Option<&str> = option_env!("PANICGRAPH_RUSTC");
struct Layout {
out: PathBuf,
target: PathBuf,
}
pub fn collect(args: &Args) -> Result<Vec<Artifact>> {
let driver = driver_path()?;
check_toolchain()?;
let root = crate_root(args)?;
let layout = prepare(&root, &driver, args)?;
build(args, &driver, &root, &layout)?;
let mut artifacts = load(&layout.out)?;
if artifacts.is_empty() {
clear(&layout.target)?;
build(args, &driver, &root, &layout)?;
artifacts = load(&layout.out)?;
}
if artifacts.is_empty() {
bail!("the build produced no analysis artifacts");
}
Ok(artifacts)
}
fn crate_root(args: &Args) -> Result<PathBuf> {
args.manifest_dir.as_ref().map_or_else(
|| {
env::current_dir()
.context("could not determine the current directory")
},
|dir| {
path::absolute(dir)
.with_context(|| format!("could not resolve {}", dir.display()))
},
)
}
pub fn build_tree(args: &Args) -> Result<PathBuf> {
let root = crate_root(args)?;
analysis_target(&root, args)
}
fn check_toolchain() -> Result<()> {
let (Some(built), Some(current)) = (BUILT_AGAINST, rustc_version()?) else {
return Ok(());
};
ensure!(
current == built,
"this tool was built against {built}, but the toolchain now offers \
{current}. The analysis driver links that compiler's own libraries \
and cannot run against another build of it, so reinstall with \
`cargo install --path .`"
);
Ok(())
}
fn rustc_version() -> Result<Option<String>> {
let text = rustc_output(&["--version"])?;
let line = text.lines().next().unwrap_or_default().trim().to_owned();
Ok((!line.is_empty()).then_some(line))
}
fn build(
args: &Args,
driver: &Path,
root: &Path,
layout: &Layout,
) -> Result<()> {
let mut cmd = Command::new("cargo");
cmd.current_dir(root)
.arg("build")
.arg("--profile")
.arg(cargo_profile(&args.profile))
.arg("--target-dir")
.arg(&layout.target);
if let Some(pkg) = &args.package {
cmd.arg("--package").arg(pkg);
}
if args.std_mode == StdMode::Full {
cmd.arg("-Z")
.arg("build-std=core,alloc,std")
.arg("--target")
.arg(host_triple()?);
}
cmd.env("RUSTC_WRAPPER", driver)
.env("PANICGRAPH_OUT", &layout.out)
.env("PANICGRAPH_PROFILE", &args.profile)
.env("PANICGRAPH_STD_MODE", args.std_mode.name());
if let Some(toolchain) = TOOLCHAIN {
cmd.env("RUSTUP_TOOLCHAIN", toolchain);
}
if let Some(path) = library_path()? {
cmd.env("LD_LIBRARY_PATH", path);
}
let status = cmd
.status()
.context("could not run cargo; is it on the PATH?")?;
if !status.success() {
bail!("the analysis build failed; the errors above are from cargo");
}
Ok(())
}
const SLOT_MARKER: &str = ".panicgraph-slot";
fn prepare(root: &Path, driver: &Path, args: &Args) -> Result<Layout> {
let base = root.join("target").join("panicgraph");
let slot = format!(
"{}-{}-{}",
args.profile,
args.std_mode.name(),
args.package.as_deref().unwrap_or("all"),
);
let layout = Layout {
out: base.join(&slot),
target: analysis_target(root, args)?,
};
let marker = format!("{slot}\n{}\n", driver_stamp(driver));
discard_if_stale(&layout, &marker)?;
fs::create_dir_all(&layout.out).with_context(|| {
format!("could not create {}", layout.out.display())
})?;
fs::write(layout.out.join(SLOT_MARKER), marker.as_bytes()).with_context(
|| format!("could not write the marker in {}", layout.out.display()),
)?;
prune_stale(&base, &layout);
Ok(layout)
}
fn driver_stamp(driver: &Path) -> String {
let Ok(meta) = fs::metadata(driver) else {
return "unknown".to_owned();
};
let modified = meta
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map_or(0, |d| d.as_nanos());
format!("{}-{modified}", meta.len())
}
fn discard_if_stale(layout: &Layout, marker: &str) -> Result<()> {
let path = layout.out.join(SLOT_MARKER);
if !layout.out.exists() {
return Ok(());
}
if fs::read_to_string(&path).is_ok_and(|found| found == marker) {
return Ok(());
}
clear(&layout.out)?;
clear(&layout.target)
}
fn clear(dir: &Path) -> Result<()> {
let (Some(parent), Some(name)) = (dir.parent(), dir.file_name()) else {
return Ok(());
};
let aside = parent.join(format!(
"{}.discarded-{}",
name.to_string_lossy(),
std::process::id()
));
match fs::rename(dir, &aside) {
Ok(()) => {}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
return Ok(());
}
Err(err) => {
return Err(err)
.with_context(|| format!("could not clear {}", dir.display()));
}
}
match fs::remove_dir_all(&aside) {
Ok(()) => Ok(()),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(err) => Err(err)
.with_context(|| format!("could not remove {}", aside.display())),
}
}
fn prune_stale(base: &Path, layout: &Layout) {
let Ok(entries) = fs::read_dir(base) else {
return;
};
for entry in entries.filter_map(Result::ok) {
let path = entry.path();
if !path.is_dir()
|| path == layout.out
|| path == layout.target
|| path.file_name().is_some_and(|name| name == "build")
{
continue;
}
if path.join(SLOT_MARKER).exists() {
continue;
}
if fs::remove_dir_all(&path).is_ok() {
eprintln!("removed stale results in {}", path.display());
}
}
}
fn analysis_target(root: &Path, args: &Args) -> Result<PathBuf> {
let base = root.join("target").join("panicgraph");
Ok(match args.std_mode {
StdMode::Full => {
shared_build_dir()?.unwrap_or_else(|| base.join("build"))
}
StdMode::Shipped => base.join("build"),
})
}
fn shared_build_dir() -> Result<Option<PathBuf>> {
let base = env::var_os("PANICGRAPH_CACHE")
.map(PathBuf::from)
.or_else(|| {
env::var_os("XDG_CACHE_HOME")
.map(|dir| PathBuf::from(dir).join("panicgraph"))
})
.or_else(|| {
env::var_os("HOME").map(|home| {
PathBuf::from(home).join(".cache").join("panicgraph")
})
});
let Some(base) = base else {
return Ok(None);
};
let version = match BUILT_AGAINST {
Some(version) => version.to_owned(),
None => match rustc_version()? {
Some(version) => version,
None => return Ok(None),
},
};
let fingerprint: String = version
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
.collect();
Ok(Some(base.join(format!("build-{fingerprint}"))))
}
fn load(dir: &Path) -> Result<Vec<Artifact>> {
let mut paths: Vec<PathBuf> = Vec::new();
for entry in fs::read_dir(dir)
.with_context(|| format!("could not read {}", dir.display()))?
{
let path = entry?.path();
if path.extension().is_some_and(|e| e == "json") {
paths.push(path);
}
}
paths.sort();
let mut out = Vec::with_capacity(paths.len());
for path in paths {
let text = fs::read(&path)
.with_context(|| format!("could not read {}", path.display()))?;
let artifact: Artifact = serde_json::from_slice(&text)
.with_context(|| format!("could not parse {}", path.display()))?;
out.push(artifact);
}
Ok(out)
}
fn driver_path() -> Result<PathBuf> {
let exe = env::current_exe().context("could not locate this program")?;
let dir = exe
.parent()
.context("this program has no containing directory")?;
let driver = dir.join("panicgraph-driver");
if !driver.exists() {
bail!(
"the analysis driver is missing from {}; it is installed \
alongside this program, so reinstall with `cargo install \
--path .`",
dir.display()
);
}
Ok(driver)
}
fn library_path() -> Result<Option<OsString>> {
let Some(sysroot) = sysroot()? else {
return Ok(None);
};
let lib = PathBuf::from(sysroot).join("lib");
let mut value = lib.into_os_string();
if let Some(existing) = env::var_os("LD_LIBRARY_PATH") {
value.push(":");
value.push(existing);
}
Ok(Some(value))
}
pub(crate) fn sysroot() -> Result<Option<String>> {
let text = rustc_output(&["--print", "sysroot"])?;
let text = text.trim();
Ok((!text.is_empty()).then(|| text.to_owned()))
}
pub(crate) fn host_triple() -> Result<String> {
rustc_field("host: ")?.context("rustc did not report a host triple")
}
fn rustc_field(label: &str) -> Result<Option<String>> {
Ok(rustc_output(&["--version", "--verbose"])?
.lines()
.find_map(|l| l.strip_prefix(label))
.map(str::trim)
.map(str::to_owned))
}
fn rustc_output(args: &[&str]) -> Result<String> {
let mut cmd = Command::new("rustc");
if let Some(toolchain) = TOOLCHAIN {
cmd.env("RUSTUP_TOOLCHAIN", toolchain);
}
let out = cmd.args(args).output().context("could not run rustc")?;
ensure!(
out.status.success(),
"rustc {} failed: {}",
args.join(" "),
String::from_utf8_lossy(&out.stderr).trim()
);
String::from_utf8(out.stdout).context("rustc printed invalid utf-8")
}
pub(crate) fn profile_dir(profile: &str) -> &str {
if profile == "dev" { "debug" } else { profile }
}
fn cargo_profile(profile: &str) -> &str {
if profile == "debug" { "dev" } else { profile }
}