use std::fs;
use std::path::{Path, PathBuf};
use std::result::Result as StdResult;
use anyhow::{Context as _, Result};
use ignore::WalkBuilder;
use crate::backup;
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct Package {
pub bin_names: Vec<String>,
pub lib_modules: Vec<LibModuleDecl>,
pub manifest_path: PathBuf,
pub name: String,
pub root: PathBuf,
pub src_files: Vec<PathBuf>,
pub tests_files: Vec<PathBuf>,
pub uses_lib_aggregation: bool,
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct LibModuleDecl {
pub attrs: Vec<String>,
pub ident: String,
pub rel_path: String,
}
#[inline]
pub fn discover(repo_root: &Path) -> Result<Vec<Package>> {
let metadata = cargo_metadata::MetadataCommand::new()
.current_dir(repo_root)
.no_deps()
.exec()
.with_context(|| format!("running cargo metadata in {}", repo_root.display()))?;
let mut packages = Vec::new();
for pkg in &metadata.packages {
let manifest_path: PathBuf = pkg.manifest_path.clone().into();
let Some(root) = manifest_path.parent().map(Path::to_path_buf) else {
continue;
};
let src_files = collect_rs(&root.join("src"));
let tests_files = collect_rs(&root.join("tests"));
let lib_modules = collect_lib_modules(&root);
let uses_lib_aggregation = needs_lib_aggregation(&root, &lib_modules);
let mut bin_names: Vec<String> = pkg
.targets
.iter()
.filter(|target| {
target
.kind
.iter()
.any(|kind| matches!(kind, cargo_metadata::TargetKind::Bin))
})
.map(|target| target.name.clone())
.collect();
bin_names.sort();
bin_names.dedup();
packages.push(Package {
bin_names,
lib_modules,
manifest_path,
name: pkg.name.to_string(),
root,
src_files,
tests_files,
uses_lib_aggregation,
});
}
packages.sort_by(|left, right| left.name.cmp(&right.name));
Ok(packages)
}
fn collect_rs(dir: &Path) -> Vec<PathBuf> {
if !dir.exists() {
return Vec::new();
}
let walker = WalkBuilder::new(dir)
.standard_filters(true)
.hidden(false)
.git_ignore(true)
.git_global(false)
.git_exclude(true)
.follow_links(false)
.build();
let mut files: Vec<PathBuf> = walker
.filter_map(StdResult::ok)
.filter(|entry| entry.path().is_file())
.map(ignore::DirEntry::into_path)
.filter(|path| path.extension().is_some_and(|ext| ext == "rs") && !is_backup_file(path))
.collect();
files.sort();
files
}
fn collect_lib_modules(pkg_root: &Path) -> Vec<LibModuleDecl> {
let lib_rs = pkg_root.join("src/lib.rs");
if !lib_rs.exists() {
return Vec::new();
}
let Ok(source) = fs::read_to_string(&lib_rs) else {
return Vec::new();
};
let Ok(tree) = syn::parse_file(&source) else {
return Vec::new();
};
let mut out = Vec::new();
for item in &tree.items {
let syn::Item::Mod(decl) = item else { continue };
if decl.content.is_some() {
continue;
}
let ident = decl.ident.to_string();
let rel_path = if let Some(custom) = extract_path_attr(&decl.attrs) {
format!("src/{custom}")
} else {
let leaf = pkg_root.join(format!("src/{ident}.rs"));
let folder = pkg_root.join(format!("src/{ident}/mod.rs"));
if leaf.is_file() {
format!("src/{ident}.rs")
} else if folder.is_file() {
format!("src/{ident}/mod.rs")
} else {
continue;
}
};
let attrs: Vec<String> = decl
.attrs
.iter()
.filter(|attr| !is_path_attr(attr))
.map(|attr| quote::ToTokens::to_token_stream(attr).to_string())
.collect();
out.push(LibModuleDecl {
attrs,
ident,
rel_path,
});
}
out
}
fn extract_path_attr(attrs: &[syn::Attribute]) -> Option<String> {
for attr in attrs {
if !is_path_attr(attr) {
continue;
}
if let syn::Meta::NameValue(name_value) = &attr.meta
&& let syn::Expr::Lit(expr_lit) = &name_value.value
&& let syn::Lit::Str(literal) = &expr_lit.lit
{
return Some(literal.value());
}
}
None
}
fn is_backup_file(path: &Path) -> bool {
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.ends_with(backup::SUFFIX))
}
fn is_path_attr(attr: &syn::Attribute) -> bool {
attr.path().is_ident("path")
}
fn needs_lib_aggregation(pkg_root: &Path, lib_modules: &[LibModuleDecl]) -> bool {
let lib_rs = pkg_root.join("src/lib.rs");
if !lib_rs.is_file() {
return false;
}
let Ok(source) = fs::read_to_string(&lib_rs) else {
return false;
};
let Ok(tree) = syn::parse_file(&source) else {
return false;
};
for item in &tree.items {
match item {
syn::Item::Mod(_) | syn::Item::Use(_) | syn::Item::ExternCrate(_) => {}
_other @ (syn::Item::Const(_)
| syn::Item::Enum(_)
| syn::Item::Fn(_)
| syn::Item::ForeignMod(_)
| syn::Item::Impl(_)
| syn::Item::Macro(_)
| syn::Item::Static(_)
| syn::Item::Struct(_)
| syn::Item::Trait(_)
| syn::Item::TraitAlias(_)
| syn::Item::Type(_)
| syn::Item::Union(_)
| syn::Item::Verbatim(_))
| _other => return true,
}
}
for decl in lib_modules {
if decl.rel_path.ends_with("/mod.rs") {
return true;
}
let file = pkg_root.join(&decl.rel_path);
if let Ok(submodule_source) = fs::read_to_string(&file)
&& let Ok(submodule_tree) = syn::parse_file(&submodule_source)
{
for item in &submodule_tree.items {
if let syn::Item::Mod(sub) = item
&& sub.content.is_none()
&& extract_path_attr(&sub.attrs).is_none()
{
return true;
}
}
}
}
false
}