use crate::error::RailResult;
use cargo_metadata::{Metadata, MetadataCommand, Package, PackageId};
use rayon::prelude::*;
use rustc_hash::FxHashMap;
use semver::Version;
use std::collections::{HashMap, HashSet};
use std::path::Path;
#[derive(Clone)]
struct TargetMetadataEntry {
metadata: Metadata,
package_id_index: HashMap<PackageId, usize>,
}
impl TargetMetadataEntry {
fn new(metadata: Metadata) -> Self {
let package_id_index = metadata
.packages
.iter()
.enumerate()
.map(|(idx, pkg)| (pkg.id.clone(), idx))
.collect();
Self {
metadata,
package_id_index,
}
}
fn package_by_id(&self, id: &PackageId) -> Option<&Package> {
self.package_id_index.get(id).map(|&idx| &self.metadata.packages[idx])
}
}
#[derive(Clone)]
pub struct MultiTargetMetadata {
cache: FxHashMap<String, TargetMetadataEntry>,
}
impl MultiTargetMetadata {
pub fn load_parallel(workspace_root: &Path, targets: &[String]) -> RailResult<Self> {
let workspace_root = workspace_root.to_path_buf();
if targets.is_empty() {
let metadata = Self::load_single_target(&workspace_root, None)?;
let mut cache = FxHashMap::default();
cache.insert(String::from("default"), TargetMetadataEntry::new(metadata));
return Ok(Self { cache });
}
let results: Vec<RailResult<(String, Metadata)>> = targets
.par_iter()
.map(|target| {
let metadata = Self::load_single_target(&workspace_root, Some(target))?;
Ok((target.clone(), metadata))
})
.collect();
let mut cache = FxHashMap::default();
for result in results {
let (target, metadata) = result?;
cache.insert(target, TargetMetadataEntry::new(metadata));
}
Ok(Self { cache })
}
fn load_single_target(workspace_root: &Path, target: Option<&str>) -> RailResult<Metadata> {
let manifest_path = workspace_root.join("Cargo.toml");
let mut cmd = MetadataCommand::new();
cmd.manifest_path(&manifest_path);
if let Some(target_triple) = target {
cmd.other_options(vec![String::from("--filter-platform"), String::from(target_triple)]);
}
let metadata = cmd.exec().map_err(|e| {
if let Some(t) = target {
let err_str = e.to_string();
if err_str.contains("error[E0463]")
|| err_str.contains("can't find crate")
|| err_str.contains("target may not be installed")
{
crate::error::RailError::with_help(
format!("Target '{}' is not installed on this machine", t),
format!(
"Install the target with: rustup target add {}\n\
Or remove it from rail.toml [targets] if not needed for this workspace.",
t
),
)
} else {
crate::error::RailError::with_help(
format!("Failed to load cargo metadata for target '{}'", t),
format!("Error: {}\n\nCheck that the target is valid and installed.", e),
)
}
} else {
crate::error::RailError::with_help("Failed to load cargo metadata".to_string(), format!("Error: {}", e))
}
})?;
Ok(metadata)
}
pub fn get(&self, target: &str) -> Option<&Metadata> {
self.cache.get(target).map(|e| &e.metadata)
}
pub fn any(&self) -> Option<&Metadata> {
self.cache.values().next().map(|e| &e.metadata)
}
pub fn targets(&self) -> Vec<&str> {
let mut targets: Vec<_> = self.cache.keys().map(|s| s.as_str()).collect();
targets.sort_unstable();
targets
}
pub fn workspace_packages(&self) -> Vec<&Package> {
self.any().map(|m| m.workspace_packages()).unwrap_or_default()
}
pub fn all_versions(&self, dep_name: &str) -> HashMap<String, Version> {
let mut versions = HashMap::with_capacity(self.cache.len());
for (target, entry) in &self.cache {
let metadata = &entry.metadata;
if let Some(resolve) = &metadata.resolve {
let found_version = resolve.nodes.iter().find_map(|node| {
entry
.package_by_id(&node.id)
.filter(|pkg| pkg.name == dep_name)
.map(|pkg| pkg.version.clone())
});
if let Some(version) = found_version {
versions.insert(target.clone(), version);
}
}
}
versions
}
pub fn direct_dep_versions(&self, dep_name: &str) -> HashMap<String, Version> {
let mut versions = HashMap::with_capacity(self.cache.len());
for (target, entry) in &self.cache {
let metadata = &entry.metadata;
let workspace_member_ids: HashSet<_> = metadata.workspace_packages().iter().map(|p| &p.id).collect();
if let Some(resolve) = &metadata.resolve {
let found_version = resolve
.nodes
.iter()
.filter(|node| workspace_member_ids.contains(&node.id))
.find_map(|node| {
node
.deps
.iter()
.find(|dep| dep.name == dep_name)
.and_then(|dep| entry.package_by_id(&dep.pkg))
.map(|pkg| pkg.version.clone())
});
if let Some(version) = found_version {
versions.insert(target.clone(), version);
}
}
}
versions
}
pub fn is_transitive_only(&self, dep_name: &str) -> bool {
let is_direct_dep = self.cache.values().any(|entry| {
entry
.metadata
.workspace_packages()
.iter()
.any(|pkg| pkg.dependencies.iter().any(|dep| dep.name == dep_name))
});
if is_direct_dep {
return false;
}
self.cache.values().any(|entry| {
entry.metadata.resolve.as_ref().is_some_and(|resolve| {
resolve
.nodes
.iter()
.any(|node| entry.package_by_id(&node.id).is_some_and(|pkg| pkg.name == dep_name))
})
})
}
pub fn is_path_dependency(&self, dep_name: &str) -> bool {
self
.cache
.values()
.flat_map(|entry| &entry.metadata.packages)
.find(|pkg| pkg.name == dep_name)
.is_some_and(|pkg| pkg.source.is_none())
}
pub fn all_features(&self, dep_name: &str) -> HashMap<String, HashSet<String>> {
let mut features = HashMap::with_capacity(self.cache.len());
for (target, entry) in &self.cache {
let metadata = &entry.metadata;
if let Some(resolve) = &metadata.resolve {
let found_features = resolve.nodes.iter().find_map(|node| {
entry
.package_by_id(&node.id)
.filter(|pkg| pkg.name == dep_name)
.map(|pkg| {
node
.features
.iter()
.filter(|f| pkg.features.contains_key(f.as_str()))
.map(|f| f.to_string())
.collect::<HashSet<String>>()
})
});
if let Some(feat_set) = found_features {
features.insert(target.clone(), feat_set);
}
}
}
features
}
#[allow(dead_code)] pub fn universal_features(&self, dep_name: &str) -> HashSet<String> {
let per_target = self.all_features(dep_name);
if per_target.is_empty() {
return HashSet::new();
}
let mut iter = per_target.values();
let mut result = iter.next().cloned().unwrap_or_default();
for feats in iter {
result.retain(|f| feats.contains(f));
}
result
}
pub fn targets_with_dep(&self, dep_name: &str) -> Vec<String> {
let mut targets = Vec::with_capacity(self.cache.len());
for (target, entry) in &self.cache {
let metadata = &entry.metadata;
if let Some(resolve) = &metadata.resolve {
let has_dep = resolve
.nodes
.iter()
.any(|node| entry.package_by_id(&node.id).is_some_and(|pkg| pkg.name == dep_name));
if has_dep {
targets.push(target.clone());
}
}
}
targets.sort_unstable();
targets
}
pub fn find_fragmented_transitives(&self) -> Vec<FragmentedTransitive> {
let mut transitives = Vec::new();
let all_deps: HashSet<&str> = self
.cache
.values()
.filter_map(|entry| entry.metadata.resolve.as_ref())
.flat_map(|resolve| &resolve.nodes)
.filter_map(|node| self.cache.values().find_map(|entry| entry.package_by_id(&node.id)))
.map(|pkg| pkg.name.as_str())
.collect();
for dep_name in all_deps {
if !self.is_transitive_only(dep_name) {
continue; }
if self.is_path_dependency(dep_name) {
continue;
}
let features = self.all_features(dep_name);
let unique_sets: HashSet<_> = features
.values()
.map(|set| {
let mut vec: Vec<_> = set.iter().cloned().collect();
vec.sort_unstable();
vec
})
.collect();
if unique_sets.len() > 1 {
let mut feature_sets = features.values();
let Some(first_set) = feature_sets.next() else {
continue;
};
let mut common_features = first_set.clone();
for set in feature_sets {
common_features.retain(|feature| set.contains(feature));
}
let versions = self.all_versions(dep_name);
let version = match versions.values().max() {
Some(v) => v.clone(),
None => continue, };
let mut unified_features: Vec<_> = common_features.into_iter().collect();
unified_features.sort_unstable();
transitives.push(FragmentedTransitive {
name: dep_name.to_string(),
version,
feature_sets: features,
unified_features,
});
}
}
transitives.sort_unstable_by(|a, b| a.name.cmp(&b.name));
transitives
}
fn compute_deps_msrv(&self) -> Option<(Version, Vec<String>, usize)> {
let mut max_version: Option<Version> = None;
let mut contributors: Vec<&str> = Vec::new();
let mut deps_with_msrv = 0;
let mut seen_packages: HashSet<&PackageId> = HashSet::new();
for entry in self.cache.values() {
let metadata = &entry.metadata;
for pkg in &metadata.packages {
if !seen_packages.insert(&pkg.id) {
continue;
}
if let Some(ref rust_version) = pkg.rust_version {
deps_with_msrv += 1;
match &max_version {
None => {
max_version = Some(rust_version.clone());
contributors = vec![pkg.name.as_str()];
}
Some(current_max) => {
if rust_version > current_max {
max_version = Some(rust_version.clone());
contributors = vec![pkg.name.as_str()];
} else if rust_version == current_max {
contributors.push(pkg.name.as_str());
}
}
}
}
}
}
max_version.map(|v| {
(
v,
contributors.into_iter().map(std::borrow::ToOwned::to_owned).collect(),
deps_with_msrv,
)
})
}
pub fn compute_msrv_with_config(
&self,
workspace_root: &Path,
msrv_source: crate::config::MsrvSource,
) -> Option<ComputedMsrv> {
use crate::config::MsrvSource;
let deps_result = self.compute_deps_msrv();
let (workspace_msrv, used_package_fallback) = read_workspace_rust_version(workspace_root);
match msrv_source {
MsrvSource::Deps => {
deps_result.map(|(version, contributors, deps_with_msrv)| ComputedMsrv {
version: version.clone(),
contributors,
deps_with_msrv,
deps_msrv: Some(version),
workspace_msrv,
source_used: MsrvSourceUsed::Deps,
warning: None,
})
}
MsrvSource::Workspace => {
match (&workspace_msrv, &deps_result) {
(Some(ws_ver), Some((deps_ver, contributors, deps_with_msrv))) => {
let warning = if deps_ver > ws_ver {
Some(format!(
"workspace rust-version ({}.{}.{}) is lower than deps require ({}.{}.{}); \
deps {} need the higher version",
ws_ver.major,
ws_ver.minor,
ws_ver.patch,
deps_ver.major,
deps_ver.minor,
deps_ver.patch,
contributors.first().map_or("unknown", String::as_str)
))
} else if used_package_fallback {
Some(
"no [workspace.package].rust-version found; using [package].rust-version as baseline and writing it to [workspace.package].rust-version. \
consider enabling MSRV inheritance (rust-version = { workspace = true }) to avoid drift."
.to_string(),
)
} else {
None
};
Some(ComputedMsrv {
version: ws_ver.clone(),
contributors: contributors.clone(),
deps_with_msrv: *deps_with_msrv,
deps_msrv: Some(deps_ver.clone()),
workspace_msrv: Some(ws_ver.clone()),
source_used: MsrvSourceUsed::Workspace,
warning,
})
}
(Some(ws_ver), None) => {
Some(ComputedMsrv {
version: ws_ver.clone(),
contributors: Vec::new(),
deps_with_msrv: 0,
deps_msrv: None,
workspace_msrv: Some(ws_ver.clone()),
source_used: MsrvSourceUsed::Workspace,
warning: if used_package_fallback {
Some(
"no [workspace.package].rust-version found; using [package].rust-version as baseline and writing it to [workspace.package].rust-version. \
consider enabling MSRV inheritance (rust-version = { workspace = true }) to avoid drift."
.to_string(),
)
} else {
None
},
})
}
(None, Some((deps_ver, contributors, deps_with_msrv))) => {
Some(ComputedMsrv {
version: deps_ver.clone(),
contributors: contributors.clone(),
deps_with_msrv: *deps_with_msrv,
deps_msrv: Some(deps_ver.clone()),
workspace_msrv: None,
source_used: MsrvSourceUsed::Deps,
warning: Some("no workspace rust-version found, using deps MSRV".to_string()),
})
}
(None, None) => None,
}
}
MsrvSource::Max => {
match (&workspace_msrv, &deps_result) {
(Some(ws_ver), Some((deps_ver, contributors, deps_with_msrv))) => {
let (version, source_used) = if ws_ver >= deps_ver {
(ws_ver.clone(), MsrvSourceUsed::MaxWorkspace)
} else {
(deps_ver.clone(), MsrvSourceUsed::MaxDeps)
};
Some(ComputedMsrv {
version,
contributors: contributors.clone(),
deps_with_msrv: *deps_with_msrv,
deps_msrv: Some(deps_ver.clone()),
workspace_msrv: Some(ws_ver.clone()),
source_used,
warning: if used_package_fallback {
Some(
"no [workspace.package].rust-version found; using [package].rust-version as baseline. \
consider enabling MSRV inheritance (rust-version = { workspace = true }) to avoid drift."
.to_string(),
)
} else {
None
},
})
}
(Some(ws_ver), None) => {
Some(ComputedMsrv {
version: ws_ver.clone(),
contributors: Vec::new(),
deps_with_msrv: 0,
deps_msrv: None,
workspace_msrv: Some(ws_ver.clone()),
source_used: MsrvSourceUsed::MaxWorkspace,
warning: if used_package_fallback {
Some(
"no [workspace.package].rust-version found; using [package].rust-version as baseline. \
consider enabling MSRV inheritance (rust-version = { workspace = true }) to avoid drift."
.to_string(),
)
} else {
None
},
})
}
(None, Some((deps_ver, contributors, deps_with_msrv))) => {
Some(ComputedMsrv {
version: deps_ver.clone(),
contributors: contributors.clone(),
deps_with_msrv: *deps_with_msrv,
deps_msrv: Some(deps_ver.clone()),
workspace_msrv: None,
source_used: MsrvSourceUsed::MaxDeps,
warning: None,
})
}
(None, None) => None,
}
}
}
}
}
fn read_workspace_rust_version(workspace_root: &Path) -> (Option<Version>, bool) {
let cargo_toml_path = workspace_root.join("Cargo.toml");
let Ok(content) = std::fs::read_to_string(&cargo_toml_path) else {
return (None, false);
};
let Ok(doc) = content.parse::<toml_edit::DocumentMut>() else {
return (None, false);
};
let workspace_rust_version_str = doc
.get("workspace")
.and_then(|ws| ws.get("package"))
.and_then(|pkg| pkg.get("rust-version"))
.and_then(|v| v.as_str());
if let Some(s) = workspace_rust_version_str {
return (parse_rust_version(s), false);
}
let package_rust_version_str = doc
.get("package")
.and_then(|pkg| pkg.get("rust-version"))
.and_then(|v| v.as_str());
if let Some(s) = package_rust_version_str {
return (parse_rust_version(s), true);
}
(None, false)
}
fn parse_rust_version(s: &str) -> Option<Version> {
if let Ok(v) = Version::parse(s) {
return Some(v);
}
let parts: Vec<&str> = s.split('.').collect();
if parts.len() == 2
&& let (Ok(major), Ok(minor)) = (parts[0].parse::<u64>(), parts[1].parse::<u64>())
{
return Some(Version::new(major, minor, 0));
}
None
}
#[derive(Debug, Clone)]
pub struct FragmentedTransitive {
pub name: String,
pub version: Version,
pub feature_sets: HashMap<String, HashSet<String>>,
pub unified_features: Vec<String>,
}
impl FragmentedTransitive {
pub fn overhead_factor(&self) -> usize {
self.feature_sets.len()
}
}
#[derive(Debug, Clone)]
pub struct ComputedMsrv {
pub version: Version,
pub contributors: Vec<String>,
pub deps_with_msrv: usize,
pub deps_msrv: Option<Version>,
pub workspace_msrv: Option<Version>,
pub source_used: MsrvSourceUsed,
pub warning: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MsrvSourceUsed {
Deps,
Workspace,
MaxWorkspace,
MaxDeps,
}
impl MultiTargetMetadata {
pub fn package_to_lib_name_map(&self) -> HashMap<String, String> {
use cargo_metadata::TargetKind;
let mut map = HashMap::new();
for entry in self.cache.values() {
let metadata = &entry.metadata;
for pkg in &metadata.packages {
let lib_name = pkg
.targets
.iter()
.find(|t| t.kind.contains(&TargetKind::Lib))
.map(|t| t.name.clone())
.unwrap_or_else(|| pkg.name.to_string());
let normalized_lib = lib_name.replace('-', "_");
map.insert(pkg.name.to_string(), normalized_lib);
}
}
map
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_rust_version_full() {
let v = parse_rust_version("1.70.0").unwrap();
assert_eq!(v.major, 1);
assert_eq!(v.minor, 70);
assert_eq!(v.patch, 0);
}
#[test]
fn test_parse_rust_version_two_parts() {
let v = parse_rust_version("1.70").unwrap();
assert_eq!(v.major, 1);
assert_eq!(v.minor, 70);
assert_eq!(v.patch, 0);
}
#[test]
fn test_parse_rust_version_high_minor() {
let v = parse_rust_version("1.91").unwrap();
assert_eq!(v.major, 1);
assert_eq!(v.minor, 91);
assert_eq!(v.patch, 0);
}
#[test]
fn test_parse_rust_version_invalid() {
assert!(parse_rust_version("invalid").is_none());
assert!(parse_rust_version("").is_none());
assert!(parse_rust_version("1").is_none());
assert!(parse_rust_version("a.b.c").is_none());
}
#[test]
fn test_msrv_source_used_variants() {
assert_ne!(MsrvSourceUsed::Deps, MsrvSourceUsed::Workspace);
assert_ne!(MsrvSourceUsed::MaxWorkspace, MsrvSourceUsed::MaxDeps);
}
#[test]
fn test_targets_returns_sorted_output() {
let mut keys = vec!["z-target", "a-target", "m-target"];
keys.sort_unstable();
assert_eq!(keys, vec!["a-target", "m-target", "z-target"]);
}
#[test]
fn test_fragmented_transitive_unified_features_sorting_contract() {
let mut features = vec!["zebra".to_string(), "alpha".to_string(), "beta".to_string()];
features.sort_unstable();
let transitive = FragmentedTransitive {
name: "test-dep".to_string(),
version: Version::new(1, 0, 0),
feature_sets: HashMap::new(),
unified_features: features,
};
assert!(
is_sorted(&transitive.unified_features),
"unified_features should be sorted for deterministic output"
);
assert_eq!(
transitive.unified_features,
vec!["alpha", "beta", "zebra"],
"Features should be in alphabetical order"
);
}
#[test]
fn test_feature_set_comparison_is_deterministic() {
let mut set1: HashSet<String> = HashSet::new();
set1.insert("c".to_string());
set1.insert("a".to_string());
set1.insert("b".to_string());
let mut set2: HashSet<String> = HashSet::new();
set2.insert("a".to_string());
set2.insert("b".to_string());
set2.insert("c".to_string());
let mut vec1: Vec<_> = set1.iter().cloned().collect();
vec1.sort_unstable();
let mut vec2: Vec<_> = set2.iter().cloned().collect();
vec2.sort_unstable();
assert_eq!(vec1, vec2, "Sorted feature sets should be equal");
assert_eq!(vec1, vec!["a", "b", "c"]);
}
#[test]
fn test_find_fragmented_transitives_output_is_sorted() {
let mut transitives = [
FragmentedTransitive {
name: "zebra-crate".to_string(),
version: Version::new(1, 0, 0),
feature_sets: HashMap::new(),
unified_features: vec![],
},
FragmentedTransitive {
name: "alpha-crate".to_string(),
version: Version::new(1, 0, 0),
feature_sets: HashMap::new(),
unified_features: vec![],
},
FragmentedTransitive {
name: "middle-crate".to_string(),
version: Version::new(1, 0, 0),
feature_sets: HashMap::new(),
unified_features: vec![],
},
];
transitives.sort_unstable_by(|a, b| a.name.cmp(&b.name));
assert_eq!(transitives[0].name, "alpha-crate");
assert_eq!(transitives[1].name, "middle-crate");
assert_eq!(transitives[2].name, "zebra-crate");
}
fn is_sorted(slice: &[String]) -> bool {
slice.windows(2).all(|w| w[0] <= w[1])
}
}