use std::borrow::Cow;
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use anyhow::Context as _;
use itertools::Itertools;
use crate::data_generation::request::RequestKind;
use crate::util::atomic_write;
use super::error::{IntoTerminalResult as _, TerminalError};
use super::progress::CallbackHandler;
use super::request::CrateDataRequest;
const EXTRA_RUSTDOCFLAGS: &str = "-Z unstable-options --document-private-items --document-hidden-items --output-format=json --cap-lints=allow";
#[derive(Debug, Clone)]
pub(crate) struct RustdocBuildEnvironment {
pub(crate) target_triple: String,
pub(crate) cargo_rustflags: Cow<'static, str>,
pub(crate) cargo_rustdocflags: Cow<'static, str>,
pub(crate) toolchain_version: String,
}
impl RustdocBuildEnvironment {
pub(crate) fn from_env_and_config_for_target(
build_target: Option<&str>,
) -> anyhow::Result<Self> {
let flags = Flags::from_env_and_config_for_target(build_target)?;
let cargo_rustflags = if flags.rustflags.is_empty() {
Cow::Borrowed("--cap-lints=allow")
} else {
Cow::Owned(format!("{} --cap-lints=allow", flags.rustflags))
};
let cargo_rustdocflags = if flags.rustdocflags.is_empty() {
Cow::Borrowed(EXTRA_RUSTDOCFLAGS)
} else {
Cow::Owned(format!("{} {}", flags.rustdocflags, EXTRA_RUSTDOCFLAGS))
};
let cmd_output = std::process::Command::new("rustdoc")
.arg("--version")
.output()
.context("'rustdoc --version' failed, is Rust installed correctly?")?;
if !cmd_output.status.success() {
anyhow::bail!(
"'rustdoc --version' exited with status {}",
cmd_output.status
);
}
let mut toolchain_version = String::from_utf8(cmd_output.stdout)
.context("'rustdoc --version' output is not legal UTF-8")?;
toolchain_version.truncate(toolchain_version.trim_end().len());
let prefix = "rustdoc ";
anyhow::ensure!(
toolchain_version.starts_with(prefix),
"'rustdoc --version' output did not start with 'rustdoc '",
);
toolchain_version.drain(..prefix.len());
Ok(Self {
target_triple: flags.target_triple,
cargo_rustflags,
cargo_rustdocflags,
toolchain_version,
})
}
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct GenerationSettings {
pub(crate) pass_through_stderr: bool,
pub(crate) use_color: bool,
}
impl GenerationSettings {
fn stderr(&self) -> std::process::Stdio {
if self.pass_through_stderr {
std::process::Stdio::inherit()
} else {
std::process::Stdio::piped()
}
}
fn color_flag(&self) -> &'static str {
if self.use_color {
"--color=always"
} else {
"--color=never"
}
}
}
pub(super) fn generate_rustdoc(
request: &CrateDataRequest<'_>,
build_dir: &Path,
settings: GenerationSettings,
build_environment: &RustdocBuildEnvironment,
callbacks: &mut CallbackHandler<'_>,
) -> Result<(PathBuf, cargo_metadata::Metadata), TerminalError> {
let crate_name = request.kind.name().into_terminal_result()?;
let version = request.kind.version().into_terminal_result()?;
callbacks.generate_placeholder_project_start();
let placeholder_manifest = create_placeholder_rustdoc_manifest(request)
.context("failed to create placeholder manifest")
.into_terminal_result()?;
let placeholder_manifest_path =
save_placeholder_rustdoc_manifest(build_dir, placeholder_manifest)
.context("failed to save placeholder rustdoc manifest")
.into_terminal_result()?;
callbacks.generate_placeholder_project_success();
if matches!(request.kind, RequestKind::LocalProject(..)) {
match run_cargo_update(
crate_name,
version,
request,
placeholder_manifest_path.as_path(),
&settings,
) {
CargoUpdateResult::Success => {}
CargoUpdateResult::IoError(e) => {
let error = anyhow::Error::new(e)
.context("IO error while running 'cargo update' on placeholder project");
return Err(TerminalError::Other(error));
}
CargoUpdateResult::ErrorReturned(_exit_status, message) => {
let error = anyhow::anyhow!(
"aborting due to failure to run 'cargo update' for crate {crate_name} v{version}"
);
return Err(TerminalError::WithAdvice(error, message));
}
}
}
let metadata = cargo_metadata::MetadataCommand::new()
.manifest_path(&placeholder_manifest_path)
.exec()?;
let placeholder_target_directory = metadata.target_directory.as_path().as_std_path().to_owned();
let target_dir = placeholder_target_directory.as_path();
let rustdoc_data = run_cargo_doc(
request,
&metadata,
&placeholder_manifest_path,
target_dir,
crate_name,
version,
&settings,
build_environment,
callbacks,
)?;
Ok((rustdoc_data, metadata))
}
fn produce_repro_workspace_shell_commands(request: &CrateDataRequest<'_>) -> String {
let selector = match &request.kind {
RequestKind::Registry { .. } => format!(
"{}@={}",
request.kind.name().expect("failed to get crate name"),
request.kind.version().expect("failed to get crate version")
),
RequestKind::LocalProject(project) => format!(
"--path {}",
project
.manifest
.path
.parent()
.expect("source Cargo.toml had no parent path")
.canonicalize()
.expect("failed to canonicalize path")
.to_str()
.expect("failed to create path string")
),
};
let no_default_features = if !request.default_features {
"--no-default-features "
} else {
""
};
let feature_list = if request.extra_features.is_empty() {
"".to_string()
} else {
format!("--features {} ", request.extra_features.iter().join(","))
};
format!(
" \
cargo new --lib example &&
cd example &&
echo '[workspace]' >> Cargo.toml &&
cargo add {selector} {no_default_features}{feature_list}&&
"
)
}
enum CargoUpdateResult {
Success,
IoError(std::io::Error),
ErrorReturned(std::process::ExitStatus, String),
}
fn get_default_build_target_triple(config: &cargo_config2::Config) -> anyhow::Result<String> {
if let Ok(value) = std::env::var("CARGO_BUILD_TARGET") {
return Ok(value);
}
if let Some(first_build_target) = config
.build
.target
.iter()
.flat_map(|triples| triples.iter())
.next()
{
return Ok(first_build_target.triple().to_string());
}
let cmd_output = std::process::Command::new("rustc")
.args(["--print", "host-tuple"])
.output()
.context("'rustc --print host-tuple' failed, is Rust installed correctly?")?;
let mut triple = String::from_utf8(cmd_output.stdout)
.context("'rustc --print host-tuple' output is not legal UTF-8")?;
triple.truncate(triple.trim_end().len());
Ok(triple)
}
struct Flags {
target_triple: String,
rustflags: String,
rustdocflags: String,
}
fn decode_cargo_flags(encoded: String) -> String {
const SEPARATOR: char = char::from_u32(0x1f).unwrap();
if !encoded.contains(SEPARATOR) {
return encoded;
}
encoded.split(SEPARATOR).join(" ")
}
impl Flags {
fn determine_rustflags(
config: &cargo_config2::Config,
target_triple: &str,
) -> anyhow::Result<String> {
if let Ok(rustflags) = std::env::var("CARGO_BUILD_RUSTFLAGS") {
return Ok(rustflags);
}
if let Ok(encoded_rustflags) = std::env::var("CARGO_ENCODED_RUSTFLAGS") {
return Ok(decode_cargo_flags(encoded_rustflags));
}
if let Ok(rustflags) = std::env::var("RUSTFLAGS") {
return Ok(rustflags);
}
if let Some(flags) = config.rustflags(target_triple)? {
return Ok(flags.encode_space_separated()?);
}
Ok(config
.build
.rustflags
.as_ref()
.map(|flags| flags.encode_space_separated())
.transpose()?
.unwrap_or_default())
}
fn determine_rustdocflags(
config: &cargo_config2::Config,
target_triple: &str,
) -> anyhow::Result<String> {
if let Ok(rustdocflags) = std::env::var("CARGO_BUILD_RUSTDOCFLAGS") {
return Ok(rustdocflags);
}
if let Ok(encoded_rustdocflags) = std::env::var("CARGO_ENCODED_RUSTDOCFLAGS") {
return Ok(decode_cargo_flags(encoded_rustdocflags));
}
if let Ok(rustdocflags) = std::env::var("RUSTDOCFLAGS") {
return Ok(rustdocflags);
}
if let Some(flags) = config.rustdocflags(target_triple)? {
return Ok(flags.encode_space_separated()?);
}
Ok(config
.build
.rustdocflags
.as_ref()
.map(|flags| flags.encode_space_separated())
.transpose()?
.unwrap_or_default())
}
fn from_env_and_config_for_target(build_target: Option<&str>) -> anyhow::Result<Self> {
let config = cargo_config2::Config::load().context("failed to inspect cargo config")?;
let triple = match build_target {
Some(build_target) => build_target.to_owned(),
None => get_default_build_target_triple(&config)?,
};
Ok(Self {
rustflags: Self::determine_rustflags(&config, &triple)?,
rustdocflags: Self::determine_rustdocflags(&config, &triple)?,
target_triple: triple,
})
}
}
fn extract_cfg_related_flags(flags: &str) -> Vec<String> {
let tokens = flags.split_whitespace().collect::<Vec<_>>();
let mut extracted = Vec::new();
let mut index = 0usize;
while index < tokens.len() {
let token = tokens[index];
if matches!(token, "--cfg" | "--check-cfg") {
if let Some(value) = tokens.get(index + 1) {
extracted.push(token.to_owned());
extracted.push((*value).to_owned());
index += 2;
continue;
}
} else if token.starts_with("--cfg=") || token.starts_with("--check-cfg=") {
extracted.push(token.to_owned());
}
index += 1;
}
extracted
}
fn combine_witness_rustflags(mut rustflags: String, rustdocflags: &str) -> Option<String> {
let cfg_related = extract_cfg_related_flags(rustdocflags);
if cfg_related.is_empty() {
return (!rustflags.is_empty()).then_some(rustflags);
}
if !rustflags.is_empty() {
rustflags.push(' ');
}
rustflags.push_str(&cfg_related.join(" "));
Some(rustflags)
}
pub(crate) fn effective_witness_rustflags(
request: &CrateDataRequest<'_>,
) -> anyhow::Result<Option<String>> {
let flags = Flags::from_env_and_config_for_target(request.build_target())?;
Ok(combine_witness_rustflags(
flags.rustflags,
&flags.rustdocflags,
))
}
fn run_cargo_update(
crate_name: &str,
version: &str,
request: &CrateDataRequest<'_>,
placeholder_manifest_path: &Path,
settings: &GenerationSettings,
) -> CargoUpdateResult {
let mut cmd = std::process::Command::new("cargo");
cmd.stdout(std::process::Stdio::null()) .stderr(settings.stderr())
.arg("update")
.arg("--manifest-path")
.arg(placeholder_manifest_path);
cmd.arg(settings.color_flag());
let output = match cmd.output() {
Ok(output) => output,
Err(e) => return CargoUpdateResult::IoError(e),
};
if !output.status.success() {
let mut message = String::with_capacity(1024);
if settings.pass_through_stderr {
writeln!(message, "error: running 'cargo update' on crate '{crate_name}' v{version} failed, see stderr output above").expect("formatting failed");
} else {
let delimiter = "-----";
writeln!(
message,
"\
error: running 'cargo update' on crate '{crate_name}' failed with output:\n\
{delimiter}\n{}\n{delimiter}\n\
error: failed to update dependencies for crate {crate_name} v{version}",
String::from_utf8_lossy(&output.stderr)
)
.expect("formatting failed");
}
writeln!(
message,
"note: this is unlikely to be a bug in cargo-semver-checks,"
)
.expect("formatting failed");
writeln!(
message,
" and is probably an issue with the crate's Cargo.toml"
)
.expect("formatting failed");
writeln!(
message,
"note: the following command can be used to reproduce the compilation error:"
)
.expect("formatting failed");
let repro_base = produce_repro_workspace_shell_commands(request);
writeln!(message, "{repro_base}cargo update").expect("formatting failed");
return CargoUpdateResult::ErrorReturned(output.status, message);
}
CargoUpdateResult::Success
}
#[allow(clippy::too_many_arguments)]
fn run_cargo_doc(
request: &CrateDataRequest<'_>,
metadata: &cargo_metadata::Metadata,
placeholder_manifest_path: &Path,
target_dir: &Path,
crate_name: &str,
version: &str,
settings: &GenerationSettings,
build_environment: &RustdocBuildEnvironment,
callbacks: &mut CallbackHandler<'_>,
) -> Result<PathBuf, TerminalError> {
let pkg_spec = format!("{crate_name}@{version}");
callbacks.generate_rustdoc_start();
let mut cmd = std::process::Command::new("cargo");
cmd.env("RUSTC_BOOTSTRAP", "1")
.env(
"RUSTDOCFLAGS",
build_environment.cargo_rustdocflags.as_ref(),
)
.env("RUSTFLAGS", build_environment.cargo_rustflags.as_ref())
.stdout(std::process::Stdio::null()) .stderr(settings.stderr())
.arg("doc")
.arg("--manifest-path")
.arg(placeholder_manifest_path)
.arg("--target-dir")
.arg(target_dir)
.arg("--package")
.arg(pkg_spec)
.arg("--lib");
if let Some(build_target) = request.build_target {
cmd.arg("--target").arg(build_target);
}
cmd.arg("--no-deps");
cmd.arg(settings.color_flag());
let output = cmd.output()?;
if !output.status.success() {
let mut message = String::with_capacity(1024);
if settings.pass_through_stderr {
writeln!(message, "error: running cargo-doc on crate {crate_name} v{version} failed, see stderr output above").expect("formatting failed");
} else {
let delimiter = "-----";
writeln!(
message,
"error: running cargo-doc on crate '{crate_name}' failed with output:"
)
.expect("formatting failed");
writeln!(
message,
"{delimiter}\n{}\n{delimiter}\n",
String::from_utf8_lossy(&output.stderr)
)
.expect("formatting failed");
writeln!(
message,
"error: failed to build rustdoc for crate {crate_name} v{version}"
)
.expect("formatting failed");
}
writeln!(
message,
"note: this is usually due to a compilation error in the crate,"
)
.expect("formatting failed");
writeln!(
message,
" and is unlikely to be a bug in cargo-semver-checks"
)
.expect("formatting failed");
if build_environment.cargo_rustflags.contains("--cfg") {
writeln!(
message,
"note: RUSTFLAGS appears to contain '--cfg' arguments."
)
.expect("formatting failed");
writeln!(
message,
" Rustdoc only uses '--cfg' options in RUSTDOCFLAGS."
)
.expect("formatting failed");
writeln!(
message,
" Setting the same flags in RUSTDOCFLAGS may resolve the problem."
)
.expect("formatting failed");
}
writeln!(
message,
"note: the following command can be used to reproduce the error:"
)
.expect("formatting failed");
let repro_base = produce_repro_workspace_shell_commands(request);
let build_target_flag = if let Some(build_target) = request.build_target {
format!(" --target {build_target}")
} else {
String::new()
};
writeln!(
message,
"\
{repro_base}cargo check{build_target_flag} &&
cargo doc{build_target_flag}"
)
.expect("formatting failed");
return Err(TerminalError::WithAdvice(
anyhow::anyhow!(
"aborting due to failure to build rustdoc for crate {crate_name} v{version}"
),
message,
));
}
let rustdoc_dir = determine_rustdoc_dir(request, target_dir, crate_name, version)?;
let observed_stderr_but_lib_msg_not_present = if !settings.pass_through_stderr {
let stderr_output = String::from_utf8_lossy(&output.stderr);
!stderr_output.contains("ignoring invalid dependency ")
|| !stderr_output.contains(" which is missing a lib target")
} else {
false
};
let subject_crate = metadata
.packages
.iter()
.find(|dep| dep.name.as_str() == crate_name)
.ok_or_else(|| {
if !observed_stderr_but_lib_msg_not_present {
anyhow::anyhow!("crate {crate_name} v{version} has no lib target, nothing to check")
} else {
panic!(
"We declared a dependency on crate '{crate_name}', but it doesn't exist \
in the metadata and stderr didn't mention it was lacking a lib target. This is probably a bug.",
);
}
})
.into_terminal_result()?;
if let Some(lib_target) = subject_crate
.targets
.iter()
.find(|target| crate::is_lib_like_checkable_target(target))
{
let lib_name = lib_target.name.as_str();
let rustdoc_json_file_name = lib_name.replace('-', "_");
let json_path = rustdoc_dir.join(format!("{rustdoc_json_file_name}.json"));
if json_path.exists() {
callbacks.generate_rustdoc_success();
return Ok(json_path);
} else {
return Err(TerminalError::Other(anyhow::anyhow!(
"could not find expected rustdoc output for `{}`: {}",
crate_name,
json_path.display()
)));
}
}
Err(TerminalError::Other(anyhow::anyhow!(
"crate {crate_name} v{version} has no lib target, so there's nothing to check",
)))
}
fn determine_rustdoc_dir(
request: &CrateDataRequest<'_>,
target_dir: &Path,
crate_name: &str,
version: &str,
) -> Result<PathBuf, TerminalError> {
if let Some(build_target) = request.build_target {
return Ok(target_dir.join(build_target).join("doc"));
}
let build_target = {
let output = std::process::Command::new("cargo")
.env("RUSTC_BOOTSTRAP", "1")
.args([
"config",
"-Zunstable-options",
"--color=never",
"get",
"--format=json-value",
"build.target",
])
.output()?;
if output.status.success() {
serde_json::from_slice::<Option<String>>(&output.stdout)?
} else if std::str::from_utf8(&output.stderr)
.expect("non-utf8 cargo output")
.contains("config value `build.target` is not set")
{
None
} else {
let mut message = String::with_capacity(1024);
let delimiter = "-----";
writeln!(
message,
"error: running cargo-config on crate '{crate_name}' failed with output:"
)
.expect("formatting failed");
writeln!(
message,
"{delimiter}\n{}\n{delimiter}\n",
String::from_utf8_lossy(&output.stderr)
)
.expect("formatting failed");
writeln!(
message,
"error: unexpected cargo config output for crate {crate_name} v{version}\n"
)
.expect("formatting failed");
writeln!(
message,
"note: this may be a bug in cargo, or a bug in cargo-semver-checks;"
)
.expect("formatting failed");
writeln!(
message,
" if unsure, feel free to open a GitHub issue on cargo-semver-checks"
)
.expect("formatting failed");
writeln!(
message,
"note: running the following command on the crate should reproduce the error:"
)
.expect("formatting failed");
writeln!(
message,
" cargo config -Zunstable-options get --format=json-value build.target",
)
.expect("formatting failed");
return Err(TerminalError::WithAdvice(
anyhow::anyhow!(
"aborting due to cargo-config failure on crate {crate_name} v{version}"
),
message,
));
}
};
let rustdoc_dir = if let Some(build_target) = build_target {
target_dir.join(build_target).join("doc")
} else {
target_dir.join("doc")
};
Ok(rustdoc_dir)
}
fn create_placeholder_rustdoc_manifest(
request: &CrateDataRequest<'_>,
) -> anyhow::Result<cargo_toml::Manifest<()>> {
use cargo_toml::*;
Ok(Manifest::<()> {
package: {
let mut package = Package::new("placeholder", "0.0.0");
package.publish = Inheritable::Set(Publish::Flag(false));
Some(package)
},
workspace: Some(Workspace::<()>::default()),
lib: {
let product = Product {
path: Some("lib.rs".to_string()),
..Product::default()
};
Some(product)
},
dependencies: {
let project_with_features: DependencyDetail = match &request.kind {
RequestKind::Registry { .. } => DependencyDetail {
version: Some(format!("={}", request.kind.version()?)),
default_features: request.default_features,
features: request
.extra_features
.iter()
.map(ToString::to_string)
.collect(),
..DependencyDetail::default()
},
RequestKind::LocalProject(local_request) => {
DependencyDetail {
path: Some({
let dir_path = crate::manifest::get_project_dir_from_manifest_path(
&local_request.manifest.path,
)?;
dir_path
.canonicalize()
.context("failed to canonicalize manifest path")?
.to_str()
.context("manifest path is not valid UTF-8")?
.to_string()
}),
features: request
.extra_features
.iter()
.map(ToString::to_string)
.collect(),
default_features: request.default_features,
..DependencyDetail::default()
}
}
};
let mut deps = DepsSet::new();
deps.insert(
request.kind.name()?.to_string(),
Dependency::Detailed(Box::new(project_with_features)),
);
deps
},
..Default::default()
})
}
fn save_placeholder_rustdoc_manifest(
placeholder_build_dir: &Path,
placeholder_manifest: cargo_toml::Manifest<()>,
) -> anyhow::Result<PathBuf> {
fs_err::create_dir_all(placeholder_build_dir).context("failed to create build dir")?;
let placeholder_manifest_path = placeholder_build_dir.join("Cargo.toml");
let _: std::io::Result<()> = fs_err::remove_file(placeholder_build_dir.join("Cargo.lock"));
let placeholder_manifest = toml::to_string(&placeholder_manifest)?;
atomic_write(&placeholder_manifest_path, |writer| {
writer.write_all(placeholder_manifest.as_bytes())?;
Ok(())
})
.context("failed to write placeholder manifest")?;
atomic_write(placeholder_build_dir.join("lib.rs"), |_writer| Ok(()))
.context("failed to create empty lib.rs")?;
Ok(placeholder_manifest_path)
}
#[cfg(test)]
mod tests {
use super::combine_witness_rustflags;
#[test]
fn combine_witness_rustflags_preserves_existing_rustflags() {
assert_eq!(
combine_witness_rustflags("--cfg existing".to_owned(), ""),
Some("--cfg existing".to_owned())
);
}
#[test]
fn combine_witness_rustflags_appends_cfg_related_rustdocflags() {
assert_eq!(
combine_witness_rustflags(
"--cfg existing".to_owned(),
"--cfg extra --check-cfg=cfg(extra) --document-private-items"
),
Some("--cfg existing --cfg extra --check-cfg=cfg(extra)".to_owned())
);
}
#[test]
fn combine_witness_rustflags_returns_none_when_no_flags_apply() {
assert_eq!(combine_witness_rustflags(String::new(), ""), None);
}
}