use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use crate::models::{
DatasourceId, Dependency, FileInfo, Package, PackageData, PackageUid, TopLevelDependency,
};
pub(super) struct MixUmbrellaRootHint {
pub(super) root_dir: PathBuf,
pub(super) root_mix_exs_idx: usize,
pub(super) apps_path: String,
pub(super) apps_filter: Option<HashSet<String>>,
}
pub(super) struct MixUmbrellaMemberDomain {
pub(super) manifest_idx: usize,
pub(super) dir_path: PathBuf,
pub(super) local_mix_lock_idx: Option<usize>,
}
pub(super) struct MixUmbrellaDomain {
pub(super) root_dir: PathBuf,
pub(super) apps_dir: PathBuf,
pub(super) root_mix_exs_idx: usize,
pub(super) root_mix_lock_idx: Option<usize>,
pub(super) members: Vec<MixUmbrellaMemberDomain>,
}
struct Candidate {
package: Package,
direct_dep_app_names: HashSet<String>,
}
pub(super) fn collect_mix_umbrella_hints(files: &[FileInfo]) -> Vec<MixUmbrellaRootHint> {
let mut hints = Vec::new();
for (idx, file) in files.iter().enumerate() {
let path = Path::new(&file.path);
if path.file_name().and_then(|name| name.to_str()) != Some("mix.exs") {
continue;
}
for pkg_data in &file.package_data {
if pkg_data.datasource_id != Some(DatasourceId::HexMixExs) {
continue;
}
let Some(extra_data) = &pkg_data.extra_data else {
continue;
};
let Some(apps_path) = extra_data.get("apps_path").and_then(|v| v.as_str()) else {
continue;
};
let Some(parent) = path.parent() else {
continue;
};
let apps_filter = extra_data
.get("apps")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect::<HashSet<_>>()
});
hints.push(MixUmbrellaRootHint {
root_dir: parent.to_path_buf(),
root_mix_exs_idx: idx,
apps_path: apps_path.to_string(),
apps_filter,
});
}
}
hints.sort_by(|left, right| left.root_dir.cmp(&right.root_dir));
hints
}
pub(super) fn plan_mix_umbrella_domains(
files: &[FileInfo],
root_hints: &[&MixUmbrellaRootHint],
) -> Vec<MixUmbrellaDomain> {
let mut domains = Vec::new();
for hint in root_hints {
let apps_dir = hint.root_dir.join(&hint.apps_path);
let members = discover_members(files, &apps_dir, hint.apps_filter.as_ref());
if members.is_empty() {
continue;
}
domains.push(MixUmbrellaDomain {
root_dir: hint.root_dir.clone(),
apps_dir,
root_mix_exs_idx: hint.root_mix_exs_idx,
root_mix_lock_idx: find_mix_lock_index(files, &hint.root_dir),
members,
});
}
domains.sort_by(|left, right| left.root_dir.cmp(&right.root_dir));
domains
}
fn discover_members(
files: &[FileInfo],
apps_dir: &Path,
apps_filter: Option<&HashSet<String>>,
) -> Vec<MixUmbrellaMemberDomain> {
let mut members = Vec::new();
for (idx, file) in files.iter().enumerate() {
let path = Path::new(&file.path);
if path.file_name().and_then(|name| name.to_str()) != Some("mix.exs") {
continue;
}
let Some(parent) = path.parent() else {
continue;
};
if parent.parent() != Some(apps_dir) {
continue;
}
let has_valid_package = file
.package_data
.iter()
.any(|pkg| pkg.datasource_id == Some(DatasourceId::HexMixExs) && pkg.purl.is_some());
if !has_valid_package {
continue;
}
if let Some(filter) = apps_filter {
let dir_name = parent
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("");
if !filter.contains(dir_name) {
continue;
}
}
members.push(MixUmbrellaMemberDomain {
manifest_idx: idx,
dir_path: parent.to_path_buf(),
local_mix_lock_idx: find_mix_lock_index(files, parent),
});
}
members.sort_by(|left, right| {
files[left.manifest_idx]
.path
.cmp(&files[right.manifest_idx].path)
});
members
}
fn find_mix_lock_index(files: &[FileInfo], dir: &Path) -> Option<usize> {
files.iter().position(|file| {
let path = Path::new(&file.path);
path.parent() == Some(dir)
&& path.file_name().and_then(|name| name.to_str()) == Some("mix.lock")
})
}
pub(super) fn apply_mix_umbrella_domain(
domain: &MixUmbrellaDomain,
files: &mut [FileInfo],
packages: &mut Vec<Package>,
dependencies: &mut Vec<TopLevelDependency>,
) {
let member_pkg_data: Vec<PackageData> = domain
.members
.iter()
.filter_map(|member| {
files[member.manifest_idx]
.package_data
.iter()
.find(|pkg| {
pkg.datasource_id == Some(DatasourceId::HexMixExs) && pkg.purl.is_some()
})
.cloned()
})
.collect();
if member_pkg_data.len() != domain.members.len() {
return;
}
let root_pkg_data = files[domain.root_mix_exs_idx]
.package_data
.iter()
.find(|pkg| pkg.datasource_id == Some(DatasourceId::HexMixExs))
.cloned();
let root_has_identity = root_pkg_data
.as_ref()
.is_some_and(|pkg_data| pkg_data.purl.is_some());
let mut candidates: Vec<Candidate> = Vec::new();
if root_has_identity {
let root_pkg_data = root_pkg_data.as_ref().expect("checked above");
candidates.push(Candidate {
package: Package::from_package_data(
root_pkg_data,
files[domain.root_mix_exs_idx].path.clone(),
),
direct_dep_app_names: direct_dep_app_names(root_pkg_data),
});
}
let root_candidate_len = candidates.len();
for (member, pkg_data) in domain.members.iter().zip(member_pkg_data.iter()) {
candidates.push(Candidate {
package: Package::from_package_data(pkg_data, files[member.manifest_idx].path.clone()),
direct_dep_app_names: direct_dep_app_names(pkg_data),
});
}
let identity_by_app_name: HashMap<String, (String, String)> = candidates
.iter()
.filter_map(|candidate| {
let name = candidate.package.name.clone()?;
let purl = candidate.package.purl.clone()?;
let version = candidate.package.version.clone().unwrap_or_default();
Some((name, (purl, version)))
})
.collect();
if let Some(root_pkg_data) = &root_pkg_data {
let root_owner_uid = root_has_identity.then(|| candidates[0].package.package_uid.clone());
emit_direct_dependencies(
root_pkg_data,
&files[domain.root_mix_exs_idx].path.clone(),
root_owner_uid,
&identity_by_app_name,
dependencies,
);
}
for (offset, member) in domain.members.iter().enumerate() {
let candidate = &candidates[root_candidate_len + offset];
emit_direct_dependencies(
&member_pkg_data[offset],
&files[member.manifest_idx].path.clone(),
Some(candidate.package.package_uid.clone()),
&identity_by_app_name,
dependencies,
);
}
if let Some(lock_idx) = domain.root_mix_lock_idx {
let lock_path = files[lock_idx].path.clone();
let lock_deps: Vec<Dependency> = files[lock_idx]
.package_data
.iter()
.find(|pkg| pkg.datasource_id == Some(DatasourceId::HexMixLock))
.map(|pkg| pkg.dependencies.clone())
.unwrap_or_default();
for dep in &lock_deps {
let Some(app_name) = dep_app_name(dep) else {
continue;
};
let owners: Vec<&Candidate> = candidates
.iter()
.filter(|candidate| candidate.direct_dep_app_names.contains(&app_name))
.collect();
if owners.is_empty() {
dependencies.push(TopLevelDependency::from_dependency(
dep,
lock_path.clone(),
DatasourceId::HexMixLock,
None,
));
continue;
}
for owner in owners {
dependencies.push(TopLevelDependency::from_dependency(
dep,
lock_path.clone(),
DatasourceId::HexMixLock,
Some(owner.package.package_uid.clone()),
));
}
}
}
for (offset, member) in domain.members.iter().enumerate() {
let Some(lock_idx) = member.local_mix_lock_idx else {
continue;
};
let lock_path = files[lock_idx].path.clone();
let lock_deps: Vec<Dependency> = files[lock_idx]
.package_data
.iter()
.find(|pkg| pkg.datasource_id == Some(DatasourceId::HexMixLock))
.map(|pkg| pkg.dependencies.clone())
.unwrap_or_default();
let owner_uid = candidates[root_candidate_len + offset]
.package
.package_uid
.clone();
for dep in &lock_deps {
dependencies.push(TopLevelDependency::from_dependency(
dep,
lock_path.clone(),
DatasourceId::HexMixLock,
Some(owner_uid.clone()),
));
}
}
let root_package_uid = root_has_identity.then(|| candidates[0].package.package_uid.clone());
let member_uids: Vec<PackageUid> = (0..domain.members.len())
.map(|offset| {
candidates[root_candidate_len + offset]
.package
.package_uid
.clone()
})
.collect();
for candidate in candidates {
packages.push(candidate.package);
}
assign_files_to_umbrella_packages(
files,
&domain.root_dir,
&domain.apps_dir,
&domain.members,
&member_uids,
root_package_uid.as_ref(),
);
}
fn direct_dep_app_names(pkg_data: &PackageData) -> HashSet<String> {
pkg_data
.dependencies
.iter()
.filter(|dep| !is_in_umbrella(dep))
.filter_map(dep_app_name)
.collect()
}
fn is_in_umbrella(dep: &Dependency) -> bool {
dep.extra_data
.as_ref()
.and_then(|extra| extra.get("in_umbrella"))
.and_then(|value| value.as_bool())
== Some(true)
}
fn dep_app_name(dep: &Dependency) -> Option<String> {
dep.extra_data
.as_ref()?
.get("app")?
.as_str()
.map(str::to_string)
}
fn emit_direct_dependencies(
pkg_data: &PackageData,
manifest_path: &str,
owner_uid: Option<PackageUid>,
identity_by_app_name: &HashMap<String, (String, String)>,
dependencies: &mut Vec<TopLevelDependency>,
) {
for dep in &pkg_data.dependencies {
if is_in_umbrella(dep) {
let Some(app_name) = dep_app_name(dep) else {
continue;
};
let Some((purl, version)) = identity_by_app_name.get(&app_name) else {
continue;
};
let mut resolved = dep.clone();
resolved.purl = Some(purl.clone());
resolved.extracted_requirement = if version.is_empty() {
None
} else {
Some(version.clone())
};
dependencies.push(TopLevelDependency::from_dependency(
&resolved,
manifest_path.to_string(),
DatasourceId::HexMixExs,
owner_uid.clone(),
));
continue;
}
if dep.purl.is_some() {
dependencies.push(TopLevelDependency::from_dependency(
dep,
manifest_path.to_string(),
DatasourceId::HexMixExs,
owner_uid.clone(),
));
}
}
}
fn assign_files_to_umbrella_packages(
files: &mut [FileInfo],
root_dir: &Path,
apps_dir: &Path,
members: &[MixUmbrellaMemberDomain],
member_uids: &[PackageUid],
root_package_uid: Option<&PackageUid>,
) {
const BUILD_OUTPUT_DIRS: [&str; 2] = ["_build", "deps"];
for file in files.iter_mut() {
let path = Path::new(&file.path);
if !path.starts_with(root_dir) {
continue;
}
let mut assigned = false;
for (member, uid) in members.iter().zip(member_uids.iter()) {
if path.starts_with(&member.dir_path) {
if !file.for_packages.contains(uid) {
file.for_packages.push(uid.clone());
}
assigned = true;
break;
}
}
if assigned {
continue;
}
if path.starts_with(apps_dir) {
continue;
}
if let Ok(relative) = path.strip_prefix(root_dir)
&& let Some(first_component) = relative.components().next()
&& BUILD_OUTPUT_DIRS.contains(&first_component.as_os_str().to_string_lossy().as_ref())
{
continue;
}
if let Some(root_uid) = root_package_uid {
if !file.for_packages.contains(root_uid) {
file.for_packages.push(root_uid.clone());
}
} else {
for uid in member_uids {
if !file.for_packages.contains(uid) {
file.for_packages.push(uid.clone());
}
}
}
}
}