use std::{
collections::HashMap,
env, fs,
io::{self, Write},
path::{Component, Path, PathBuf, absolute},
};
use cargo::{core::Workspace, ops, util::context::GlobalContext};
use once_cell::sync::Lazy;
use rustdoc_json::PackageTarget;
use rustdoc_types::Crate;
use semver::Version;
use tempfile::TempDir;
use super::target::{Entrypoint, Target};
use crate::{
error::{Result, RuskelError, convert_cargo_error},
toolchain::nightly_sysroot,
};
fn is_std_library_crate(name: &str) -> bool {
matches!(name, "std" | "core" | "alloc" | "proc_macro" | "test")
}
static STD_MODULE_MAPPING: Lazy<HashMap<&'static str, &'static str>> = Lazy::new(|| {
let mut map = HashMap::new();
map.insert("alloc", "alloc");
map.insert("borrow", "alloc");
map.insert("boxed", "alloc");
map.insert("collections", "alloc");
map.insert("rc", "alloc");
map.insert("string", "alloc");
map.insert("sync", "alloc");
map.insert("vec", "alloc");
map.insert("arch", "std");
map.insert("autodiff", "std");
map.insert("backtrace", "std");
map.insert("bstr", "std");
map.insert("env", "std");
map.insert("f128", "std");
map.insert("f16", "std");
map.insert("fs", "std");
map.insert("io", "std");
map.insert("net", "std");
map.insert("os", "std");
map.insert("pat", "std");
map.insert("path", "std");
map.insert("prelude", "std");
map.insert("process", "std");
map.insert("random", "std");
map.insert("simd", "std");
map.insert("thread", "std");
map.insert("any", "core");
map.insert("array", "core");
map.insert("ascii", "core");
map.insert("cell", "core");
map.insert("char", "core");
map.insert("clone", "core");
map.insert("cmp", "core");
map.insert("convert", "core");
map.insert("default", "core");
map.insert("error", "core");
map.insert("f32", "core");
map.insert("f64", "core");
map.insert("ffi", "core");
map.insert("fmt", "core");
map.insert("future", "core");
map.insert("hash", "core");
map.insert("hint", "core");
map.insert("i128", "core");
map.insert("i16", "core");
map.insert("i32", "core");
map.insert("i64", "core");
map.insert("i8", "core");
map.insert("isize", "core");
map.insert("iter", "core");
map.insert("marker", "core");
map.insert("mem", "core");
map.insert("num", "core");
map.insert("ops", "core");
map.insert("option", "core");
map.insert("panic", "core");
map.insert("pin", "core");
map.insert("primitive", "core");
map.insert("ptr", "core");
map.insert("result", "core");
map.insert("slice", "core");
map.insert("str", "core");
map.insert("task", "core");
map.insert("time", "core");
map.insert("u128", "core");
map.insert("u16", "core");
map.insert("u32", "core");
map.insert("u64", "core");
map.insert("u8", "core");
map.insert("usize", "core");
map
});
fn is_std_library_module(name: &str) -> bool {
STD_MODULE_MAPPING.contains_key(name)
}
fn resolve_std_reexport(target_str: &str) -> Option<String> {
if !target_str.starts_with("std::") {
return None;
}
let after_std = &target_str[5..]; let module_name = after_std.split("::").next()?;
match STD_MODULE_MAPPING.get(module_name) {
Some(&"alloc") => Some(target_str.replace("std::", "alloc::")),
Some(&"core") => Some(target_str.replace("std::", "core::")),
Some(&"std") => None, _ => None, }
}
fn load_std_library_json(crate_name: &str, display_name: Option<&str>) -> Result<Crate> {
let sysroot = nightly_sysroot()?;
let json_path = sysroot
.join("share")
.join("doc")
.join("rust")
.join("json")
.join(format!("{crate_name}.json"));
if !json_path.exists() {
return Err(RuskelError::Generate(
"Standard library documentation not available (missing rust-docs-json component)"
.to_string(),
));
}
let json_content = fs::read_to_string(&json_path)?;
let mut crate_data: Crate = serde_json::from_str(&json_content).map_err(|e| {
RuskelError::Generate(format!(
"Failed to parse standard library JSON documentation: {e}"
))
})?;
if let Some(display) = display_name
&& let Some(root_item) = crate_data.index.get_mut(&crate_data.root)
{
root_item.name = Some(display.to_string());
}
Ok(crate_data)
}
#[derive(Debug)]
struct CargoPath {
root: Option<PathBuf>,
_temp_guard: Option<TempDir>,
kind: CargoPathKind,
}
#[derive(Debug)]
enum CargoPathKind {
Filesystem,
StdLibrary {
actual: String,
display: String,
},
}
impl CargoPath {
fn from_path(path: PathBuf) -> Self {
Self {
root: Some(path),
_temp_guard: None,
kind: CargoPathKind::Filesystem,
}
}
fn from_temp_dir(temp_dir: TempDir) -> Self {
let root = temp_dir.path().to_path_buf();
Self {
root: Some(root),
_temp_guard: Some(temp_dir),
kind: CargoPathKind::Filesystem,
}
}
fn std(actual: impl Into<String>, display: impl Into<String>) -> Self {
Self {
root: None,
_temp_guard: None,
kind: CargoPathKind::StdLibrary {
actual: actual.into(),
display: display.into(),
},
}
}
fn is_std_library(&self) -> bool {
matches!(self.kind, CargoPathKind::StdLibrary { .. })
}
fn std_names(&self) -> Option<(&str, &str)> {
match &self.kind {
CargoPathKind::StdLibrary { actual, display } => {
Some((actual.as_str(), display.as_str()))
}
_ => None,
}
}
fn canonical_path(&self) -> Result<PathBuf> {
if self.is_std_library() {
return Err(RuskelError::Generate(
"Standard library crates don't have a filesystem path".to_string(),
));
}
let path = self.as_path()?;
fs::canonicalize(path).map_err(|err| {
RuskelError::Generate(format!(
"Failed to canonicalize path '{}': {err}",
path.display()
))
})
}
pub fn as_path(&self) -> Result<&Path> {
match &self.kind {
CargoPathKind::Filesystem => self.root.as_deref().ok_or_else(|| {
RuskelError::Generate("filesystem cargo path missing root directory".to_string())
}),
CargoPathKind::StdLibrary { actual, display } => Err(RuskelError::Generate(format!(
"Standard library crate '{display}' (resolved as '{actual}') does not have a filesystem path"
))),
}
}
fn manifest_dir_from_path(manifest_path: &Path, package_name: &str) -> Result<PathBuf> {
manifest_path
.parent()
.map(Path::to_path_buf)
.ok_or_else(|| {
RuskelError::Generate(format!(
"Package '{package_name}' manifest path '{}' has no parent directory",
manifest_path.display()
))
})
}
pub fn read_crate(&self, options: &CrateReadOptions) -> Result<CrateRead> {
if let Some((actual_crate, display_crate)) = self.std_names() {
let display_name = if actual_crate != display_crate {
Some(display_crate)
} else {
None
};
return Ok(CrateRead {
crate_data: load_std_library_json(actual_crate, display_name)?,
bin_target: None,
});
}
let manifest_path = self.manifest_path()?;
let PackageTargetSelection {
package_target,
bin_target,
} = select_package_target(
&manifest_path,
options.offline,
options.bin_override.as_deref(),
)?;
let include_private =
options.private_items || bin_target.as_ref().is_some_and(|target| target.is_bin_only);
let mut captured_stdout = Vec::new();
let mut captured_stderr = Vec::new();
let build_result = rustdoc_json::Builder::default()
.toolchain("nightly")
.manifest_path(manifest_path)
.package_target(package_target)
.document_private_items(include_private)
.no_default_features(options.no_default_features)
.all_features(options.all_features)
.features(&options.features)
.quiet(options.silent)
.silent(false)
.build_with_captured_output(&mut captured_stdout, &mut captured_stderr);
if !options.silent {
if !captured_stdout.is_empty() && io::stdout().write_all(&captured_stdout).is_err() {
}
if !captured_stderr.is_empty() && io::stderr().write_all(&captured_stderr).is_err() {
}
}
let json_path = build_result
.map_err(|err| map_rustdoc_build_error(&err, &captured_stderr, options.silent))?;
let json_content = fs::read_to_string(&json_path)?;
let crate_data: Crate = serde_json::from_str(&json_content).map_err(|e| {
RuskelError::Generate(format!(
"Failed to parse rustdoc JSON, which may indicate an outdated nightly toolchain - try running 'rustup update nightly':\nError: {e}"
))
})?;
Ok(CrateRead {
crate_data,
bin_target,
})
}
pub fn manifest_path(&self) -> Result<PathBuf> {
if self.is_std_library() {
return Err(RuskelError::Generate(
"Standard library crates don't have a manifest path".to_string(),
));
}
let manifest_path = self.as_path()?.join("Cargo.toml");
absolute(&manifest_path).map_err(|err| {
RuskelError::Generate(format!(
"Failed to resolve manifest path for '{}': {err}",
manifest_path.display()
))
})
}
pub fn has_manifest(&self) -> Result<bool> {
if self.is_std_library() {
return Ok(false);
}
Ok(self.as_path()?.join("Cargo.toml").exists())
}
pub fn is_package(&self) -> Result<bool> {
if self.is_std_library() {
return Ok(false);
}
Ok(self.has_manifest()? && !self.is_workspace()?)
}
pub fn is_workspace(&self) -> Result<bool> {
if self.is_std_library() {
return Ok(false);
}
if !self.has_manifest()? {
return Ok(false);
}
let manifest_path = self.manifest_path()?;
let manifest = cargo_toml::Manifest::from_path(&manifest_path)
.map_err(|err| RuskelError::ManifestParse(err.to_string()))?;
Ok(manifest.workspace.is_some() && manifest.package.is_none())
}
pub fn find_dependency(&self, dependency: &str, offline: bool) -> Result<Option<Self>> {
if self.is_std_library() {
return Ok(None);
}
let config = create_quiet_cargo_config(offline)?;
let manifest_path = self.manifest_path()?;
let workspace =
Workspace::new(&manifest_path, &config).map_err(|err| convert_cargo_error(&err))?;
let (_, ps) = ops::fetch(
&workspace,
&ops::FetchOptions {
gctx: &config,
targets: vec![],
},
)
.map_err(|err| convert_cargo_error(&err))?;
let alt_dependency = if dependency.contains('_') {
dependency.replace('_', "-")
} else {
dependency.replace('-', "_")
};
for package in ps.packages() {
let package_name = package.name().as_str();
if package_name == dependency || package_name == alt_dependency {
let manifest_dir =
Self::manifest_dir_from_path(package.manifest_path(), package_name)?;
return Ok(Some(Self::from_path(manifest_dir)));
}
}
Ok(None)
}
pub fn nearest_manifest(start_dir: &Path) -> Option<Self> {
let mut current_dir = start_dir.to_path_buf();
loop {
let manifest_path = current_dir.join("Cargo.toml");
if manifest_path.exists() {
return Some(Self::from_path(current_dir));
}
if !current_dir.pop() {
break;
}
}
None
}
fn find_workspace_package(&self, module_name: &str) -> Result<Option<ResolvedTarget>> {
let workspace_manifest_path = self.manifest_path()?;
let alt_name = if module_name.contains('_') {
module_name.replace('_', "-")
} else {
module_name.replace('-', "_")
};
let config = create_quiet_cargo_config(false)?;
let workspace = Workspace::new(&workspace_manifest_path, &config)
.map_err(|err| convert_cargo_error(&err))?;
for package in workspace.members() {
let package_name = package.name().as_str();
if package_name == module_name || package_name == alt_name {
let package_path =
Self::manifest_dir_from_path(package.manifest_path(), package_name)?;
return Ok(Some(ResolvedTarget::new(
Self::from_path(package_path),
&[],
)));
}
}
Ok(None)
}
}
fn create_quiet_cargo_config(offline: bool) -> Result<GlobalContext> {
let mut config = GlobalContext::default().map_err(|err| convert_cargo_error(&err))?;
config
.configure(
0, true, None, false, false, offline,
&None, &[], &[], )
.map_err(|err| convert_cargo_error(&err))?;
Ok(config)
}
fn select_package_target(
manifest_path: &Path,
offline: bool,
bin_override: Option<&str>,
) -> Result<PackageTargetSelection> {
let config = create_quiet_cargo_config(offline)?;
let workspace =
Workspace::new(manifest_path, &config).map_err(|err| convert_cargo_error(&err))?;
let package = workspace
.current()
.map_err(|err| convert_cargo_error(&err))?;
let has_lib = package.targets().iter().any(|target| target.is_lib());
let bin_targets: Vec<_> = package
.targets()
.iter()
.filter(|target| target.is_bin())
.collect();
let bin_names: Vec<&str> = bin_targets.iter().map(|target| target.name()).collect();
if let Some(bin_name) = bin_override {
if bin_names.contains(&bin_name) {
return Ok(PackageTargetSelection {
package_target: PackageTarget::Bin(bin_name.to_string()),
bin_target: Some(BinaryTarget {
name: bin_name.to_string(),
is_bin_only: !has_lib,
}),
});
}
let available = if bin_names.is_empty() {
"no binary targets found".to_string()
} else {
format!("available: {}", bin_names.join(", "))
};
return Err(RuskelError::Generate(format!(
"error: binary target '{bin_name}' not found in package ({available})"
)));
}
if has_lib {
return Ok(PackageTargetSelection {
package_target: PackageTarget::Lib,
bin_target: None,
});
}
if bin_names.is_empty() {
return Err(RuskelError::Generate(
"error: no library targets found in package".to_string(),
));
}
if let Some(default_run) = package.manifest().default_run()
&& bin_names.contains(&default_run)
{
return Ok(PackageTargetSelection {
package_target: PackageTarget::Bin(default_run.to_string()),
bin_target: Some(BinaryTarget {
name: default_run.to_string(),
is_bin_only: true,
}),
});
}
if bin_names.len() == 1 {
let name = bin_names[0];
return Ok(PackageTargetSelection {
package_target: PackageTarget::Bin(name.to_string()),
bin_target: Some(BinaryTarget {
name: name.to_string(),
is_bin_only: true,
}),
});
}
Err(RuskelError::Generate(format!(
"error: multiple binary targets found in package ({})",
bin_names.join(", ")
)))
}
fn generate_dummy_manifest(
dependency: &str,
version: Option<String>,
features: Option<&[&str]>,
) -> String {
let cargo_dependency = dependency.replace('_', "-");
let version_str = version.map_or("*".to_string(), |v| v);
let features_str = features.map_or(String::new(), |f| {
let feature_list = f
.iter()
.map(|feat| format!("\"{feat}\""))
.collect::<Vec<_>>()
.join(", ");
format!(", features = [{feature_list}]")
});
format!(
r#"[package]
name = "dummy-crate"
version = "0.1.0"
[dependencies]
{cargo_dependency} = {{ version = "{version_str}"{features_str} }}
"#
)
}
fn create_dummy_crate(
dependency: &str,
version: Option<String>,
features: Option<&[&str]>,
) -> Result<CargoPath> {
let temp_dir = TempDir::new()?;
let path = temp_dir.path();
let manifest_path = path.join("Cargo.toml");
let src_dir = path.join("src");
fs::create_dir_all(&src_dir)?;
let lib_rs = src_dir.join("lib.rs");
let mut file = fs::File::create(lib_rs)?;
writeln!(file, "// Dummy crate")?;
let manifest = generate_dummy_manifest(dependency, version, features);
fs::write(manifest_path, manifest)?;
Ok(CargoPath::from_temp_dir(temp_dir))
}
#[derive(Debug, Clone)]
pub struct BinaryTarget {
pub(crate) name: String,
pub(crate) is_bin_only: bool,
}
#[derive(Debug)]
pub struct CrateRead {
pub(crate) crate_data: Crate,
pub(crate) bin_target: Option<BinaryTarget>,
}
#[derive(Debug, Clone)]
pub struct CrateReadOptions {
pub(crate) no_default_features: bool,
pub(crate) all_features: bool,
pub(crate) features: Vec<String>,
pub(crate) private_items: bool,
pub(crate) silent: bool,
pub(crate) offline: bool,
pub(crate) bin_override: Option<String>,
}
#[derive(Debug)]
struct PackageTargetSelection {
package_target: PackageTarget,
bin_target: Option<BinaryTarget>,
}
#[derive(Debug)]
pub struct ResolvedTarget {
package_path: CargoPath,
pub filter: String,
}
impl ResolvedTarget {
fn new(path: CargoPath, components: &[String]) -> Self {
let filter = if components.is_empty() {
String::new()
} else {
let mut normalized_components = components.to_vec();
normalized_components[0] = to_import_name(&normalized_components[0]);
normalized_components.join("::")
};
Self {
package_path: path,
filter,
}
}
pub fn read_crate(&self, options: &CrateReadOptions) -> Result<CrateRead> {
self.package_path.read_crate(options)
}
pub fn from_target(target: Target, offline: bool) -> Result<Self> {
match target.entrypoint {
Entrypoint::Path(path) => {
if path.is_file() && path.extension().is_some_and(|ext| ext == "rs") {
Self::from_rust_file(path, &target.path)
} else {
let cargo_path = CargoPath::from_path(path.clone());
let cargo_path = CargoPath::from_path(cargo_path.canonical_path()?);
if cargo_path.is_package()? {
Ok(Self::new(cargo_path, &target.path))
} else if cargo_path.is_workspace()? {
if target.path.is_empty() {
Err(RuskelError::InvalidTarget(
"No package specified in workspace".to_string(),
))
} else {
let package_name = &target.path[0];
if let Some(package) =
cargo_path.find_workspace_package(package_name)?
{
Ok(Self::new(package.package_path, &target.path[1..]))
} else {
Err(RuskelError::ModuleNotFound(format!(
"Package '{package_name}' not found in workspace"
)))
}
}
} else {
Err(RuskelError::InvalidTarget(format!(
"Path '{}' is neither a package nor a workspace",
path.display()
)))
}
}
}
Entrypoint::Name { name, version } => {
if is_std_library_crate(&name) {
return Ok(Self::new(CargoPath::std(name.clone(), name), &target.path));
}
if is_std_library_module(&name) {
return Err(RuskelError::InvalidTarget(format!(
"'{name}' appears to be a standard library module. Use the full path like 'std::{name}'"
)));
}
let current_dir = env::current_dir()?;
match CargoPath::nearest_manifest(¤t_dir) {
Some(root) => {
if let Some(workspace_member) = root.find_workspace_package(&name)? {
let Self { package_path, .. } = workspace_member;
return Ok(Self::new(package_path, &target.path));
}
if let Some(dependency) = root.find_dependency(&name, offline)? {
Ok(Self::new(dependency, &target.path))
} else {
Self::from_dummy_crate(&name, version, &target.path, offline)
}
}
None => Self::from_dummy_crate(&name, version, &target.path, offline),
}
}
}
}
fn from_rust_file(file_path: PathBuf, additional_path: &[String]) -> Result<Self> {
let file_path = fs::canonicalize(file_path)?;
let mut current_dir = file_path
.parent()
.ok_or_else(|| RuskelError::InvalidTarget("Invalid file path".to_string()))?
.to_path_buf();
while !current_dir.join("Cargo.toml").exists() {
if !current_dir.pop() {
return Err(RuskelError::ManifestNotFound);
}
}
let cargo_path = CargoPath::from_path(current_dir.clone());
let relative_path = file_path.strip_prefix(¤t_dir).map_err(|_| {
RuskelError::InvalidTarget("Failed to determine relative path".to_string())
})?;
let mut components: Vec<_> = relative_path
.components()
.filter_map(|c| {
if let Component::Normal(os_str) = c {
os_str.to_str().map(String::from)
} else {
None
}
})
.collect();
if components.first().is_some_and(|c| c == "src") {
components.remove(0);
}
if let Some(file_name) = components.pop()
&& let Some(stem) = Path::new(&file_name).file_stem().and_then(|s| s.to_str())
{
components.push(stem.to_string());
}
components.extend_from_slice(additional_path);
Ok(Self::new(cargo_path, &components))
}
fn from_dummy_crate(
name: &str,
version: Option<Version>,
path: &[String],
offline: bool,
) -> Result<Self> {
let version_str = version.map(|v| v.to_string());
let dummy = create_dummy_crate(name, version_str, None)?;
match dummy.find_dependency(name, offline) {
Ok(Some(dependency_path)) => Ok(Self::new(dependency_path, path)),
Ok(None) => Err(RuskelError::ModuleNotFound(format!(
"Dependency '{name}' not found in dummy crate"
))),
Err(err) => {
if offline {
match err {
RuskelError::DependencyNotFound => Err(RuskelError::Generate(format!(
"crate '{name}' is not cached locally for offline use. Run 'cargo fetch {name}' without --offline first or retry without --offline."
))),
RuskelError::Cargo(message)
if message.contains("--offline")
|| message.contains("offline mode") =>
{
Err(RuskelError::Generate(format!(
"crate '{name}' is unavailable in offline mode: {message}"
)))
}
other => Err(other),
}
} else {
Err(err)
}
}
}
}
}
pub fn resolve_target(target_str: &str, offline: bool) -> Result<ResolvedTarget> {
let (resolved_target_str, original_crate) =
if let Some(mapped) = resolve_std_reexport(target_str) {
let original = target_str.split("::").next().unwrap_or("std");
(mapped, Some(original.to_string()))
} else {
(target_str.to_string(), None)
};
let target = Target::parse(&resolved_target_str)?;
match &target.entrypoint {
Entrypoint::Path(_) => ResolvedTarget::from_target(target, offline),
Entrypoint::Name { name, version } => {
if is_std_library_crate(name) {
let display_name = original_crate.as_ref().unwrap_or(name).to_string();
return Ok(ResolvedTarget::new(
CargoPath::std(name.to_string(), display_name),
&target.path,
));
}
if is_std_library_module(name) {
return Err(RuskelError::InvalidTarget(format!(
"'{name}' appears to be a standard library module. Use the full path like 'std::{name}'"
)));
}
if version.is_some() {
ResolvedTarget::from_dummy_crate(name, version.clone(), &target.path, offline)
} else {
let resolved = ResolvedTarget::from_target(target.clone(), offline)?;
if let Some(first_component) = resolved
.filter
.split("::")
.next()
.filter(|component| !component.is_empty())
&& let Some(cp) = resolved
.package_path
.find_dependency(first_component, offline)?
{
return Ok(ResolvedTarget::new(cp, &target.path));
}
Ok(resolved)
}
}
}
}
fn to_import_name(package_name: &str) -> String {
package_name.replace('-', "_")
}
const MAX_STDERR_CHARS: usize = 8_192;
fn map_rustdoc_build_error(
err: &rustdoc_json::BuildError,
captured_stderr: &[u8],
silent: bool,
) -> RuskelError {
match err {
rustdoc_json::BuildError::BuildRustdocJsonError => {
format_rustdoc_failure(captured_stderr, silent)
}
other => {
let err_msg = other.to_string();
if err_msg.contains("no library targets found in package") {
return RuskelError::Generate(
"error: no library targets found in package".to_string(),
);
}
if err_msg.contains("toolchain") && err_msg.contains("is not installed") {
return RuskelError::Generate(
"ruskel requires the nightly toolchain to be installed - run 'rustup toolchain install nightly'"
.to_string(),
);
}
if err_msg.contains("Failed to build rustdoc JSON") {
return format_rustdoc_failure(captured_stderr, silent);
}
RuskelError::Generate(format!("Failed to build rustdoc JSON: {err_msg}"))
}
}
}
fn format_rustdoc_failure(captured_stderr: &[u8], silent: bool) -> RuskelError {
let stderr_raw = String::from_utf8_lossy(captured_stderr).into_owned();
let stderr_trimmed = stderr_raw.trim();
let summary = extract_primary_diagnostic(stderr_trimmed).unwrap_or_else(|| {
"rustdoc exited with an error; rerun with --verbose for full diagnostics.".to_string()
});
let summary = summary.trim();
if silent {
if stderr_trimmed.is_empty() {
return RuskelError::Generate(
"Failed to build rustdoc JSON: rustdoc exited with an error but emitted no diagnostics. \
Re-run with --verbose or `cargo rustdoc` to inspect the failure.".to_string(),
);
}
let (diagnostics, truncated) = truncate_diagnostics(stderr_trimmed);
let mut message = format!("Failed to build rustdoc JSON: {summary}");
message.push_str("\n\nrustdoc stderr:\n");
message.push_str(&diagnostics);
if truncated {
message.push_str("\n… output truncated …");
}
return RuskelError::Generate(message);
}
RuskelError::Generate(format!("Failed to build rustdoc JSON: {summary}"))
}
fn extract_primary_diagnostic(stderr: &str) -> Option<String> {
let mut lines = stderr.lines().peekable();
while let Some(line) = lines.next() {
if !is_primary_error_line(line) {
continue;
}
let mut snippet = vec![line.trim_end().to_string()];
while let Some(peek) = lines.peek() {
let trimmed = peek.trim_end();
if trimmed.is_empty() {
lines.next();
break;
}
let trimmed_start = trimmed.trim_start_matches(' ');
let is_line_number_block = trimmed.contains('|')
&& trimmed
.split_once('|')
.map(|(prefix, _)| prefix.trim().chars().all(|c| c.is_ascii_digit()))
.unwrap_or(false);
let is_context_line = peek.starts_with(' ')
|| peek.starts_with('\t')
|| peek.starts_with('|')
|| trimmed_start.starts_with("-->")
|| trimmed_start.starts_with("note:")
|| trimmed_start.starts_with("help:")
|| trimmed_start.starts_with("warning:")
|| trimmed_start.starts_with("= note:")
|| trimmed_start.starts_with("= help:")
|| trimmed_start.starts_with("= warning:")
|| is_line_number_block;
if !is_context_line {
break;
}
if let Some(next_line) = lines.next() {
snippet.push(next_line.trim_end().to_string());
} else {
break;
}
}
return Some(snippet.join("\n"));
}
None
}
fn is_primary_error_line(line: &str) -> bool {
let trimmed = line.trim();
if let Some(body) = trimmed.strip_prefix("error[") {
return body.contains(']');
}
if let Some(body) = trimmed.strip_prefix("error:") {
let body = body.trim_start();
return !(body.starts_with("Compilation failed")
|| body.starts_with("could not compile")
|| body.starts_with("could not document"));
}
false
}
fn truncate_diagnostics(stderr: &str) -> (String, bool) {
let mut buffer = String::new();
let mut truncated = false;
for (idx, ch) in stderr.chars().enumerate() {
if idx >= MAX_STDERR_CHARS {
truncated = true;
break;
}
buffer.push(ch);
}
(buffer, truncated)
}
#[cfg(test)]
mod tests {
use std::{
env,
ffi::OsString,
fs,
path::{Path, PathBuf},
sync::{Mutex, MutexGuard},
};
use once_cell::sync::Lazy;
use pretty_assertions::assert_eq;
use tempfile::tempdir;
use super::*;
#[test]
fn primary_diagnostic_extracts_compiler_error() -> Result<()> {
let stderr = r#"
error: expected pattern, found `=`
--> src/lib.rs:3:9
|
3 | let = left + right;
| ^ expected pattern
error: Compilation failed, aborting rustdoc
"#;
let diagnostic = extract_primary_diagnostic(stderr).ok_or_else(|| {
RuskelError::Generate("failed to find primary diagnostic".to_string())
})?;
assert!(diagnostic.contains("expected pattern"));
assert!(diagnostic.contains("src/lib.rs:3:9"));
assert!(!diagnostic.contains("Compilation failed"));
Ok(())
}
#[test]
fn format_rustdoc_failure_includes_diagnostics_when_silent() {
let stderr = b"error: expected pattern, found `=`\n --> src/lib.rs:3:9\n |\n3 | let = left + right;\n | ^ expected pattern\n";
let message = format_rustdoc_failure(stderr, true).to_string();
assert!(message.contains("Failed to build rustdoc JSON"));
assert!(message.contains("expected pattern"));
assert!(message.contains("src/lib.rs:3:9"));
assert!(message.contains("rustdoc stderr"));
}
struct DirGuard {
original: PathBuf,
}
impl DirGuard {
fn change_to(path: &Path) -> Result<Self> {
let original = env::current_dir()?;
env::set_current_dir(path)?;
Ok(Self { original })
}
}
impl Drop for DirGuard {
fn drop(&mut self) {
if let Err(err) = env::set_current_dir(&self.original) {
panic!("failed to restore current directory: {err}");
}
}
}
static ENV_LOCK: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
struct EnvVarGuard {
key: &'static str,
original: Option<OsString>,
_guard: MutexGuard<'static, ()>,
}
impl EnvVarGuard {
fn set_path(key: &'static str, value: &Path) -> Self {
let guard = ENV_LOCK.lock().expect("env mutex poisoned");
let original = env::var_os(key);
unsafe { env::set_var(key, value) };
Self {
key,
original,
_guard: guard,
}
}
}
impl Drop for EnvVarGuard {
fn drop(&mut self) {
match &self.original {
Some(value) => unsafe { env::set_var(self.key, value) },
None => unsafe { env::remove_var(self.key) },
}
}
}
#[test]
fn test_to_import_name() {
assert_eq!(to_import_name("serde"), "serde");
assert_eq!(to_import_name("serde-json"), "serde_json");
assert_eq!(to_import_name("tokio-util"), "tokio_util");
assert_eq!(
to_import_name("my-hyphenated-package"),
"my_hyphenated_package"
);
}
#[test]
fn test_generate_dummy_manifest() {
let manifest = generate_dummy_manifest("serde", None, None);
assert!(manifest.contains("serde = { version = \"*\" }"));
assert!(!manifest.contains("features"));
let manifest = generate_dummy_manifest("tokio", Some("1.0".to_string()), Some(&["rt"]));
assert!(manifest.contains("tokio = { version = \"1.0\", features = [\"rt\"] }"));
let manifest = generate_dummy_manifest("tokio", None, Some(&["rt", "macros", "test-util"]));
assert!(manifest.contains(
"tokio = { version = \"*\", features = [\"rt\", \"macros\", \"test-util\"] }"
));
let manifest = generate_dummy_manifest("serde", None, Some(&["derive", "std"]));
assert!(manifest.contains("[dependencies]"));
assert!(manifest.contains("serde = { version = \"*\", features = [\"derive\", \"std\"] }"));
}
#[test]
fn test_generate_dummy_manifest_with_underscores() {
let manifest = generate_dummy_manifest("serde_json", None, None);
assert!(manifest.contains("serde-json = { version = \"*\" }"));
assert!(!manifest.contains("serde_json"));
let manifest = generate_dummy_manifest("async-trait", None, None);
assert!(manifest.contains("async-trait = { version = \"*\" }"));
let manifest =
generate_dummy_manifest("my_complex_crate_name", Some("0.1.0".to_string()), None);
assert!(manifest.contains("my-complex-crate-name = { version = \"0.1.0\" }"));
}
#[test]
fn test_create_dummy_crate() -> Result<()> {
let cargo_path = create_dummy_crate("serde", None, None)?;
let path = cargo_path.as_path()?;
assert!(path.join("Cargo.toml").exists());
let manifest_content = fs::read_to_string(path.join("Cargo.toml"))?;
assert!(manifest_content.contains("[dependencies]"));
assert!(manifest_content.contains("serde = { version = \"*\""));
Ok(())
}
#[test]
fn test_create_dummy_crate_with_features() -> Result<()> {
let cargo_path = create_dummy_crate("serde", Some("1.0".to_string()), Some(&["derive"]))?;
let path = cargo_path.as_path()?;
assert!(path.join("Cargo.toml").exists());
let manifest_content = fs::read_to_string(path.join("Cargo.toml"))?;
assert!(manifest_content.contains("[dependencies]"));
assert!(
manifest_content.contains("serde = { version = \"1.0\", features = [\"derive\"] }")
);
Ok(())
}
#[test]
fn test_is_workspace() -> Result<()> {
let temp_dir = tempdir()?;
let cargo_path = CargoPath::from_path(temp_dir.path().to_path_buf());
let manifest = r#"
[workspace]
members = ["member1", "member2"]
"#;
let manifest_path = cargo_path.manifest_path()?;
fs::write(&manifest_path, manifest)?;
assert!(cargo_path.is_workspace()?);
fs::write(
&manifest_path,
r#"
[package]
name = "test-crate"
version = "0.1.0"
"#,
)?;
assert!(!cargo_path.is_workspace()?);
Ok(())
}
#[test]
fn test_find_workspace_package() -> Result<()> {
let temp_dir = tempdir()?;
let manifest = r#"
[workspace]
members = ["member1", "member2"]
"#;
fs::write(temp_dir.path().join("Cargo.toml"), manifest)?;
let member1_dir = temp_dir.path().join("member1");
fs::create_dir(&member1_dir)?;
fs::create_dir(member1_dir.join("src"))?;
let member1_manifest = r#"
[package]
name = "member1"
version = "0.1.0"
[features]
default = []
feature1 = []
"#;
fs::write(member1_dir.join("Cargo.toml"), member1_manifest)?;
fs::write(member1_dir.join("src").join("lib.rs"), "// member1 lib.rs")?;
let member2_dir = temp_dir.path().join("member2");
fs::create_dir(&member2_dir)?;
fs::create_dir(member2_dir.join("src"))?;
let member2_manifest = r#"
[package]
name = "member2"
version = "0.2.0"
"#;
fs::write(member2_dir.join("Cargo.toml"), member2_manifest)?;
fs::write(member2_dir.join("src").join("lib.rs"), "// member2 lib.rs")?;
let cargo_path = CargoPath::from_path(temp_dir.path().to_path_buf());
if let Some(resolved) = cargo_path.find_workspace_package("member1")? {
assert_eq!(resolved.package_path.as_path()?, member1_dir);
assert_eq!(resolved.filter, "");
} else {
panic!("Failed to find package in the workspace");
}
if let Some(resolved) = cargo_path.find_workspace_package("member2")? {
assert_eq!(resolved.package_path.as_path()?, member2_dir);
assert_eq!(resolved.filter, "");
} else {
panic!("Failed to find package in the workspace");
}
assert!(
cargo_path
.find_workspace_package("non-existent-package")?
.is_none()
);
Ok(())
}
#[test]
fn test_resolve_name_prefers_workspace_members() -> Result<()> {
let temp_dir = tempdir()?;
let workspace_root = temp_dir.path().join("workspace");
let localcrate_dir = workspace_root.join("localcrate");
fs::create_dir_all(localcrate_dir.join("src"))?;
fs::write(
workspace_root.join("Cargo.toml"),
r#"
[workspace]
members = ["localcrate"]
"#,
)?;
fs::write(
localcrate_dir.join("Cargo.toml"),
r#"
[package]
name = "localcrate"
version = "0.1.0"
"#,
)?;
fs::write(localcrate_dir.join("src/lib.rs"), "// localcrate lib")?;
let _guard = DirGuard::change_to(&workspace_root)?;
let resolved = resolve_target("localcrate", true)?;
let ResolvedTarget {
package_path,
filter,
} = resolved;
let path = package_path.canonical_path()?;
let expected = fs::canonicalize(&localcrate_dir)?;
assert_eq!(path, expected);
assert!(filter.is_empty());
Ok(())
}
#[test]
fn test_offline_dummy_crate_error_message() -> Result<()> {
let temp_dir = tempdir()?;
let _cargo_home_guard = EnvVarGuard::set_path("CARGO_HOME", temp_dir.path());
let path: Vec<String> = Vec::new();
match ResolvedTarget::from_dummy_crate("serde", None, &path, true) {
Err(err) => {
let message = err.to_string();
assert!(
message.contains("not cached locally for offline use"),
"{message}"
);
}
Ok(_) => panic!("Expected offline resolution to fail"),
}
Ok(())
}
fn setup_test_structure() -> TempDir {
let temp_dir = TempDir::new().unwrap();
let root = temp_dir.path();
fs::create_dir_all(root.join("workspace/pkg1/src")).unwrap();
fs::create_dir_all(root.join("workspace/pkg2/src")).unwrap();
fs::write(
root.join("workspace/Cargo.toml"),
r#"
[workspace]
members = ["pkg1", "pkg2"]
"#,
)
.unwrap();
fs::write(
root.join("workspace/pkg1/Cargo.toml"),
r#"
[package]
name = "pkg1"
version = "0.1.0"
"#,
)
.unwrap();
fs::write(root.join("workspace/pkg1/src/lib.rs"), "// pkg1 lib").unwrap();
fs::write(root.join("workspace/pkg1/src/module.rs"), "// pkg1 module").unwrap();
fs::write(
root.join("workspace/pkg2/Cargo.toml"),
r#"
[package]
name = "pkg2"
version = "0.1.0"
[dependencies]
"#,
)
.unwrap();
fs::write(root.join("workspace/pkg2/src/lib.rs"), "// pkg2 lib").unwrap();
fs::create_dir_all(root.join("standalone/src")).unwrap();
fs::write(
root.join("standalone/Cargo.toml"),
r#"
[package]
name = "standalone"
version = "0.1.0"
"#,
)
.unwrap();
fs::write(root.join("standalone/src/lib.rs"), "// standalone lib").unwrap();
fs::write(
root.join("standalone/src/module.rs"),
"// standalone module",
)
.unwrap();
temp_dir
}
enum ExpectedResult {
Path(PathBuf),
}
#[test]
fn test_is_std_library_crate() {
assert!(is_std_library_crate("std"));
assert!(is_std_library_crate("core"));
assert!(is_std_library_crate("alloc"));
assert!(is_std_library_crate("proc_macro"));
assert!(is_std_library_crate("test"));
assert!(!is_std_library_crate("serde"));
assert!(!is_std_library_crate("tokio"));
assert!(!is_std_library_crate("random_crate"));
}
#[test]
fn test_resolve_std_reexport() {
assert_eq!(
resolve_std_reexport("std::rc"),
Some("alloc::rc".to_string())
);
assert_eq!(
resolve_std_reexport("std::rc::Rc"),
Some("alloc::rc::Rc".to_string())
);
assert_eq!(
resolve_std_reexport("std::vec::Vec"),
Some("alloc::vec::Vec".to_string())
);
assert_eq!(
resolve_std_reexport("std::collections::HashMap"),
Some("alloc::collections::HashMap".to_string())
);
assert_eq!(
resolve_std_reexport("std::mem"),
Some("core::mem".to_string())
);
assert_eq!(
resolve_std_reexport("std::mem::size_of"),
Some("core::mem::size_of".to_string())
);
assert_eq!(
resolve_std_reexport("std::option::Option"),
Some("core::option::Option".to_string())
);
assert_eq!(resolve_std_reexport("std::fs"), None);
assert_eq!(resolve_std_reexport("std::io"), None);
assert_eq!(resolve_std_reexport("std::net"), None);
assert_eq!(resolve_std_reexport("alloc::rc"), None);
assert_eq!(resolve_std_reexport("core::mem"), None);
assert_eq!(resolve_std_reexport("serde::Deserialize"), None);
}
#[test]
fn test_is_std_library_module() {
assert!(is_std_library_module("rc"));
assert!(is_std_library_module("vec"));
assert!(is_std_library_module("collections"));
assert!(is_std_library_module("sync"));
assert!(is_std_library_module("io"));
assert!(is_std_library_module("mem"));
assert!(is_std_library_module("ptr"));
assert!(!is_std_library_module("serde"));
assert!(!is_std_library_module("tokio"));
assert!(!is_std_library_module("reqwest"));
}
fn assert_std_target(
target: &str,
expected_actual: &str,
expected_display: &str,
expected_filter: &str,
) {
let result = resolve_target(target, true).unwrap();
match result.package_path.std_names() {
Some((actual, display)) => {
assert_eq!(actual, expected_actual);
assert_eq!(display, expected_display);
}
None => panic!("Expected StdLibrary variant for {target}"),
}
assert_eq!(result.filter, expected_filter);
}
fn assert_std_module_error(module: &str, suggestion: &str) {
match resolve_target(module, true) {
Err(err) => {
let message = err.to_string();
assert!(message.contains("appears to be a standard library module"));
assert!(message.contains(suggestion));
}
Ok(_) => panic!(
"'{module}' should have failed with an error about being a std library module"
),
}
}
#[test]
fn test_std_library_resolve() {
assert_std_target("std", "std", "std", "");
assert_std_target("std::vec::Vec", "alloc", "std", "vec::Vec");
assert_std_target("core::mem", "core", "core", "mem");
assert_std_module_error("rc", "std::rc");
assert_std_target("std::rc::Rc", "alloc", "std", "rc::Rc");
assert_std_target("alloc::rc::Rc", "alloc", "alloc", "rc::Rc");
assert_std_target("std::mem", "core", "std", "mem");
}
#[test]
fn test_from_target() {
let temp_dir = setup_test_structure();
let root = temp_dir.path();
let test_cases = vec![
(
Target {
entrypoint: Entrypoint::Path(root.join("workspace/pkg1")),
path: vec![],
},
ExpectedResult::Path(root.join("workspace/pkg1")),
vec![],
),
(
Target {
entrypoint: Entrypoint::Path(root.join("workspace/pkg1")),
path: vec!["module".to_string()],
},
ExpectedResult::Path(root.join("workspace/pkg1")),
vec!["module".to_string()],
),
(
Target {
entrypoint: Entrypoint::Path(root.join("workspace")),
path: vec!["pkg2".to_string()],
},
ExpectedResult::Path(root.join("workspace/pkg2")),
vec![],
),
(
Target {
entrypoint: Entrypoint::Path(root.join("workspace/pkg1/src/module.rs")),
path: vec![],
},
ExpectedResult::Path(root.join("workspace/pkg1")),
vec!["module".to_string()],
),
(
Target {
entrypoint: Entrypoint::Path(root.join("standalone")),
path: vec!["module".to_string()],
},
ExpectedResult::Path(root.join("standalone")),
vec!["module".to_string()],
),
];
for (i, (target, expected_result, expected_filter)) in test_cases.into_iter().enumerate() {
let result = ResolvedTarget::from_target(target, true);
match (result, expected_result) {
(Ok(resolved), ExpectedResult::Path(expected)) => {
let resolved_path = resolved
.package_path
.canonical_path()
.unwrap_or_else(|err| panic!("Test case {i} failed: {err}"));
let expected_path = fs::canonicalize(expected).unwrap();
assert_eq!(
resolved_path, expected_path,
"Test case {} failed: package_path mismatch",
i
);
assert_eq!(
resolved.filter,
expected_filter.join("::"),
"Test case {} failed: filter mismatch",
i
);
}
(Err(e), _) => {
panic!("Test case {i} failed: expected Ok, but got error '{e}'");
}
}
}
}
}