use std::{
env,
ffi::OsString,
fs, path,
path::{Path, PathBuf},
process::Command,
};
use anyhow::{Context, Result, bail, ensure};
use crate::{Artifact, StdMode, args::Args, util::Set};
const TOOLCHAIN: Option<&str> = option_env!("RUSTUP_TOOLCHAIN");
const BUILT_AGAINST: Option<&str> = option_env!("PANICGRAPH_RUSTC");
struct Layout {
out: PathBuf,
target: PathBuf,
shared: bool,
}
struct Workspace {
root: PathBuf,
library: bool,
}
impl Workspace {
fn locate(args: &Args) -> Result<Self> {
let root = crate_root(args)?;
let library = is_library_workspace(&root)?;
Ok(Self { root, library })
}
}
pub fn collect(args: &Args) -> Result<Vec<Artifact>> {
let driver = driver_path()?;
check_toolchain()?;
let workspace = Workspace::locate(args)?;
let layout = prepare(&workspace.root, &driver, args)?;
let mut units = build(args, &driver, &workspace, &layout, false)?;
let mut artifacts = load(&layout.out, &units)?;
if artifacts.is_empty() {
clear(&layout.target)?;
units = build(args, &driver, &workspace, &layout, false)?;
artifacts = load(&layout.out, &units)?;
}
if args.with_tests {
match build(args, &driver, &workspace, &layout, true) {
Err(err) => eprintln!(
"warning: the test targets could not be built, so their \
instantiations are left out: {err:#}"
),
Ok(tested) => {
units.extend(tested);
artifacts = load(&layout.out, &units)?;
}
}
}
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()))
},
)
}
fn is_library_workspace(root: &Path) -> Result<bool> {
let mut cmd = cargo(root);
cmd.args(["locate-project", "--workspace", "--message-format", "plain"]);
let out = cmd
.output()
.context("could not run cargo; is it on the PATH?")?;
if !out.status.success() {
return Ok(false);
}
let path =
String::from_utf8(out.stdout).context("cargo printed invalid utf-8")?;
let path = path.trim();
if path.is_empty() {
return Ok(false);
}
let manifest = fs::read_to_string(path)
.with_context(|| format!("could not read {path}"))?;
Ok(is_library_manifest(&manifest))
}
const PATCH_TABLE: &str = "patch.crates-io";
const PATCH_ENTRY: &str = "patch.crates-io.rustc-std-workspace-core";
const LIBRARY_SHIM: &str = "rustc-std-workspace-core";
#[must_use]
pub fn is_library_manifest(manifest: &str) -> bool {
let mut patching = false;
for line in manifest.lines() {
let line = line.trim();
if let Some(rest) = line.strip_prefix('[') {
let table = rest.split(']').next().unwrap_or_default();
if spells(table, PATCH_ENTRY) {
return true;
}
patching = spells(table, PATCH_TABLE);
} else if patching {
let key = line.split('=').next().unwrap_or_default();
if spells(key, LIBRARY_SHIM) {
return true;
}
}
}
false
}
fn spells(text: &str, name: &str) -> bool {
text.chars()
.filter(|c| !matches!(c, '"' | '\'' | ' ' | '\t'))
.eq(name.chars())
}
pub fn build_tree(args: &Args) -> Result<PathBuf> {
let root = crate_root(args)?;
analysis_target(&root, args).map(|(tree, _)| tree)
}
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))
}
type Unit = (PathBuf, bool);
fn build(
args: &Args,
driver: &Path,
workspace: &Workspace,
layout: &Layout,
tests: bool,
) -> Result<Set<Unit>> {
let mut cmd = cargo(&workspace.root);
cmd.arg("build")
.arg("--message-format")
.arg("json-render-diagnostics")
.arg("--profile")
.arg(cargo_profile(&args.profile))
.arg("--target-dir")
.arg(&layout.target);
if tests {
cmd.arg("--tests");
}
if let Some(pkg) = &args.package {
cmd.arg("--package").arg(pkg);
}
if args.features.all {
cmd.arg("--all-features");
}
if args.features.no_default {
cmd.arg("--no-default-features");
}
if !args.features.named.is_empty() {
cmd.arg("--features").arg(args.features.named.join(","));
}
if args.std_mode == StdMode::Full {
cmd.arg("-Z")
.arg("build-std=core,alloc,std")
.arg("--target")
.arg(host_triple()?);
}
cmd.stdin(std::process::Stdio::piped())
.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(level) = args.mir_opt_level {
cmd.env("PANICGRAPH_MIR_OPT_LEVEL", level.to_string());
}
if workspace.library {
cmd.env("PANICGRAPH_LIBRARY_WORKSPACE", "1");
}
if let Some(path) = library_path()? {
cmd.env("LD_LIBRARY_PATH", path);
}
let mut child = cmd
.stdout(std::process::Stdio::piped())
.spawn()
.context("could not run cargo; is it on the PATH?")?;
drop(child.stdin.take());
let mut messages = String::new();
if let Some(mut out) = child.stdout.take() {
std::io::Read::read_to_string(&mut out, &mut messages)
.context("could not read what cargo reported")?;
}
let status = child.wait().context("could not wait for cargo")?;
if !status.success() {
bail!("the analysis build failed; the errors above are from cargo");
}
Ok(units_in(&messages))
}
fn units_in(messages: &str) -> Set<Unit> {
let mut units = Set::default();
for line in messages.lines() {
let Ok(message) = serde_json::from_str::<serde_json::Value>(line)
else {
continue;
};
if message["reason"] != "compiler-artifact" {
continue;
}
let Some(source) = message["target"]["src_path"].as_str() else {
continue;
};
let test = message["profile"]["test"].as_bool().unwrap_or(false);
units.insert((comparable(Path::new(source)), test));
}
units
}
fn comparable(path: &Path) -> PathBuf {
fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
}
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"),
mir_opt_suffix(args),
if args.with_tests { "-tests" } else { "" },
feature_suffix(args),
);
let (target, shared) = analysis_target(root, args)?;
let layout = Layout {
out: base.join(&slot),
target,
shared,
};
let stamp = driver_stamp(driver);
let marker = format!("{slot}\n{stamp}\n");
discard_if_stale(&layout, &marker, &stamp)?;
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, stamp: &str) -> Result<()> {
let path = layout.out.join(SLOT_MARKER);
let fresh = layout.out.exists()
&& fs::read_to_string(&path).is_ok_and(|found| found == marker);
if !fresh {
clear(&layout.out)?;
if !layout.shared {
clear(&layout.target)?;
}
}
if layout.shared {
discard_shared_if_stale(&layout.target, stamp)?;
}
Ok(())
}
fn discard_shared_if_stale(target: &Path, stamp: &str) -> Result<()> {
let path = target.join(SLOT_MARKER);
if target.exists()
&& !fs::read_to_string(&path).is_ok_and(|found| found == stamp)
{
clear(target)?;
}
fs::create_dir_all(target)
.with_context(|| format!("could not create {}", target.display()))?;
fs::write(&path, stamp.as_bytes()).with_context(|| {
format!("could not write the marker in {}", target.display())
})
}
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, bool)> {
let base = root.join("target").join("panicgraph");
let local = base.join(format!("build{}", mir_opt_suffix(args)));
Ok(match args.std_mode {
StdMode::Full => shared_build_dir(args)?
.map_or_else(|| (local.clone(), false), |tree| (tree, true)),
StdMode::Shipped => (local, false),
})
}
fn feature_suffix(args: &Args) -> String {
if args.features.is_default() {
return String::new();
}
let safe: String = args
.features
.describe()
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
.collect();
format!("-f-{safe}")
}
fn mir_opt_suffix(args: &Args) -> String {
args.mir_opt_level
.map(|level| format!("-mir{level}"))
.unwrap_or_default()
}
fn shared_build_dir(args: &Args) -> 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}{}",
mir_opt_suffix(args)
))))
}
fn load(dir: &Path, units: &Set<Unit>) -> 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()))?;
let current = artifact.source.as_deref().is_none_or(|source| {
units.contains(&(comparable(Path::new(source)), artifact.test))
});
if current {
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 cargo(dir: &Path) -> Command {
let mut cmd = Command::new("cargo");
cmd.current_dir(dir);
if let Some(toolchain) = TOOLCHAIN {
cmd.env("RUSTUP_TOOLCHAIN", toolchain);
}
cmd
}
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 }
}