use std::collections::{HashMap, HashSet};
use std::io::IsTerminal;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use anyhow::{Context, Result, bail};
pub struct LockfilePackages {
names: HashSet<String>,
multi_versions: HashMap<String, Vec<String>>,
}
impl LockfilePackages {
pub fn contains(&self, name: &str) -> bool {
self.names.contains(name)
}
pub fn is_empty(&self) -> bool {
self.names.is_empty()
}
pub fn resolve_spec(&self, name: &str) -> Option<String> {
if let Some(spec) = self.resolve_spec_exact(name) {
return Some(spec);
}
let hyphenated = name.replace('_', "-");
if hyphenated != name {
return self.resolve_spec_exact(&hyphenated);
}
None
}
fn resolve_spec_exact(&self, name: &str) -> Option<String> {
if !self.names.contains(name) {
return None;
}
if let Some(versions) = self.multi_versions.get(name) {
let highest = versions.last().unwrap();
Some(format!("{name}@{highest}"))
} else {
Some(name.to_string())
}
}
}
static TOOLCHAIN_CHECKED: AtomicBool = AtomicBool::new(false);
fn ensure_toolchain_available(toolchain: &str) -> Result<()> {
if TOOLCHAIN_CHECKED.load(Ordering::Relaxed) {
return Ok(());
}
let result = Command::new("rustup")
.args(["which", "rustdoc", "--toolchain", toolchain])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
match result {
Ok(status) if status.success() => {
TOOLCHAIN_CHECKED.store(true, Ordering::Relaxed);
return Ok(());
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
bail!(
"rustup is not installed. Install it from https://rustup.rs/ \
then run: rustup toolchain install {toolchain}"
);
}
_ => {} }
if std::io::stderr().is_terminal() {
eprintln!("[cargo-brief] The '{toolchain}' toolchain is required but not installed.");
eprint!("[cargo-brief] Install it now? [y/N] ");
let response = read_tty_line();
if !response
.as_ref()
.is_ok_and(|s| matches!(s.trim(), "y" | "Y"))
{
bail!(
"The '{toolchain}' toolchain is not installed.\n\
Install it with: rustup toolchain install {toolchain}"
);
}
eprintln!("[cargo-brief] Installing '{toolchain}' toolchain...");
let install_status = Command::new("rustup")
.args(["toolchain", "install", toolchain])
.stderr(Stdio::inherit())
.stdout(Stdio::inherit())
.status()
.context("Failed to run `rustup toolchain install`")?;
if !install_status.success() {
bail!(
"Failed to install the '{toolchain}' toolchain.\n\
Try manually: rustup toolchain install {toolchain}"
);
}
TOOLCHAIN_CHECKED.store(true, Ordering::Relaxed);
Ok(())
} else {
bail!(
"The '{toolchain}' toolchain is not installed.\n\
Install it with: rustup toolchain install {toolchain}"
);
}
}
fn read_tty_line() -> std::io::Result<String> {
#[cfg(unix)]
const TTY_PATH: &str = "/dev/tty";
#[cfg(windows)]
const TTY_PATH: &str = "CONIN$";
#[cfg(not(any(unix, windows)))]
return Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"TTY input not supported on this platform",
));
#[cfg(any(unix, windows))]
{
use std::io::BufRead;
let tty = std::fs::File::open(TTY_PATH)?;
let mut reader = std::io::BufReader::new(tty);
let mut line = String::new();
reader.read_line(&mut line)?;
Ok(line)
}
}
pub fn generate_rustdoc_json(
crate_name: &str,
toolchain: &str,
manifest_path: Option<&str>,
document_private_items: bool,
target_dir: &Path,
verbose: bool,
use_cache: bool,
) -> Result<PathBuf> {
if use_cache {
let base_name = crate_name.split('@').next().unwrap_or(crate_name);
let json_name = base_name.replace('-', "_");
let json_path = target_dir.join("doc").join(format!("{json_name}.json"));
if json_path.exists() {
if verbose {
eprintln!("[cargo-brief] Using cached rustdoc JSON for '{crate_name}'");
}
return Ok(json_path);
}
}
ensure_toolchain_available(toolchain)?;
let mut cmd = Command::new("cargo");
cmd.arg(format!("+{toolchain}"));
cmd.args(["rustdoc", "-p", crate_name, "--lib"]);
if let Some(manifest) = manifest_path {
cmd.args(["--manifest-path", manifest]);
}
cmd.arg("--");
cmd.args(["--output-format", "json", "-Z", "unstable-options"]);
if document_private_items {
cmd.arg("--document-private-items");
}
if verbose {
cmd.stderr(Stdio::inherit());
let status = cmd.status().with_context(|| {
format!(
"Failed to execute `cargo +{toolchain} rustdoc`. \
Is the '{toolchain}' toolchain installed? Try: rustup toolchain install {toolchain}"
)
})?;
if !status.success() {
bail!("cargo rustdoc failed for '{crate_name}' (see output above)");
}
} else {
let output = cmd.output().with_context(|| {
format!(
"Failed to execute `cargo +{toolchain} rustdoc`. \
Is the '{toolchain}' toolchain installed? Try: rustup toolchain install {toolchain}"
)
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("toolchain") && stderr.contains("is not installed") {
bail!(
"The '{toolchain}' toolchain is not installed.\n\
Install it with: rustup toolchain install {toolchain}"
);
}
if stderr.contains("is ambiguous") {
if !crate_name.contains('@') {
let specs: Vec<&str> = stderr
.lines()
.filter_map(|l| {
let trimmed = l.trim();
if trimmed.contains('@') && !trimmed.contains(' ') {
Some(trimmed)
} else {
None
}
})
.collect();
if let Some(best) = pick_highest_version_spec(&specs) {
return generate_rustdoc_json(
best,
toolchain,
manifest_path,
document_private_items,
target_dir,
verbose,
use_cache,
);
}
}
bail!(
"Multiple versions of '{crate_name}' exist and auto-resolution failed. \
Use `<name>@<version>` to disambiguate (e.g. `{crate_name}@1.0.0`)."
);
}
if stderr.contains("did not match any packages")
|| stderr.contains("package(s) `")
|| stderr.contains("no packages match")
{
bail!(
"Package '{crate_name}' not found in the workspace.\n\
Check the package name and ensure it exists in the workspace.\n\
TIP: If it's an optional or unresolved dependency, try:\n\
cargo brief --crates {crate_name} --features <features>\n\
Original error:\n{stderr}"
);
}
bail!("cargo rustdoc failed:\n{stderr}");
}
}
let base_name = crate_name.split('@').next().unwrap_or(crate_name);
let json_name = base_name.replace('-', "_");
let json_path = target_dir.join("doc").join(format!("{json_name}.json"));
if !json_path.exists() {
bail!(
"Expected rustdoc JSON at {} but file not found",
json_path.display()
);
}
Ok(json_path)
}
pub fn parse_rustdoc_json_cached(path: &Path) -> Result<rustdoc_types::Crate> {
let bin_path = path.with_extension("bin");
if bin_path.exists()
&& let (Ok(bin_meta), Ok(json_meta)) = (bin_path.metadata(), path.metadata())
&& bin_meta.modified()? >= json_meta.modified()?
{
let bytes = std::fs::read(&bin_path)?;
if let Ok(krate) = bincode::deserialize(&bytes) {
return Ok(krate);
}
}
let krate = parse_rustdoc_json(path)?;
if let Ok(bytes) = bincode::serialize(&krate) {
let _ = std::fs::write(&bin_path, bytes);
}
Ok(krate)
}
pub fn parse_rustdoc_json(path: &Path) -> Result<rustdoc_types::Crate> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read {}", path.display()))?;
let krate: rustdoc_types::Crate =
serde_json::from_str(&content).context("Failed to parse rustdoc JSON")?;
Ok(krate)
}
pub fn load_lockfile_packages(manifest_path: Option<&str>) -> LockfilePackages {
let lockfile_path = if let Some(manifest) = manifest_path {
Path::new(manifest)
.parent()
.map(|p| p.join("Cargo.lock"))
.unwrap_or_else(|| PathBuf::from("Cargo.lock"))
} else {
PathBuf::from("Cargo.lock")
};
let content = match std::fs::read_to_string(&lockfile_path) {
Ok(c) => c,
Err(_) => {
return LockfilePackages {
names: HashSet::new(),
multi_versions: HashMap::new(),
};
}
};
let mut names = HashSet::new();
let mut all_versions: HashMap<String, Vec<String>> = HashMap::new();
let mut current_name: Option<String> = None;
for line in content.lines() {
let trimmed = line.trim();
if trimmed == "[[package]]" {
current_name = None;
continue;
}
if trimmed.starts_with("name = \"") {
current_name = trimmed
.strip_prefix("name = \"")
.and_then(|s| s.strip_suffix('"'))
.map(|s| s.to_string());
if let Some(ref name) = current_name {
names.insert(name.clone());
}
} else if trimmed.starts_with("version = \"")
&& let Some(ref name) = current_name
&& let Some(ver) = trimmed
.strip_prefix("version = \"")
.and_then(|s| s.strip_suffix('"'))
{
all_versions
.entry(name.clone())
.or_default()
.push(ver.to_string());
} else if trimmed.starts_with('[') {
current_name = None;
}
}
let multi_versions: HashMap<String, Vec<String>> = all_versions
.into_iter()
.filter(|(_, v)| v.len() > 1)
.map(|(name, mut versions)| {
versions.sort_by(
|a, b| match (semver::Version::parse(a), semver::Version::parse(b)) {
(Ok(va), Ok(vb)) => va.cmp(&vb),
_ => a.cmp(b),
},
);
(name, versions)
})
.collect();
LockfilePackages {
names,
multi_versions,
}
}
pub fn batch_generate_rustdoc_json(
crate_names: &[&str],
toolchain: &str,
manifest_path: Option<&str>,
target_dir: &Path,
verbose: bool,
) -> Vec<String> {
let mut succeeded = Vec::new();
let mut to_generate = Vec::new();
for &name in crate_names {
let base = name.split('@').next().unwrap_or(name);
let json_name = base.replace('-', "_");
let json_path = target_dir.join("doc").join(format!("{json_name}.json"));
if json_path.exists() {
succeeded.push(name.to_string());
} else {
to_generate.push(name);
}
}
if to_generate.is_empty() {
return succeeded;
}
if verbose {
eprintln!(
"[cargo-brief] Batch generating rustdoc JSON for {} crate(s): {}",
to_generate.len(),
to_generate.join(", ")
);
}
let mut cmd = Command::new("cargo");
cmd.arg(format!("+{toolchain}"));
cmd.args(["doc", "--no-deps", "--lib"]);
for name in &to_generate {
cmd.args(["-p", name]);
}
if let Some(manifest) = manifest_path {
cmd.args(["--manifest-path", manifest]);
}
cmd.env(
"RUSTDOCFLAGS",
"--output-format json -Z unstable-options --document-private-items",
);
if verbose {
cmd.stderr(Stdio::inherit());
let status = cmd.status();
if let Err(e) = &status {
eprintln!("warning: batch cargo doc failed to execute: {e}");
return succeeded;
}
} else {
let output = cmd.output();
match output {
Err(e) => {
eprintln!("warning: batch cargo doc failed to execute: {e}");
return succeeded;
}
Ok(o) if !o.status.success() => {
}
Ok(_) => {}
}
}
for name in &to_generate {
let base = name.split('@').next().unwrap_or(name);
let json_name = base.replace('-', "_");
let json_path = target_dir.join("doc").join(format!("{json_name}.json"));
if json_path.exists() {
succeeded.push(name.to_string());
} else if verbose {
eprintln!("warning: batch cargo doc did not produce JSON for '{name}'");
}
}
succeeded
}
fn pick_highest_version_spec<'a>(specs: &[&'a str]) -> Option<&'a str> {
specs
.iter()
.filter_map(|&s| {
let ver_str = s.split_once('@')?.1;
let ver = semver::Version::parse(ver_str).ok()?;
Some((ver, s))
})
.max_by(|a, b| a.0.cmp(&b.0))
.map(|(_, s)| s)
}