use std::path::{Path, PathBuf};
use std::process::Command;
pub fn resolve_crate_source_dir(workspace_root: &Path, crate_name: &str) -> Result<PathBuf, String> {
let manifest = workspace_root.join("Cargo.toml");
let output = Command::new("cargo")
.args(["metadata", "--format-version", "1", "--manifest-path"])
.arg(&manifest)
.output()
.map_err(|e| format!("failed to run `cargo metadata`: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!(
"`cargo metadata --manifest-path {}` failed: {}",
manifest.display(),
stderr.trim()
));
}
let json = String::from_utf8_lossy(&output.stdout);
resolve_crate_source_dir_from_metadata(&json, crate_name)
.map_err(|e| format!("{e} (workspace: `{}`)", workspace_root.display()))
}
pub fn resolve_crate_source_dir_from_metadata(metadata_json: &str, crate_name: &str) -> Result<PathBuf, String> {
let metadata: serde_json::Value = serde_json::from_str(metadata_json)
.map_err(|e| format!("failed to parse cargo metadata JSON: {e}"))?;
let packages = metadata["packages"]
.as_array()
.ok_or_else(|| "cargo metadata JSON missing `packages` array".to_string())?;
let normalized_query = normalize_crate_name(crate_name);
let pkg = packages
.iter()
.find(|pkg| {
pkg["name"]
.as_str()
.map(|n| normalize_crate_name(n) == normalized_query)
.unwrap_or(false)
})
.ok_or_else(|| {
let available: Vec<&str> = packages.iter().filter_map(|pkg| pkg["name"].as_str()).collect();
format!(
"crate `{crate_name}` not found in cargo metadata; \
available packages: {}",
available.join(", ")
)
})?;
let manifest_path_str = pkg["manifest_path"]
.as_str()
.ok_or_else(|| format!("package `{crate_name}` has no `manifest_path` in cargo metadata"))?;
let crate_root = Path::new(manifest_path_str)
.parent()
.ok_or_else(|| format!("manifest_path `{manifest_path_str}` has no parent directory"))?;
Ok(crate_root.to_path_buf())
}
fn normalize_crate_name(name: &str) -> String {
name.replace('-', "_")
}
#[cfg(test)]
mod tests {
use super::*;
fn fake_metadata_json(packages: &[(&str, &str)]) -> String {
let pkg_entries: Vec<String> = packages
.iter()
.map(|(name, manifest_path)| {
format!(r#"{{"name": "{name}", "manifest_path": "{manifest_path}"}}"#)
})
.collect();
format!(r#"{{"packages": [{}]}}"#, pkg_entries.join(","))
}
#[test]
fn resolves_registry_path_from_metadata() {
let json = fake_metadata_json(&[(
"crawlberg",
"/home/user/.cargo/registry/src/index.crates.io-abc/crawlberg-0.1.0/Cargo.toml",
)]);
let dir = resolve_crate_source_dir_from_metadata(&json, "crawlberg").unwrap();
assert_eq!(
dir,
PathBuf::from("/home/user/.cargo/registry/src/index.crates.io-abc/crawlberg-0.1.0")
);
}
#[test]
fn rebases_multiple_sources() {
let json = fake_metadata_json(&[(
"crawlberg",
"/home/user/.cargo/registry/src/index.crates.io-abc/crawlberg-0.1.0/Cargo.toml",
)]);
let crate_dir = resolve_crate_source_dir_from_metadata(&json, "crawlberg").unwrap();
let rel_sources = [
PathBuf::from("src/types/config.rs"),
PathBuf::from("src/types/discovery.rs"),
PathBuf::from("src/net/ssrf.rs"),
];
let rebased: Vec<PathBuf> = rel_sources.iter().map(|p| crate_dir.join(p)).collect();
let base = "/home/user/.cargo/registry/src/index.crates.io-abc/crawlberg-0.1.0";
assert_eq!(rebased[0], PathBuf::from(format!("{base}/src/types/config.rs")));
assert_eq!(rebased[1], PathBuf::from(format!("{base}/src/types/discovery.rs")));
assert_eq!(rebased[2], PathBuf::from(format!("{base}/src/net/ssrf.rs")));
}
#[test]
fn normalizes_hyphen_query_against_hyphen_package() {
let json = fake_metadata_json(&[(
"my-crate",
"/home/user/.cargo/registry/src/index.crates.io-abc/my-crate-1.0.0/Cargo.toml",
)]);
let dir = resolve_crate_source_dir_from_metadata(&json, "my_crate").unwrap();
assert_eq!(
dir,
PathBuf::from("/home/user/.cargo/registry/src/index.crates.io-abc/my-crate-1.0.0")
);
}
#[test]
fn normalizes_underscore_query_against_underscore_package() {
let json = fake_metadata_json(&[(
"my_crate",
"/home/user/.cargo/registry/src/index.crates.io-abc/my_crate-1.0.0/Cargo.toml",
)]);
let dir = resolve_crate_source_dir_from_metadata(&json, "my-crate").unwrap();
assert_eq!(
dir,
PathBuf::from("/home/user/.cargo/registry/src/index.crates.io-abc/my_crate-1.0.0")
);
}
#[test]
fn error_when_crate_not_found() {
let json = fake_metadata_json(&[("other-crate", "/some/path/Cargo.toml")]);
let err = resolve_crate_source_dir_from_metadata(&json, "crawlberg").unwrap_err();
assert!(err.contains("crate `crawlberg` not found"), "got: {err}");
assert!(err.contains("other-crate"), "got: {err}");
}
#[test]
fn error_on_invalid_json() {
let err = resolve_crate_source_dir_from_metadata("not json", "crawlberg").unwrap_err();
assert!(err.contains("failed to parse cargo metadata JSON"), "got: {err}");
}
#[test]
fn error_when_packages_missing() {
let err = resolve_crate_source_dir_from_metadata("{}", "crawlberg").unwrap_err();
assert!(err.contains("missing `packages` array"), "got: {err}");
}
}