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, is_license_family_file};
use super::license_expression_render::{detection_token_maps, render_license_expression};
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 legal_files_by_dir: HashMap<String, Vec<&FileInfo>> = HashMap::new();
for file in files.iter().filter(|file| is_legal_file(file)) {
if file.license_detections.is_empty() {
continue;
}
legal_files_by_dir
.entry(parent_dir(&file.path).to_string())
.or_default()
.push(file);
}
if legal_files_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 colocated_legal_files: Vec<&FileInfo> = package_anchor_dirs(package)
.into_iter()
.filter(|dir| packages_per_dir.get(dir).copied() == Some(1))
.filter_map(|dir| legal_files_by_dir.get(&dir))
.flatten()
.copied()
.collect();
if colocated_legal_files.is_empty() {
continue;
}
let Some((source_legal_files, file_expression)) =
select_legal_files_and_expression(colocated_legal_files)
else {
continue;
};
let detections: Vec<LicenseDetection> = source_legal_files
.iter()
.flat_map(|file| file.license_detections.iter())
.cloned()
.collect();
let (token_to_key, token_to_spdx) = detection_token_maps(&detections);
let Some(declared_key_expression) =
render_license_expression(&file_expression, &token_to_key, false)
.and_then(|key_form| combine_license_expressions([key_form]))
else {
continue;
};
package.declared_license_expression_spdx =
render_license_expression(&file_expression, &token_to_spdx, true)
.and_then(|spdx_form| combine_license_expressions([spdx_form]));
package.declared_license_expression = Some(declared_key_expression);
package.license_detections = detections;
}
}
fn single_declared_expression(legal_files: &[&FileInfo]) -> Option<String> {
let distinct_expressions = unique(
&legal_files
.iter()
.filter_map(|file| file.detected_license_expression.clone())
.collect::<Vec<_>>(),
);
let [expression] = distinct_expressions.as_slice() else {
return None;
};
Some(expression.clone())
}
fn select_legal_files_and_expression(
colocated_legal_files: Vec<&FileInfo>,
) -> Option<(Vec<&FileInfo>, String)> {
if let Some(expression) = single_declared_expression(&colocated_legal_files) {
return Some((colocated_legal_files, expression));
}
let license_family_files: Vec<&FileInfo> = colocated_legal_files
.iter()
.copied()
.filter(|file| is_license_family_file(file))
.collect();
if license_family_files.is_empty() {
return None;
}
let expression = single_declared_expression(&license_family_files)?;
Some((license_family_files, expression))
}
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::license_detection::MatcherKind;
use crate::models::{LineNumber, Match, MatchScore, 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 match_from(from_file: &str, expression: &str, spdx: &str) -> Match {
Match {
license_expression: expression.to_string(),
license_expression_spdx: spdx.to_string(),
from_file: Some(from_file.to_string()),
start_line: LineNumber::ONE,
end_line: LineNumber::ONE,
matcher: MatcherKind::default(),
score: MatchScore::MAX,
matched_length: Some(1),
match_coverage: Some(100.0),
rule_relevance: Some(100),
rule_identifier: String::new(),
rule_url: None,
matched_text: None,
matched_text_diagnostics: None,
referenced_filenames: None,
}
}
fn detection(path: &str, expression: &str, spdx: &str) -> LicenseDetection {
LicenseDetection {
license_expression: expression.to_string(),
license_expression_spdx: spdx.to_string(),
matches: vec![match_from(path, expression, spdx)],
detection_log: Vec::new(),
identifier: String::new(),
}
}
fn legal(path: &str, expression: &str, spdx: &str) -> FileInfo {
let mut file = crate::post_processing::test_utils::file(path);
file.license_detections = vec![detection(path, expression, spdx)];
file.detected_license_expression = Some(expression.to_string());
file
}
fn legal_compound(path: &str, combined: &str, expressions: &[(&str, &str)]) -> FileInfo {
let mut file = crate::post_processing::test_utils::file(path);
file.license_detections = expressions
.iter()
.map(|(expression, spdx)| detection(path, expression, spdx))
.collect();
file.detected_license_expression = Some(combined.to_string());
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 promotes_single_file_compound_license() {
let files = vec![legal_compound(
"proj/LICENSE",
"mpl-2.0 AND bsl-1.1",
&[("mpl-2.0", "MPL-2.0"), ("bsl-1.1", "BUSL-1.1")],
)];
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("bsl-1.1 AND mpl-2.0"),
"a single legal file's compound license is promoted as its own canonically ordered expression"
);
assert_eq!(
packages[0].declared_license_expression_spdx.as_deref(),
Some("BUSL-1.1 AND MPL-2.0")
);
assert_eq!(packages[0].license_detections.len(), 2);
}
#[test]
fn promotes_key_form_even_when_file_expression_is_spdx() {
let files = vec![legal_compound(
"proj/LICENSE",
"BUSL-1.1 AND MPL-2.0", &[("mpl-2.0", "MPL-2.0"), ("bsl-1.1", "BUSL-1.1")],
)];
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("bsl-1.1 AND mpl-2.0"),
"key field must be ScanCode keys from the detections, not the file's SPDX string"
);
assert_eq!(
packages[0].declared_license_expression_spdx.as_deref(),
Some("BUSL-1.1 AND MPL-2.0"),
"SPDX field must be populated and match the key expression's operands"
);
}
#[test]
fn preserves_file_level_or_even_with_separate_detections() {
let files = vec![legal_compound(
"proj/LICENSE",
"apache-2.0 OR mit",
&[("apache-2.0", "Apache-2.0"), ("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.as_deref(),
Some("apache-2.0 OR mit"),
"a file-level OR must not be turned into AND even when its licenses are separate detections"
);
assert_eq!(
packages[0].declared_license_expression_spdx.as_deref(),
Some("Apache-2.0 OR MIT")
);
}
#[test]
fn leaves_spdx_unset_when_a_license_has_no_spdx_id() {
let files = vec![legal("proj/LICENSE", "custom-vendor-license", "")];
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("custom-vendor-license"),
"the key field is still promoted for an unmapped license"
);
assert_eq!(
packages[0].declared_license_expression_spdx, None,
"the SPDX field is absent rather than carrying key-form text for an unmapped license"
);
}
#[test]
fn promotes_single_file_alternative_license_without_forcing_and() {
let files = vec![legal(
"proj/LICENSE",
"apache-2.0 OR mit",
"Apache-2.0 OR 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.as_deref(),
Some("apache-2.0 OR mit"),
"an OR-shaped single-file expression must not be turned into an AND"
);
assert_eq!(
packages[0].declared_license_expression_spdx.as_deref(),
Some("Apache-2.0 OR MIT"),
"the SPDX field must carry the same OR operator as the key expression"
);
}
#[test]
fn promotes_single_file_with_exception_keeps_spdx() {
let files = vec![legal(
"proj/LICENSE",
"gpl-2.0 WITH classpath-exception-2.0",
"GPL-2.0 WITH Classpath-exception-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("gpl-2.0 WITH classpath-exception-2.0")
);
assert_eq!(
packages[0].declared_license_expression_spdx.as_deref(),
Some("GPL-2.0 WITH Classpath-exception-2.0"),
"a compound WITH expression must not drop its SPDX field"
);
}
#[test]
fn abstains_when_multiple_files_disagree_even_with_provenance() {
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,
"two disagreeing legal files stay ambiguous even though each match has a from_file"
);
}
#[test]
fn promotes_when_multiple_files_agree() {
let files = vec![
legal("proj/LICENSE", "apache-2.0", "Apache-2.0"),
legal("proj/COPYING", "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")
);
}
#[test]
fn promotes_when_license_and_notice_agree() {
let files = vec![
legal("LICENSE", "apache-2.0", "Apache-2.0"),
legal("NOTICE", "apache-2.0", "Apache-2.0"),
];
let mut packages = vec![pkg("a", "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].license_detections.len(),
2,
"an agreeing LICENSE+NOTICE set keeps both detections (no narrowing)"
);
}
#[test]
fn prefers_license_over_disagreeing_notice() {
let files = vec![
legal("LICENSE", "apache-2.0", "Apache-2.0"),
legal(
"NOTICE",
"mit AND apache-2.0 AND bsd-new",
"MIT AND Apache-2.0 AND BSD-3-Clause",
),
];
let mut packages = vec![pkg("a", "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"),
"the canonical LICENSE wins over a bundled-attribution NOTICE"
);
assert_eq!(
packages[0].declared_license_expression_spdx.as_deref(),
Some("Apache-2.0")
);
assert_eq!(
packages[0].license_detections.len(),
1,
"only the LICENSE detection is promoted, not the NOTICE detections"
);
}
#[test]
fn license_family_precedence_does_not_override_dual_license_files() {
let files = vec![
legal("LICENSE-APACHE", "apache-2.0", "Apache-2.0"),
legal("LICENSE-MIT", "mit", "MIT"),
legal("NOTICE", "apache-2.0", "Apache-2.0"),
];
let mut packages = vec![pkg("a", "go.mod")];
promote_package_declared_license_from_legal_files(&files, &mut packages);
assert_eq!(
packages[0].declared_license_expression, None,
"two disagreeing LICENSE-* files stay ambiguous; NOTICE does not break the tie"
);
}
#[test]
fn promotes_from_notice_when_no_license_family_file_present() {
let files = vec![legal("NOTICE", "apache-2.0", "Apache-2.0")];
let mut packages = vec![pkg("a", "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"),
"a NOTICE-only directory still promotes when no LICENSE family file exists"
);
}
#[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(
&[]
)));
}
}