use std::collections::{HashMap, HashSet};
use crate::models::{DatasourceId, FileInfo, LicenseDetection, Package};
use crate::utils::path::parent_dir;
use crate::utils::spdx::combine_license_expressions;
use super::PackageIx;
use super::classification::is_legal_file;
use super::output_indexes::OutputIndexes;
use super::summary_helpers::unique;
fn is_resolved_dependency_record(package: &Package) -> bool {
!package.datasource_ids.is_empty()
&& package
.datasource_ids
.iter()
.all(|id| matches!(id, DatasourceId::SwiftPackageResolved))
}
pub(super) fn promote_package_metadata_from_key_files(
files: &[FileInfo],
packages: &mut [Package],
indexes: &OutputIndexes,
) {
for (idx, package) in packages.iter_mut().enumerate() {
if is_resolved_dependency_record(package) {
continue;
}
let Some(key_file_indices) = indexes.key_file_indices_for_package(PackageIx(idx)) else {
continue;
};
if package.copyright.is_none() {
package.copyright = key_file_indices
.iter()
.filter_map(|index| files.get(index.0))
.flat_map(|file| file.copyrights.iter())
.map(|copyright| copyright.normalized_text().to_string())
.next();
}
if package.holder.is_none() {
let promoted_holders = unique(
&key_file_indices
.iter()
.filter_map(|index| files.get(index.0))
.flat_map(|file| file.holders.iter())
.map(|holder| holder.holder.clone())
.collect::<Vec<_>>(),
);
if promoted_holders.len() == 1 {
package.holder = promoted_holders.into_iter().next();
}
}
}
}
pub(super) fn promote_package_declared_license_from_legal_files(
files: &[FileInfo],
packages: &mut [Package],
) {
let mut detections_by_dir: HashMap<String, Vec<LicenseDetection>> = HashMap::new();
for file in files.iter().filter(|file| is_legal_file(file)) {
if file.license_detections.is_empty() {
continue;
}
detections_by_dir
.entry(parent_dir(&file.path).to_string())
.or_default()
.extend(file.license_detections.iter().cloned());
}
if detections_by_dir.is_empty() {
return;
}
let mut packages_per_dir: HashMap<String, usize> = HashMap::new();
for package in packages.iter() {
for dir in package_anchor_dirs(package) {
*packages_per_dir.entry(dir).or_insert(0) += 1;
}
}
for package in packages.iter_mut() {
if package.declared_license_expression.is_some()
|| package.declared_license_expression_spdx.is_some()
|| package.extracted_license_statement.is_some()
{
continue;
}
if is_resolved_dependency_record(package) {
continue;
}
let detections: Vec<LicenseDetection> = package_anchor_dirs(package)
.into_iter()
.filter(|dir| packages_per_dir.get(dir).copied() == Some(1))
.filter_map(|dir| detections_by_dir.get(&dir))
.flatten()
.cloned()
.collect();
if detections.is_empty() {
continue;
}
let distinct_expressions = unique(
&detections
.iter()
.map(|detection| detection.license_expression.clone())
.collect::<Vec<_>>(),
);
let [expression] = distinct_expressions.as_slice() else {
continue;
};
package.declared_license_expression = Some(expression.clone());
package.declared_license_expression_spdx = combine_license_expressions(
detections
.iter()
.map(|detection| detection.license_expression_spdx.clone())
.filter(|spdx| !spdx.is_empty()),
);
package.license_detections = detections;
}
}
fn package_anchor_dirs(package: &Package) -> HashSet<String> {
package
.datafile_paths
.iter()
.map(|datafile| parent_dir(datafile).to_string())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::PackageData;
fn package_with_datasources(datasources: &[DatasourceId]) -> Package {
let mut package =
Package::from_package_data(&PackageData::default(), "Package.resolved".to_string());
package.datasource_ids = datasources.to_vec();
package
}
fn legal(path: &str, expression: &str, spdx: &str) -> FileInfo {
let mut file = crate::post_processing::test_utils::file(path);
file.license_detections = vec![LicenseDetection {
license_expression: expression.to_string(),
license_expression_spdx: spdx.to_string(),
matches: Vec::new(),
detection_log: Vec::new(),
identifier: String::new(),
}];
file
}
fn pkg(uid: &str, datafile: &str) -> Package {
crate::post_processing::test_utils::package(uid, datafile)
}
#[test]
fn promotes_single_colocated_legal_file() {
let files = vec![legal("proj/LICENSE", "apache-2.0", "Apache-2.0")];
let mut packages = vec![pkg("a", "proj/go.mod")];
promote_package_declared_license_from_legal_files(&files, &mut packages);
assert_eq!(
packages[0].declared_license_expression.as_deref(),
Some("apache-2.0")
);
assert_eq!(
packages[0].declared_license_expression_spdx.as_deref(),
Some("Apache-2.0")
);
assert_eq!(packages[0].license_detections.len(), 1);
}
#[test]
fn does_not_override_manifest_declared_or_referenced_license() {
let files = vec![legal("proj/LICENSE", "apache-2.0", "Apache-2.0")];
let mut declared = pkg("a", "proj/go.mod");
declared.declared_license_expression = Some("mit".to_string());
declared.declared_license_expression_spdx = Some("MIT".to_string());
let mut extracted_only = pkg("b", "other/go.mod");
extracted_only.extracted_license_statement = Some("see LICENSE".to_string());
let files2 = vec![legal("other/LICENSE", "apache-2.0", "Apache-2.0")];
promote_package_declared_license_from_legal_files(
&files,
std::slice::from_mut(&mut declared),
);
promote_package_declared_license_from_legal_files(
&files2,
std::slice::from_mut(&mut extracted_only),
);
assert_eq!(declared.declared_license_expression.as_deref(), Some("mit"));
assert_eq!(
extracted_only.declared_license_expression, None,
"an extracted-but-unresolved statement still blocks co-hosted promotion"
);
}
#[test]
fn skips_directory_hosting_multiple_packages() {
let files = vec![legal("proj/LICENSE", "apache-2.0", "Apache-2.0")];
let mut packages = vec![pkg("a", "proj/go.mod"), pkg("b", "proj/pom.xml")];
promote_package_declared_license_from_legal_files(&files, &mut packages);
assert_eq!(packages[0].declared_license_expression, None);
assert_eq!(packages[1].declared_license_expression, None);
}
#[test]
fn does_not_smear_root_license_into_nested_subpackage() {
let files = vec![legal("proj/LICENSE", "apache-2.0", "Apache-2.0")];
let mut packages = vec![pkg("root", "proj/go.mod"), pkg("sub", "proj/sub/go.mod")];
promote_package_declared_license_from_legal_files(&files, &mut packages);
assert_eq!(
packages[0].declared_license_expression.as_deref(),
Some("apache-2.0")
);
assert_eq!(
packages[1].declared_license_expression, None,
"nested sub-package must not inherit the root LICENSE"
);
}
#[test]
fn each_package_gets_its_own_colocated_license() {
let files = vec![
legal("proj/LICENSE", "apache-2.0", "Apache-2.0"),
legal("proj/sub/LICENSE", "mit", "MIT"),
];
let mut packages = vec![pkg("root", "proj/go.mod"), pkg("sub", "proj/sub/go.mod")];
promote_package_declared_license_from_legal_files(&files, &mut packages);
assert_eq!(
packages[0].declared_license_expression.as_deref(),
Some("apache-2.0")
);
assert_eq!(
packages[1].declared_license_expression.as_deref(),
Some("mit")
);
}
#[test]
fn abstains_when_colocated_legal_files_disagree() {
let files = vec![
legal("proj/LICENSE-APACHE", "apache-2.0", "Apache-2.0"),
legal("proj/LICENSE-MIT", "mit", "MIT"),
];
let mut packages = vec![pkg("a", "proj/go.mod")];
promote_package_declared_license_from_legal_files(&files, &mut packages);
assert_eq!(
packages[0].declared_license_expression, None,
"conflicting co-hosted licenses are left unset rather than guessed"
);
}
#[test]
fn skips_resolved_dependency_records() {
let files = vec![legal("proj/LICENSE", "apache-2.0", "Apache-2.0")];
let mut package = pkg("a", "proj/Package.resolved");
package.datasource_ids = vec![DatasourceId::SwiftPackageResolved];
promote_package_declared_license_from_legal_files(
&files,
std::slice::from_mut(&mut package),
);
assert_eq!(package.declared_license_expression, None);
}
#[test]
fn resolved_dependency_record_detected_only_when_resolved_only() {
assert!(is_resolved_dependency_record(&package_with_datasources(&[
DatasourceId::SwiftPackageResolved
])));
assert!(!is_resolved_dependency_record(&package_with_datasources(
&[
DatasourceId::SwiftPackageManifestJson,
DatasourceId::SwiftPackageResolved,
]
)));
assert!(!is_resolved_dependency_record(&package_with_datasources(
&[DatasourceId::SwiftPackageManifestJson]
)));
assert!(!is_resolved_dependency_record(&package_with_datasources(
&[]
)));
}
}