use super::types::LicenseDetection;
use super::*;
use crate::license_detection::expression::{
combine_expressions_and_preserving_structure, combine_expressions_or_preserving_structure,
};
use crate::license_detection::models::{LicenseMatch, MatcherKind};
use crate::utils::spdx::{
ExpressionRelation, combine_license_expressions_preserving_structure_strict,
combine_license_expressions_with_relation_preserving_structure_strict,
};
pub const IMPERFECT_MATCH_COVERAGE_THR: f32 = 100.0;
pub const CLUES_MATCH_COVERAGE_THR: f32 = 60.0;
pub const FALSE_POSITIVE_RULE_LENGTH_THRESHOLD: usize = 3;
pub const FALSE_POSITIVE_START_LINE_THRESHOLD: usize = 1000;
pub(super) fn is_match_coverage_below_threshold(
matches: &[LicenseMatch],
threshold: f32,
any_matches: bool,
) -> bool {
if any_matches {
return matches.iter().any(|m| m.coverage() < threshold);
}
!matches.iter().any(|m| m.coverage() > threshold)
}
pub(super) fn has_unknown_matches(matches: &[LicenseMatch]) -> bool {
matches
.iter()
.any(|m| m.rule_identifier.contains("unknown") || m.license_expression.contains("unknown"))
}
pub(super) fn has_extra_words(matches: &[LicenseMatch]) -> bool {
matches.iter().any(|m| {
let score_coverage_relevance =
f64::from(m.coverage()) * f64::from(m.rule_relevance) / 100.0;
score_coverage_relevance - m.score.value() > 0.01
})
}
pub(super) fn is_false_positive(matches: &[LicenseMatch]) -> bool {
if matches.is_empty() {
return false;
}
let has_full_relevance = matches.iter().all(|m| m.rule_relevance == 100);
let copyright_words = ["copyright", "(c)"];
let has_copyrights = matches.iter().all(|m| {
m.matched_text
.as_ref()
.map(|text| {
let text_lower = text.to_lowercase();
copyright_words.iter().any(|word| text_lower.contains(word))
})
.unwrap_or(false)
});
if has_copyrights || has_full_relevance {
return false;
}
let start_line = matches
.iter()
.map(|m| m.start_line)
.min()
.map(|ln| ln.get())
.unwrap_or(0);
let bare_rules = ["gpl_bare", "freeware_bare", "public-domain_bare"];
let is_bare_rule = matches.iter().all(|m| {
bare_rules
.iter()
.any(|bare| m.rule_identifier.to_lowercase().contains(bare))
});
let is_gpl = matches.iter().all(|m| {
let id = m.rule_identifier.to_lowercase();
id.contains("gpl") && !id.contains("lgpl")
});
let rule_length_values: Vec<usize> = matches.iter().map(|m| m.rule_length).collect();
let all_rule_length_one = rule_length_values.iter().all(|&l| l == 1);
let all_low_relevance = matches.iter().all(|m| m.rule_relevance < 60);
let all_exact_spdx_license_id_rules = matches
.iter()
.all(|m| m.rule_identifier.starts_with("spdx_license_id_"));
let is_single = matches.len() == 1;
let all_is_license_tag = matches.iter().all(LicenseMatch::is_license_tag);
if is_single && is_bare_rule && all_low_relevance {
return true;
}
if is_gpl && all_rule_length_one {
return true;
}
if all_low_relevance
&& start_line > FALSE_POSITIVE_START_LINE_THRESHOLD
&& !all_exact_spdx_license_id_rules
&& rule_length_values
.iter()
.any(|&l| l <= FALSE_POSITIVE_RULE_LENGTH_THRESHOLD)
{
return true;
}
if all_is_license_tag && all_rule_length_one {
return true;
}
false
}
pub(super) fn is_low_quality_matches(matches: &[LicenseMatch]) -> bool {
matches.iter().all(|m| {
m.coverage() < CLUES_MATCH_COVERAGE_THR
|| (m.coverage() < IMPERFECT_MATCH_COVERAGE_THR
&& has_extra_words(std::slice::from_ref(m)))
})
}
pub(super) fn has_correct_license_clue_matches(matches: &[LicenseMatch]) -> bool {
!matches.is_empty()
&& matches.iter().all(|m| {
matches!(
m.matcher,
MatcherKind::Hash | MatcherKind::SpdxId | MatcherKind::Aho
)
})
&& matches.iter().all(|m| m.coverage() == 100.0)
&& matches.iter().all(LicenseMatch::is_license_clue)
}
pub(super) fn is_undetected_license_matches(matches: &[LicenseMatch]) -> bool {
!matches.is_empty() && matches.iter().all(|m| m.matcher == MatcherKind::Undetected)
}
pub(super) fn has_unknown_intro_before_detection(matches: &[LicenseMatch]) -> bool {
if matches.len() == 1 {
return false;
}
if matches.iter().all(is_unknown_intro) {
return false;
}
for m in matches {
if m.matcher == MatcherKind::Undetected {
continue;
}
let has_unknown = m.license_expression.contains("unknown");
let is_intro =
m.is_license_intro() || m.is_license_clue() || m.license_expression == "free-unknown";
if has_unknown && is_intro {
let has_unknown_intro = matches.iter().any(|other| {
other.matcher != MatcherKind::Undetected
&& other.start_line > m.start_line
&& !other.rule_identifier.contains("unknown")
&& !other.license_expression.contains("unknown")
&& !other.is_license_intro()
&& !other.is_license_clue()
});
if has_unknown_intro {
let coverage_ok = m.coverage() >= IMPERFECT_MATCH_COVERAGE_THR;
let not_unknown = !m.rule_identifier.contains("unknown")
&& !m.license_expression.contains("unknown");
if coverage_ok && not_unknown {
return true;
}
}
}
}
if matches.iter().any(is_unknown_intro) {
let filtered_matches = filter_license_intros(matches);
if filtered_matches.len() != matches.len()
&& is_match_coverage_below_threshold(
&filtered_matches,
IMPERFECT_MATCH_COVERAGE_THR,
false,
)
{
return true;
}
}
false
}
pub(super) fn is_unknown_intro(m: &LicenseMatch) -> bool {
let has_unknown = m.license_expression.contains("unknown");
has_unknown
&& (m.is_license_intro() || m.is_license_clue() || m.license_expression == "free-unknown")
}
pub(super) fn is_license_intro(match_item: &LicenseMatch) -> bool {
(match_item.is_license_intro()
|| match_item.is_license_clue()
|| match_item.license_expression == "free-unknown")
&& (match_item.matcher == MatcherKind::Aho || match_item.coverage() == 100.0)
}
pub(super) fn filter_license_intros(matches: &[LicenseMatch]) -> Vec<LicenseMatch> {
matches
.iter()
.filter(|m| !is_license_intro(m))
.cloned()
.collect()
}
pub(super) fn is_license_reference_local_file(m: &LicenseMatch) -> bool {
m.referenced_filenames
.as_ref()
.is_some_and(|v| !v.is_empty())
}
#[cfg(test)]
pub(super) fn filter_license_references(matches: &[LicenseMatch]) -> Vec<LicenseMatch> {
matches
.iter()
.filter(|m| !is_license_reference_local_file(m))
.cloned()
.collect()
}
fn has_references_to_local_files(matches: &[LicenseMatch]) -> bool {
matches.iter().any(is_license_reference_local_file)
}
pub(super) fn analyze_detection(matches: &[LicenseMatch], package_license: bool) -> &'static str {
if matches.is_empty() {
return "";
}
if is_undetected_license_matches(matches) {
return DETECTION_LOG_UNDETECTED_LICENSE;
}
if has_unknown_intro_before_detection(matches) {
return "unknown-intro-followed-by-match";
}
if has_references_to_local_files(matches) {
return "unknown-reference-to-local-file";
}
if !package_license && has_correct_license_clue_matches(matches) {
return DETECTION_LOG_LICENSE_CLUES;
}
if !package_license && is_false_positive(matches) {
return "false-positive";
}
if is_correct_detection_non_unknown(matches) {
return "";
}
if has_unknown_matches(matches) {
return DETECTION_LOG_UNKNOWN_MATCH;
}
if !package_license && is_low_quality_matches(matches) {
return "low-quality-match-fragments";
}
if matches
.iter()
.any(|m| m.coverage() < IMPERFECT_MATCH_COVERAGE_THR)
{
return DETECTION_LOG_IMPERFECT_COVERAGE;
}
if has_extra_words(matches) {
return DETECTION_LOG_EXTRA_WORDS;
}
""
}
fn is_correct_detection_non_unknown(matches: &[LicenseMatch]) -> bool {
matches.iter().all(|m| m.coverage() == 100.0)
&& !has_unknown_matches(matches)
&& !has_extra_words(matches)
}
pub fn compute_detection_score(matches: &[LicenseMatch]) -> f32 {
if matches.is_empty() {
return 0.0;
}
let total_length: f64 = matches.iter().map(|m| m.matched_length as f64).sum();
if total_length == 0.0 {
return 0.0;
}
let weighted_score: f64 = matches
.iter()
.map(|m| m.score.value() * (m.matched_length as f64 / total_length))
.sum();
((weighted_score * 100.0).round() / 100.0).min(100.0) as f32
}
pub fn determine_license_expression(
matches: &[LicenseMatch],
source_text: Option<&str>,
) -> Result<String, String> {
if matches.is_empty() {
return Err("No matches to determine expression from".to_string());
}
if let Some(expr) = determine_alternative_notice_expression(matches, source_text)? {
return Ok(expr);
}
let expressions: Vec<&str> = matches
.iter()
.map(|m| m.license_expression.as_str())
.collect();
combine_expressions_and_preserving_structure(&expressions, true)
.map_err(|e| format!("Failed to combine expressions: {}", e))
}
pub fn determine_spdx_expression(
matches: &[LicenseMatch],
source_text: Option<&str>,
) -> Result<String, String> {
if matches.is_empty() {
return Err("No matches to determine SPDX expression from".to_string());
}
if let Some(expr) = determine_alternative_notice_spdx_expression(matches, source_text)? {
return Ok(expr);
}
let expressions: Option<Vec<&str>> = matches
.iter()
.map(|m| m.license_expression_spdx.as_deref())
.collect();
let expressions = expressions
.ok_or_else(|| "Missing SPDX expressions for one or more matches".to_string())?;
combine_license_expressions_preserving_structure_strict(
expressions.into_iter().map(str::to_string),
)
.ok_or_else(|| "Failed to combine SPDX expressions".to_string())
}
fn determine_alternative_notice_expression(
matches: &[LicenseMatch],
source_text: Option<&str>,
) -> Result<Option<String>, String> {
if !has_alternative_license_notice(matches, source_text) {
return Ok(None);
}
let (substantive, supplemental): (Vec<&LicenseMatch>, Vec<&LicenseMatch>) = matches
.iter()
.partition(|m| !is_supplemental_alternative_match(m.license_expression.as_str()));
if substantive.len() < 2 {
return Ok(None);
}
let alternative_expressions: Vec<&str> = substantive
.iter()
.map(|m| m.license_expression.as_str())
.collect();
let alternative_expression =
combine_expressions_or_preserving_structure(&alternative_expressions, true)
.map_err(|e| format!("Failed to combine alternative expressions: {}", e))?;
let mut parts = vec![alternative_expression];
parts.extend(
supplemental
.iter()
.map(|m| m.license_expression.clone())
.collect::<Vec<_>>(),
);
let part_refs: Vec<&str> = parts.iter().map(String::as_str).collect();
combine_expressions_and_preserving_structure(&part_refs, true)
.map(Some)
.map_err(|e| format!("Failed to combine alternative expression parts: {}", e))
}
fn determine_alternative_notice_spdx_expression(
matches: &[LicenseMatch],
source_text: Option<&str>,
) -> Result<Option<String>, String> {
if !has_alternative_license_notice(matches, source_text) {
return Ok(None);
}
let (substantive, supplemental): (Vec<&LicenseMatch>, Vec<&LicenseMatch>) = matches
.iter()
.partition(|m| !is_supplemental_alternative_match(m.license_expression.as_str()));
if substantive.len() < 2 {
return Ok(None);
}
let alternative_expressions: Option<Vec<String>> = substantive
.iter()
.map(|m| m.license_expression_spdx.clone())
.collect();
let alternative_expressions = alternative_expressions.ok_or_else(|| {
"Missing SPDX expressions for one or more alternative-license matches".to_string()
})?;
let alternative_expression =
combine_license_expressions_with_relation_preserving_structure_strict(
alternative_expressions,
ExpressionRelation::Or,
)
.ok_or_else(|| "Failed to combine alternative SPDX expressions".to_string())?;
let mut parts = vec![alternative_expression];
let supplemental_expressions: Option<Vec<String>> = supplemental
.iter()
.map(|m| m.license_expression_spdx.clone())
.collect();
parts.extend(supplemental_expressions.ok_or_else(|| {
"Missing SPDX expressions for one or more supplemental matches".to_string()
})?);
combine_license_expressions_with_relation_preserving_structure_strict(
parts,
ExpressionRelation::And,
)
.ok_or_else(|| "Failed to combine alternative SPDX expression parts".to_string())
.map(Some)
}
fn has_alternative_license_notice(matches: &[LicenseMatch], source_text: Option<&str>) -> bool {
if matches.len() < 2 {
return false;
}
let Some(source_text) = source_text else {
return false;
};
let start_line = matches
.iter()
.map(|m| m.start_line)
.min()
.map(|ln| ln.get())
.unwrap_or(0);
let end_line = matches
.iter()
.map(|m| m.end_line)
.max()
.map(|ln| ln.get())
.unwrap_or(0);
if start_line == 0 || end_line < start_line {
return false;
}
let region = source_text
.lines()
.skip(start_line.saturating_sub(1))
.take(end_line - start_line + 1)
.collect::<Vec<_>>()
.join("\n")
.to_ascii_lowercase();
let python_alternative_notice =
region.contains("alternatively") && region.contains("may be used under the terms of");
let rust_dual_license_notice = (region.contains("licensed under either of")
&& region.contains("at your option"))
|| region.contains("dual-licensed under")
|| region.contains("dual licensed under");
python_alternative_notice || rust_dual_license_notice
}
fn is_supplemental_alternative_match(expression: &str) -> bool {
expression.contains("warranty-disclaimer")
}
pub fn determine_spdx_expression_from_scancode(
scancode_expression: &str,
spdx_mapping: &SpdxMapping,
) -> Result<String, String> {
if scancode_expression.is_empty() {
return Ok(String::new());
}
spdx_mapping
.expression_scancode_to_spdx(scancode_expression)
.map_err(|e| e.to_string())
}
pub(super) fn classify_detection(detection: &LicenseDetection, min_score: f32) -> bool {
if detection.matches.is_empty() {
return false;
}
let score = compute_detection_score(&detection.matches);
let meets_score_threshold = score >= min_score - 0.01;
let is_true_license_clue = has_correct_license_clue_matches(&detection.matches);
let not_false_positive = is_true_license_clue || !is_false_positive(&detection.matches);
meets_score_threshold && not_false_positive
}
#[cfg(test)]
#[path = "analysis_test.rs"]
mod tests;