use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::fs;
use std::path::Path;
use xshell::Shell;
use crate::environment::{
cargo_cmd, get_workspace_packages, get_workspace_root, CmdExt, Package, PackageManifest,
ProgressGuard,
};
use crate::lock::LockFile;
use crate::toolchain::{prepare_toolchain, Toolchain};
const CARGO_TREE_ARGS: &[&str] = &[
"tree",
"--target=all",
"--all-features",
"--duplicates",
"--no-dedupe",
"--edges",
"no-build",
"--edges",
"no-dev",
"--prefix",
"depth",
];
#[derive(Debug)]
enum LintError {
DuplicateDependencies(Vec<(String, String)>), StaleAllowedDuplicates(Vec<(String, Vec<String>)>), DeprecatedClippyMsrv(Vec<String>), }
impl std::fmt::Display for LintError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::DuplicateDependencies(duplicates) => {
write!(f, "Error: Found duplicate dependencies")?;
for (pkg_name, output) in duplicates {
write!(f, "\n {}: {}", pkg_name, output)?;
}
Ok(())
}
Self::StaleAllowedDuplicates(stale_entries) => {
write!(f, "Stale entries in `allowed_duplicates` found")?;
for (pkg_name, entries) in stale_entries {
for entry in entries {
write!(f, "\n {}: {}", pkg_name, entry)?;
}
}
Ok(())
}
Self::DeprecatedClippyMsrv(files) => {
write!(
f,
"Found MSRV in clippy.toml, use Cargo.toml package.rust-version instead"
)?;
for file in files {
write!(f, "\n {}", file)?;
}
Ok(())
}
}
}
}
impl std::error::Error for LintError {}
#[derive(Debug, serde::Deserialize, Default)]
#[serde(default)]
struct LintConfig {
allowed_duplicates: Vec<String>,
}
impl LintConfig {
fn load(package_dir: &Path) -> Result<Self, Box<dyn std::error::Error>> {
#[derive(serde::Deserialize, Default)]
struct RbmtTable {
#[serde(default)]
lint: LintConfig,
}
let path = package_dir.join("Cargo.toml");
if !path.exists() {
return Ok(Self::default());
}
let contents = std::fs::read_to_string(&path)?;
Ok(toml::from_str::<PackageManifest<RbmtTable>>(&contents)?.package.metadata.rbmt.lint)
}
}
pub fn run(
sh: &Shell,
lockfile: LockFile,
packages: &[String],
) -> Result<(), Box<dyn std::error::Error>> {
let packages = get_workspace_packages(sh, packages)?;
let _lockfile_guard = lockfile.activate(sh)?;
let _progress = ProgressGuard::new();
prepare_toolchain(sh, Toolchain::Nightly)?;
rbmt_eprintln!("Running lint task...");
lint_workspace(sh)?;
lint_packages(sh, &packages)?;
check_duplicate_deps(sh, &packages)?;
check_cross_package_duplicate_deps(sh)?;
check_clippy_toml_msrv(sh, &packages)?;
rbmt_eprintln!("Lint task completed successfully");
Ok(())
}
fn lint_workspace(sh: &Shell) -> Result<(), Box<dyn std::error::Error>> {
rbmt_eprintln!("Linting workspace...");
cargo_cmd(sh)
.arg("clippy")
.arg("--workspace")
.arg("--all-targets")
.arg("--all-features")
.arg("--keep-going")
.args(&["--", "-D", "warnings"])
.run_with_capture()?;
cargo_cmd(sh)
.arg("clippy")
.arg("--workspace")
.arg("--all-targets")
.arg("--keep-going")
.args(&["--", "-D", "warnings"])
.run_with_capture()?;
Ok(())
}
fn lint_packages(sh: &Shell, packages: &[Package]) -> Result<(), Box<dyn std::error::Error>> {
rbmt_eprintln!("Running package-specific lints...");
let package_names: Vec<_> = packages.iter().map(|p| p.name.as_str()).collect();
rbmt_eprintln!("Found crates: {}", package_names.join(", "));
for package in packages {
let _old_dir = sh.push_dir(&package.dir);
cargo_cmd(sh)
.arg("clippy")
.arg("--all-targets")
.arg("--no-default-features")
.arg("--keep-going")
.args(&["--", "-D", "warnings"])
.run_with_capture()?;
}
Ok(())
}
fn check_duplicate_deps(
sh: &Shell,
packages: &[Package],
) -> Result<(), Box<dyn std::error::Error>> {
rbmt_eprintln!("Checking for duplicate dependencies...");
let mut duplicate_deps: Vec<(String, String)> = Vec::new();
let mut stale_entries: Vec<(String, Vec<String>)> = Vec::new();
for package in packages {
let config = LintConfig::load(&package.dir)?;
let _old_dir = sh.push_dir(&package.dir);
let output = cargo_cmd(sh).args(CARGO_TREE_ARGS).ignore_status().read()?;
let tree = DuplicateTree::parse(&output, &config.allowed_duplicates);
if !tree.duplicates().is_empty() {
duplicate_deps.push((package.name.clone(), output));
}
if !tree.stale_allowed_duplicates().is_empty() {
stale_entries.push((package.name.clone(), tree.stale_allowed_duplicates().to_vec()));
}
}
if !duplicate_deps.is_empty() {
return Err(Box::new(LintError::DuplicateDependencies(duplicate_deps)));
}
if !stale_entries.is_empty() {
return Err(Box::new(LintError::StaleAllowedDuplicates(stale_entries)));
}
rbmt_eprintln!("No duplicate dependencies found");
Ok(())
}
fn check_cross_package_duplicate_deps(sh: &Shell) -> Result<(), Box<dyn std::error::Error>> {
let package_info = get_workspace_packages(sh, &[])?;
if package_info.len() <= 1 {
return Ok(());
}
rbmt_eprintln!("Checking for cross-package duplicate dependencies...");
let output = cargo_cmd(sh).args(CARGO_TREE_ARGS).arg("--workspace").ignore_status().read()?;
let tree = DuplicateTree::parse(&output, &[]);
let cross_package_dupes = tree.cross_package_duplicates();
if !cross_package_dupes.is_empty() {
rbmt_eprintln!("Found {} cross-package duplicate dependencies", cross_package_dupes.len());
for (crate_name, versions) in &cross_package_dupes {
for (version, members) in *versions {
let members: Vec<&str> = members.iter().map(String::as_str).collect();
rbmt_eprintln!(" {} {}: {}", crate_name, version, members.join(", "));
}
}
}
rbmt_eprintln!("No cross-package duplicate dependencies found");
Ok(())
}
struct Dependency {
depth: u32,
name: String,
version: String,
is_workspace_member: bool,
}
impl Dependency {
fn parse(line: &str) -> Option<Self> {
let depth_digits = line.chars().take_while(char::is_ascii_digit).count();
let depth: u32 = line[..depth_digits].parse().ok()?;
let rest = &line[depth_digits..];
let mut tokens = rest.split_whitespace();
let name = tokens.next()?.to_string();
let version = tokens.next()?.to_string();
let is_workspace_member = tokens.any(|t| t.starts_with("(/"));
Some(Self { depth, name, version, is_workspace_member })
}
}
struct DuplicateTree {
inner: BTreeMap<String, BTreeMap<String, BTreeSet<String>>>,
stale_allowed: Vec<String>,
}
impl DuplicateTree {
fn parse(output: &str, allowed_duplicates: &[String]) -> Self {
let mut inner: BTreeMap<String, BTreeMap<String, BTreeSet<String>>> = BTreeMap::new();
let mut current_duplicate: Option<(String, String)> = None;
let mut seen_allowed_duplicate: HashSet<String> = HashSet::new();
for line in output.lines() {
let Some(dep) = Dependency::parse(line) else { continue };
if dep.depth == 0 {
if allowed_duplicates.iter().any(|a| a == &dep.name) {
seen_allowed_duplicate.insert(dep.name.clone());
current_duplicate = None;
continue;
}
inner.entry(dep.name.clone()).or_default().entry(dep.version.clone()).or_default();
current_duplicate = Some((dep.name, dep.version));
} else if let Some((ref name, ref version)) = current_duplicate {
if dep.is_workspace_member {
if let Some(members) =
inner.get_mut(name).and_then(|versions| versions.get_mut(version))
{
members.insert(dep.name.clone());
}
}
}
}
let stale_allowed = allowed_duplicates
.iter()
.filter(|a| !seen_allowed_duplicate.contains(*a))
.cloned()
.collect();
Self { inner, stale_allowed }
}
fn duplicates(&self) -> &BTreeMap<String, BTreeMap<String, BTreeSet<String>>> { &self.inner }
fn stale_allowed_duplicates(&self) -> &[String] { &self.stale_allowed }
fn cross_package_duplicates(&self) -> BTreeMap<&str, &BTreeMap<String, BTreeSet<String>>> {
self.inner
.iter()
.filter(|(_, versions)| {
!versions
.values()
.flat_map(|members| members.iter())
.any(|m| versions.values().all(|s| s.contains(m)))
})
.map(|(crate_name, versions)| (crate_name.as_str(), versions))
.collect()
}
}
fn check_clippy_toml_msrv(
sh: &Shell,
packages: &[Package],
) -> Result<(), Box<dyn std::error::Error>> {
const CLIPPY_CONFIG_FILES: &[&str] = &["clippy.toml", ".clippy.toml"];
rbmt_eprintln!("Checking for deprecated clippy.toml MSRV settings...");
let mut clippy_files = Vec::new();
let workspace_root = get_workspace_root(sh)?;
for filename in CLIPPY_CONFIG_FILES {
let path = workspace_root.join(filename);
if path.exists() {
clippy_files.push(path);
}
}
for package in packages {
for filename in CLIPPY_CONFIG_FILES {
let path = package.dir.join(filename);
if path.exists() {
clippy_files.push(path);
}
}
}
let mut problematic_files = Vec::new();
for path in clippy_files {
let contents = fs::read_to_string(&path)?;
let config: toml::Value = toml::from_str(&contents)?;
if config.get("msrv").is_some() {
problematic_files.push(path.display().to_string());
}
}
if !problematic_files.is_empty() {
return Err(Box::new(LintError::DeprecatedClippyMsrv(problematic_files)));
}
rbmt_eprintln!("No deprecated clippy.toml MSRV settings found");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cross_package_duplicate() {
let output = "\
0bitcoin_hashes v0.13.0
1pkg1 v0.1.0 (/path/to/pkg1)
0bitcoin_hashes v0.14.1
1pkg2 v0.1.0 (/path/to/pkg2)
0hex-conservative v0.1.2
1bitcoin_hashes v0.13.0 (*)
2pkg1 v0.1.0 (/path/to/pkg1)
0hex-conservative v0.2.2
1bitcoin_hashes v0.14.1 (*)
2pkg2 v0.1.0 (/path/to/pkg2)
";
let tree = DuplicateTree::parse(output, &[]);
let dupes = tree.cross_package_duplicates();
assert!(dupes.contains_key("bitcoin_hashes"));
assert!(dupes.contains_key("hex-conservative"));
assert!(dupes["bitcoin_hashes"].contains_key("v0.13.0"));
assert!(dupes["bitcoin_hashes"].contains_key("v0.14.1"));
assert!(dupes["hex-conservative"].contains_key("v0.1.2"));
assert!(dupes["hex-conservative"].contains_key("v0.2.2"));
}
#[test]
fn cross_package_transitive_duplicates() {
let output = "\
0hex-conservative v0.1.2
1some-lib v1.0.0
2pkg1 v0.1.0
0hex-conservative v0.2.2
1some-lib v2.0.0
2pkg2 v0.1.0
";
let tree = DuplicateTree::parse(output, &[]);
let dupes = tree.cross_package_duplicates();
assert!(dupes.contains_key("hex-conservative"));
assert!(dupes["hex-conservative"].contains_key("v0.1.2"));
assert!(dupes["hex-conservative"].contains_key("v0.2.2"));
}
#[test]
fn cross_package_single_package_not_reported() {
let output = "\
0foo v0.1.0
1pkg1 v0.1.0 (/path/to/pkg1)
0foo v0.2.0
1pkg1 v0.1.0 (/path/to/pkg1)
";
let tree = DuplicateTree::parse(output, &[]);
assert!(tree.cross_package_duplicates().is_empty());
}
#[test]
fn cross_package_dedupe_output() {
let output = "\
0bitcoin_hashes v0.13.0
1pkg1 v0.1.0 (/path/to/pkg1)
0bitcoin_hashes v0.14.1
1pkg2 v0.1.0 (/path/to/pkg2)
0bitcoin_hashes v0.13.0
1pkg1 v0.1.0 (/path/to/pkg1)
0bitcoin_hashes v0.14.1
1pkg2 v0.1.0 (/path/to/pkg2)
";
let tree = DuplicateTree::parse(output, &[]);
let dupes = tree.cross_package_duplicates();
assert_eq!(dupes.len(), 1);
assert_eq!(dupes["bitcoin_hashes"]["v0.13.0"], BTreeSet::from(["pkg1".to_string()]));
assert_eq!(dupes["bitcoin_hashes"]["v0.14.1"], BTreeSet::from(["pkg2".to_string()]));
}
#[test]
fn cross_package_shared_packages_across_all_dupes() {
let output = "\
0foo v0.1.0
1pkg1 v0.1.0 (/path/to/pkg1)
1pkg2 v0.1.0 (/path/to/pkg2)
0foo v0.2.0
1pkg1 v0.1.0 (/path/to/pkg1)
1pkg2 v0.1.0 (/path/to/pkg2)
";
let tree = DuplicateTree::parse(output, &[]);
assert!(tree.cross_package_duplicates().is_empty());
}
#[test]
fn cross_package_empty_output_no_dupes() {
let tree = DuplicateTree::parse("", &[]);
assert!(tree.cross_package_duplicates().is_empty());
}
#[test]
fn allowed_duplicates_not_reported() {
let output = "\
0bitcoin_hashes v0.13.0
1pkg1 v0.1.0
0bitcoin_hashes v0.14.1
1pkg2 v0.1.0
0hex-conservative v0.1.2
1pkg1 v0.1.0
0hex-conservative v0.2.2
1pkg2 v0.1.0
";
let allowed = vec!["bitcoin_hashes".to_string()];
let tree = DuplicateTree::parse(output, &allowed);
let dupes = tree.duplicates();
assert!(!dupes.contains_key("bitcoin_hashes"), "allowed duplicate should be filtered");
assert!(dupes.contains_key("hex-conservative"), "non-allowed duplicate should be reported");
}
#[test]
fn stale_allowed_duplicates_reported() {
let output = "\
0hex-conservative v0.1.2
1pkg1 v0.1.0
0hex-conservative v0.2.2
1pkg2 v0.1.0
";
let allowed = vec!["bitcoin_hashes".to_string(), "hex-conservative".to_string()];
let tree = DuplicateTree::parse(output, &allowed);
let stale = tree.stale_allowed_duplicates();
assert_eq!(stale, &["bitcoin_hashes".to_string()]);
assert!(!stale.contains(&"hex-conservative".to_string()));
}
#[test]
fn no_stale_allowed_duplicates_when_all_present() {
let output = "\
0bitcoin_hashes v0.13.0
1pkg1 v0.1.0
0bitcoin_hashes v0.14.1
1pkg2 v0.1.0
";
let allowed = vec!["bitcoin_hashes".to_string()];
let tree = DuplicateTree::parse(output, &allowed);
assert!(tree.stale_allowed_duplicates().is_empty());
}
#[test]
fn empty_allowlist_has_no_stale_entries() {
let output = "\
0foo v0.1.0
1pkg1 v0.1.0
0foo v0.2.0
1pkg2 v0.1.0
";
let tree = DuplicateTree::parse(output, &[]);
assert!(tree.stale_allowed_duplicates().is_empty());
}
}