use std::path::{Path, PathBuf};
use std::process::Command;
use crate::cli::FeatureFlags;
use crate::error::{GroxError, Result};
use crate::types::is_stdlib_crate;
pub(crate) fn get_sysroot() -> Result<PathBuf> {
let output = Command::new("rustc")
.args(["+nightly", "--print", "sysroot"])
.output()
.map_err(|_| GroxError::NightlyNotAvailable)?;
if !output.status.success() {
return Err(GroxError::NightlyNotAvailable);
}
let sysroot = String::from_utf8_lossy(&output.stdout).trim().to_string();
let path = PathBuf::from(&sysroot);
if path.exists() {
Ok(path)
} else {
Err(GroxError::NightlyNotAvailable)
}
}
pub(crate) fn stdlib_library_path(sysroot: &Path) -> Result<PathBuf> {
let library_path = sysroot.join("lib/rustlib/src/rust/library");
if library_path.exists() {
Ok(library_path)
} else {
Err(GroxError::StdLibSourceMissing)
}
}
pub(crate) fn get_toolchain_hash() -> Result<String> {
let output = Command::new("rustc")
.args(["+nightly", "--version", "--verbose"])
.output()
.map_err(|_| GroxError::NightlyNotAvailable)?;
if !output.status.success() {
return Err(GroxError::NightlyNotAvailable);
}
let verbose = String::from_utf8_lossy(&output.stdout);
Ok(parse_toolchain_hash(&verbose))
}
fn parse_toolchain_hash(verbose_output: &str) -> String {
for line in verbose_output.lines() {
if let Some(hash) = line.strip_prefix("commit-hash: ") {
let hash = hash.trim();
if !hash.is_empty() {
return hash.to_string();
}
}
}
let first_line = verbose_output.lines().next().unwrap_or("unknown");
format!("{:016x}", djb2_hash(first_line))
}
fn djb2_hash(s: &str) -> u64 {
let mut hash: u64 = 5381;
for byte in s.bytes() {
hash = hash.wrapping_mul(33).wrapping_add(u64::from(byte));
}
hash
}
fn stdlib_cache_dir() -> Result<PathBuf> {
dirs::cache_dir()
.map(|d| d.join("groxide").join("stdlib"))
.ok_or_else(|| GroxError::Io(std::io::Error::other("could not determine cache directory")))
}
fn stdlib_target_dir(crate_name: &str, toolchain_hash: &str) -> Result<PathBuf> {
let cache_dir = stdlib_cache_dir()?;
Ok(cache_dir.join(format!("target-{crate_name}-{toolchain_hash}")))
}
pub(crate) fn generate_stdlib_json(
crate_name: &str,
features: &FeatureFlags,
private: bool,
) -> Result<String> {
if !is_stdlib_crate(crate_name) {
return Err(GroxError::RustdocFailed {
stderr: format!("'{crate_name}' is not a recognized stdlib crate"),
});
}
let sysroot = get_sysroot()?;
let library_path = stdlib_library_path(&sysroot)?;
let manifest_path = library_path.join(crate_name).join("Cargo.toml");
if !manifest_path.exists() {
return Err(GroxError::StdLibSourceMissing);
}
let toolchain_hash = get_toolchain_hash()?;
let target_dir = stdlib_target_dir(crate_name, &toolchain_hash)?;
std::fs::create_dir_all(
target_dir
.parent()
.expect("invariant: target_dir has parent"),
)
.map_err(GroxError::Io)?;
let effective_features = if features.is_default() {
FeatureFlags {
all_features: false,
no_default_features: false,
features: Vec::new(),
}
} else {
FeatureFlags {
all_features: false,
no_default_features: features.no_default_features,
features: features.features.clone(),
}
};
let normalized = crate_name.replace('-', "_");
let json_path = target_dir.join("doc").join(format!("{normalized}.json"));
crate::docgen::run_cargo_and_read_json(&target_dir, &json_path, || {
let cmd =
build_stdlib_rustdoc_command(&manifest_path, &target_dir, &effective_features, private);
run_rustdoc_command(cmd)
})
}
fn build_stdlib_rustdoc_command(
manifest_path: &Path,
target_dir: &Path,
features: &FeatureFlags,
private: bool,
) -> Command {
let mut cmd = Command::new("cargo");
cmd.arg("+nightly").arg("rustdoc");
cmd.arg("--lib");
cmd.arg("--manifest-path").arg(manifest_path);
cmd.arg("--target-dir").arg(target_dir);
if features.all_features {
cmd.arg("--all-features");
}
if features.no_default_features {
cmd.arg("--no-default-features");
}
if !features.features.is_empty() {
cmd.arg("--features").arg(features.features.join(","));
}
cmd.arg("--output-format").arg("json");
cmd.arg("-Z").arg("unstable-options");
if private {
cmd.arg("--").arg("--document-private-items");
}
cmd
}
fn run_rustdoc_command(mut cmd: Command) -> Result<()> {
let output = cmd.output().map_err(|e| GroxError::RustdocFailed {
stderr: format!("failed to execute cargo rustdoc: {e}"),
})?;
if output.status.success() {
Ok(())
} else {
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
Err(GroxError::RustdocFailed { stderr })
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn get_sysroot_returns_existing_path_when_nightly_installed() {
let result = get_sysroot();
if result.is_err() {
eprintln!("SKIP: nightly toolchain not installed");
return;
}
let sysroot = result.expect("already checked");
assert!(sysroot.exists(), "sysroot path should exist: {sysroot:?}");
assert!(
sysroot.is_dir(),
"sysroot should be a directory: {sysroot:?}"
);
}
#[test]
fn get_sysroot_path_contains_nightly() {
let result = get_sysroot();
if result.is_err() {
eprintln!("SKIP: nightly toolchain not installed");
return;
}
let sysroot = result.expect("already checked");
let sysroot_str = sysroot.to_str().expect("valid utf8");
assert!(
sysroot_str.contains("nightly"),
"sysroot should contain 'nightly': {sysroot_str}"
);
}
#[test]
fn stdlib_library_path_returns_path_when_rust_src_installed() {
let Ok(sysroot) = get_sysroot() else {
eprintln!("SKIP: nightly toolchain not installed");
return;
};
let result = stdlib_library_path(&sysroot);
match result {
Ok(path) => {
assert!(path.exists(), "library path should exist: {path:?}");
assert!(path.join("std").exists(), "should contain std directory");
assert!(path.join("core").exists(), "should contain core directory");
assert!(
path.join("alloc").exists(),
"should contain alloc directory"
);
}
Err(_) => {
eprintln!("SKIP: rust-src component not installed");
}
}
}
#[test]
fn stdlib_library_path_returns_error_for_nonexistent_sysroot() {
let result = stdlib_library_path(Path::new("/nonexistent/sysroot"));
assert!(result.is_err());
assert!(
matches!(result.unwrap_err(), GroxError::StdLibSourceMissing),
"should return StdLibSourceMissing"
);
}
#[test]
fn get_toolchain_hash_returns_nonempty_string() {
let result = get_toolchain_hash();
if result.is_err() {
eprintln!("SKIP: nightly toolchain not installed");
return;
}
let hash = result.expect("already checked");
assert!(!hash.is_empty(), "toolchain hash should not be empty");
}
#[test]
fn get_toolchain_hash_returns_hex_commit_hash() {
let result = get_toolchain_hash();
if result.is_err() {
eprintln!("SKIP: nightly toolchain not installed");
return;
}
let hash = result.expect("already checked");
assert!(
hash.len() == 40 || hash.len() == 16,
"hash should be 40 (commit) or 16 (djb2) chars: got {} chars = '{hash}'",
hash.len()
);
assert!(
hash.chars().all(|c| c.is_ascii_hexdigit()),
"hash should be hex: '{hash}'"
);
}
#[test]
fn parse_toolchain_hash_extracts_commit_hash() {
let verbose = "rustc 1.83.0-nightly (90b35a623 2024-11-26)\n\
binary: rustc\n\
commit-hash: 90b35a6239c3d8bdabc530a6a0816f7ff89a0aaf\n\
commit-date: 2024-11-26\n\
host: aarch64-apple-darwin\n\
release: 1.83.0-nightly\n";
let hash = parse_toolchain_hash(verbose);
assert_eq!(hash, "90b35a6239c3d8bdabc530a6a0816f7ff89a0aaf");
}
#[test]
fn parse_toolchain_hash_falls_back_to_djb2_when_no_commit_hash() {
let verbose = "rustc 1.83.0-nightly\nbinary: rustc\nhost: aarch64-apple-darwin\n";
let hash = parse_toolchain_hash(verbose);
assert_eq!(hash.len(), 16, "DJB2 hash should be 16 hex chars");
assert!(
hash.chars().all(|c| c.is_ascii_hexdigit()),
"should be hex: '{hash}'"
);
}
#[test]
fn parse_toolchain_hash_falls_back_for_empty_commit_hash() {
let verbose = "rustc 1.83.0-nightly\ncommit-hash: \nhost: aarch64-apple-darwin\n";
let hash = parse_toolchain_hash(verbose);
assert_eq!(
hash.len(),
16,
"should fall back to DJB2 for empty commit-hash"
);
}
#[test]
fn parse_toolchain_hash_deterministic() {
let verbose = "rustc 1.83.0-nightly (90b35a623 2024-11-26)\n\
commit-hash: 90b35a6239c3d8bdabc530a6a0816f7ff89a0aaf\n";
let hash1 = parse_toolchain_hash(verbose);
let hash2 = parse_toolchain_hash(verbose);
assert_eq!(hash1, hash2, "should be deterministic");
}
#[test]
fn djb2_hash_produces_consistent_results() {
assert_eq!(djb2_hash("test"), djb2_hash("test"));
}
#[test]
fn djb2_hash_differs_for_different_inputs() {
assert_ne!(djb2_hash("test"), djb2_hash("other"));
}
#[test]
fn djb2_hash_works_on_empty_string() {
assert_eq!(djb2_hash(""), 5381);
}
#[test]
fn build_stdlib_command_includes_manifest_path_and_target_dir() {
let features = FeatureFlags {
all_features: false,
no_default_features: false,
features: Vec::new(),
};
let cmd = build_stdlib_rustdoc_command(
Path::new("/sysroot/lib/rustlib/src/rust/library/std/Cargo.toml"),
Path::new("/cache/stdlib/target-std-abc123"),
&features,
false,
);
let args = format_command_args(&cmd);
assert!(has_arg(&args, "+nightly"));
assert!(has_arg(&args, "rustdoc"));
assert!(has_arg(&args, "--lib"));
assert!(has_arg(&args, "--manifest-path"));
assert!(has_arg(
&args,
"/sysroot/lib/rustlib/src/rust/library/std/Cargo.toml"
));
assert!(has_arg(&args, "--target-dir"));
assert!(has_arg(&args, "/cache/stdlib/target-std-abc123"));
assert!(has_arg(&args, "--output-format"));
assert!(has_arg(&args, "json"));
assert!(!has_arg(&args, "-p"));
}
#[test]
fn build_stdlib_command_includes_all_features() {
let features = FeatureFlags {
all_features: true,
no_default_features: false,
features: Vec::new(),
};
let cmd = build_stdlib_rustdoc_command(
Path::new("/std/Cargo.toml"),
Path::new("/cache/target"),
&features,
false,
);
let args = format_command_args(&cmd);
assert!(has_arg(&args, "--all-features"));
}
#[test]
fn build_stdlib_command_includes_private_items() {
let features = FeatureFlags {
all_features: false,
no_default_features: false,
features: Vec::new(),
};
let cmd = build_stdlib_rustdoc_command(
Path::new("/std/Cargo.toml"),
Path::new("/cache/target"),
&features,
true,
);
let args = format_command_args(&cmd);
assert!(has_arg(&args, "--"));
assert!(has_arg(&args, "--document-private-items"));
}
#[test]
fn build_stdlib_command_omits_private_when_not_requested() {
let features = FeatureFlags {
all_features: false,
no_default_features: false,
features: Vec::new(),
};
let cmd = build_stdlib_rustdoc_command(
Path::new("/std/Cargo.toml"),
Path::new("/cache/target"),
&features,
false,
);
let args = format_command_args(&cmd);
assert!(!has_arg(&args, "--document-private-items"));
}
#[test]
fn generate_stdlib_json_rejects_non_stdlib_crate() {
let features = FeatureFlags {
all_features: false,
no_default_features: false,
features: Vec::new(),
};
let result = generate_stdlib_json("serde", &features, false);
assert!(result.is_err());
match result.unwrap_err() {
GroxError::RustdocFailed { stderr } => {
assert!(
stderr.contains("not a recognized stdlib crate"),
"error should mention unrecognized: {stderr}"
);
}
other => panic!("expected RustdocFailed, got: {other:?}"),
}
}
#[test]
#[ignore = "requires nightly toolchain and rust-src component; slow"]
fn generate_stdlib_json_produces_json_for_core() {
let features = FeatureFlags {
all_features: false,
no_default_features: false,
features: Vec::new(),
};
let result = generate_stdlib_json("core", &features, false);
match result {
Ok(json) => {
assert!(!json.is_empty(), "JSON content should be non-empty");
assert!(json.trim_start().starts_with('{'));
crate::index_builder::parse_rustdoc_json(&json)
.expect("generated JSON should parse as rustdoc_types::Crate");
}
Err(GroxError::NightlyNotAvailable) => {
eprintln!("SKIP: nightly not available");
}
Err(GroxError::StdLibSourceMissing) => {
eprintln!("SKIP: rust-src not installed");
}
Err(e) => panic!("unexpected error: {e}"),
}
}
fn format_command_args(cmd: &Command) -> Vec<String> {
let debug = format!("{cmd:?}");
let mut args = Vec::new();
let mut in_quote = false;
let mut current = String::new();
for ch in debug.chars() {
if ch == '"' {
if in_quote {
args.push(current.clone());
current.clear();
}
in_quote = !in_quote;
} else if in_quote {
current.push(ch);
}
}
args
}
fn has_arg(args: &[String], expected: &str) -> bool {
args.iter().any(|a| a == expected)
}
}