use std::collections::BTreeMap;
use std::io;
use camino::{Utf8Path, Utf8PathBuf};
use crate::builder::{library_extension, library_prefix};
use crate::crate_name::CrateName;
use crate::resolution::{EXPERIMENTAL_LINT_CRATES, LINT_CRATES, SUITE_CRATE};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InstalledLibrary {
pub crate_name: CrateName,
pub toolchain: String,
pub path: Utf8PathBuf,
}
#[derive(Debug, Clone, Default)]
pub struct InstalledLints {
pub by_toolchain: BTreeMap<String, Vec<InstalledLibrary>>,
}
impl InstalledLints {
#[must_use]
pub fn is_empty(&self) -> bool {
self.by_toolchain.is_empty()
}
}
pub fn scan_installed(target_dir: &Utf8Path) -> io::Result<InstalledLints> {
let mut result = InstalledLints::default();
if !target_dir.exists() {
return Ok(result);
}
for entry in target_dir.read_dir_utf8()? {
let entry = entry?;
let toolchain_path = entry.path();
if !toolchain_path.is_dir() {
continue;
}
let toolchain = entry.file_name().to_owned();
let libraries = scan_toolchain_layouts(toolchain_path, &toolchain)?;
if !libraries.is_empty() {
result.by_toolchain.insert(toolchain, libraries);
}
}
Ok(result)
}
fn scan_toolchain_layouts(
toolchain_path: &Utf8Path,
toolchain: &str,
) -> io::Result<Vec<InstalledLibrary>> {
let mut libraries = Vec::new();
let release_path = toolchain_path.join("release");
if release_path.is_dir() {
libraries.extend(scan_toolchain_release(&release_path, toolchain)?);
}
for entry in toolchain_path.read_dir_utf8()? {
let entry = entry?;
if !entry.path().is_dir() || entry.file_name() == "release" {
continue;
}
let lib_path = entry.path().join("lib");
if lib_path.is_dir() && contains_libraries_in_layout(&lib_path)? {
libraries.extend(scan_toolchain_release(&lib_path, toolchain)?);
}
}
libraries.sort_by(|left, right| left.crate_name.as_str().cmp(right.crate_name.as_str()));
Ok(libraries)
}
fn contains_libraries_in_layout(lib_path: &Utf8Path) -> io::Result<bool> {
for entry in lib_path.read_dir_utf8()? {
let entry = entry?;
if entry.path().is_file() && parse_library_filename(entry.file_name()).is_some() {
return Ok(true);
}
}
Ok(false)
}
fn scan_toolchain_release(
release_path: &Utf8Path,
toolchain: &str,
) -> io::Result<Vec<InstalledLibrary>> {
let mut libraries = Vec::new();
for entry in release_path.read_dir_utf8()? {
let entry = entry?;
let file_name = entry.file_name();
if let Some((crate_name, parsed_toolchain)) = parse_library_filename(file_name) {
if parsed_toolchain == toolchain {
libraries.push(InstalledLibrary {
crate_name,
toolchain: parsed_toolchain,
path: entry.path().to_owned(),
});
}
}
}
Ok(libraries)
}
#[must_use]
pub fn parse_library_filename(filename: &str) -> Option<(CrateName, String)> {
let prefix = library_prefix();
let extension = library_extension();
let without_prefix = filename.strip_prefix(prefix)?;
let without_ext = without_prefix.strip_suffix(extension)?;
let at_pos = without_ext.find('@')?;
let crate_name = &without_ext[..at_pos];
let toolchain = &without_ext[at_pos + 1..];
if crate_name.is_empty() || toolchain.is_empty() {
return None;
}
Some((CrateName::from(crate_name), toolchain.to_owned()))
}
#[must_use]
pub fn lints_for_library(crate_name: &CrateName) -> Vec<&'static str> {
lints_for_library_with_experimental(crate_name, false)
}
#[must_use]
pub fn lints_for_library_with_experimental(
crate_name: &CrateName,
include_experimental: bool,
) -> Vec<&'static str> {
let name = crate_name.as_str();
if name == SUITE_CRATE {
let mut lints = LINT_CRATES.to_vec();
if include_experimental {
lints.extend(EXPERIMENTAL_LINT_CRATES);
}
lints
} else if let Some(&static_name) = LINT_CRATES.iter().find(|&&s| s == name) {
vec![static_name]
} else if let Some(&static_name) = EXPERIMENTAL_LINT_CRATES.iter().find(|&&s| s == name) {
vec![static_name]
} else {
Vec::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
use tempfile::TempDir;
macro_rules! skip_unless_linux {
() => {
if !cfg!(target_os = "linux") {
return;
}
};
}
#[rstest]
#[case::standard_linux(
"libmodule_max_lines@nightly-2026-05-28.so",
"module_max_lines",
"nightly-2026-05-28"
)]
#[case::suite(
"libwhitaker_suite@nightly-2026-05-28.so",
"whitaker_suite",
"nightly-2026-05-28"
)]
#[case::stable_toolchain(
"libno_expect_outside_tests@stable-1.80.0.so",
"no_expect_outside_tests",
"stable-1.80.0"
)]
fn parse_library_filename_valid(
#[case] filename: &str,
#[case] expected_crate: &str,
#[case] expected_toolchain: &str,
) {
skip_unless_linux!();
let result = parse_library_filename(filename);
assert!(result.is_some(), "expected Some for {filename}");
let (crate_name, toolchain) = result.expect("already checked");
assert_eq!(crate_name.as_str(), expected_crate);
assert_eq!(toolchain, expected_toolchain);
}
#[rstest]
#[case::no_at_sign("libmodule_max_lines.so")]
#[case::empty_crate("lib@nightly-2026-05-28.so")]
#[case::empty_toolchain("libmodule_max_lines@.so")]
#[case::wrong_prefix("module_max_lines@nightly-2026-05-28.so")]
#[case::wrong_extension("libmodule_max_lines@nightly-2026-05-28.dll")]
#[case::random_file("readme.txt")]
fn parse_library_filename_invalid(#[case] filename: &str) {
skip_unless_linux!();
let result = parse_library_filename(filename);
assert!(result.is_none(), "expected None for {filename}");
}
#[test]
fn lints_for_suite_returns_standard_lints_only() {
let lints = lints_for_library(&CrateName::from("whitaker_suite"));
assert_eq!(lints.len(), LINT_CRATES.len());
for lint in LINT_CRATES {
assert!(lints.contains(lint), "missing standard lint: {lint}");
}
for lint in EXPERIMENTAL_LINT_CRATES {
assert!(
!lints.contains(lint),
"suite should not report experimental lint: {lint}"
);
}
}
#[test]
fn lints_for_suite_includes_experimental_when_requested() {
let lints = lints_for_library_with_experimental(&CrateName::from("whitaker_suite"), true);
assert_eq!(
lints.len(),
LINT_CRATES.len() + EXPERIMENTAL_LINT_CRATES.len(),
"suite should report standard and experimental lints"
);
for lint in LINT_CRATES {
assert!(lints.contains(lint), "missing standard lint: {lint}");
}
for lint in EXPERIMENTAL_LINT_CRATES {
assert!(
lints.contains(lint),
"suite should include experimental lint when requested: {lint}"
);
}
}
#[test]
fn lints_for_individual_crate_returns_single_lint() {
let lints = lints_for_library(&CrateName::from("module_max_lines"));
assert_eq!(lints, vec!["module_max_lines"]);
}
#[test]
fn lints_for_bumpy_road_crate_returns_single_lint() {
let lints = lints_for_library(&CrateName::from("bumpy_road_function"));
assert_eq!(lints, vec!["bumpy_road_function"]);
}
#[test]
fn lints_for_unknown_crate_returns_empty() {
let lints = lints_for_library(&CrateName::from("unknown_crate"));
assert!(lints.is_empty());
}
#[test]
fn scan_empty_directory_returns_empty() {
let temp = TempDir::new().expect("failed to create temp dir");
let target_dir = Utf8Path::from_path(temp.path()).expect("non-UTF8 path");
let result = scan_installed(target_dir).expect("scan should succeed");
assert!(result.is_empty());
}
#[test]
fn scan_nonexistent_directory_returns_empty() {
let result =
scan_installed(Utf8Path::new("/nonexistent/path")).expect("scan should succeed");
assert!(result.is_empty());
}
#[test]
fn scan_finds_installed_libraries() {
skip_unless_linux!();
let temp = TempDir::new().expect("failed to create temp dir");
let target_dir = Utf8Path::from_path(temp.path()).expect("non-UTF8 path");
let toolchain = "nightly-2026-05-28";
let release_dir = target_dir.join(toolchain).join("release");
std::fs::create_dir_all(&release_dir).expect("failed to create dirs");
let lib_name = format!("libwhitaker_suite@{toolchain}.so");
std::fs::write(release_dir.join(&lib_name), b"fake").expect("failed to write file");
let result = scan_installed(target_dir).expect("scan should succeed");
assert!(!result.is_empty());
assert!(result.by_toolchain.contains_key(toolchain));
let libs = result
.by_toolchain
.get(toolchain)
.expect("toolchain should exist");
assert_eq!(libs.len(), 1);
assert_eq!(libs[0].crate_name.as_str(), "whitaker_suite");
assert_eq!(libs[0].toolchain, toolchain);
}
}