use crate::config::Accept;
use spdx::expression::{ExprNode, Operator};
use std::collections::BTreeSet;
pub fn canonical_text(id: &str) -> Option<&'static str> {
spdx::license_id(id).map(|l| l.text()).or_else(|| spdx::exception_id(id).map(|e| e.text()))
}
pub fn license_name(req: &spdx::LicenseReq) -> String {
req.license.id().map_or_else(|| req.license.to_string(), |id| id.name.to_string())
}
pub fn exceptions_for(expr: &spdx::Expression, chosen: &BTreeSet<String>) -> Vec<String> {
expr.iter()
.filter_map(|node| match node {
ExprNode::Req(r) => {
let ex = r.req.addition.as_ref()?.id()?;
let lic = r.req.license.id()?.name;
chosen.contains(lic).then(|| ex.name.to_string())
}
ExprNode::Op(_) => None,
})
.collect()
}
pub fn choose(expr: &spdx::Expression, accepted: &[Accept]) -> Option<BTreeSet<String>> {
let mut stack: Vec<Option<BTreeSet<String>>> = Vec::new();
for node in expr.iter() {
match node {
ExprNode::Req(req) => {
let ex = req.req.addition.as_ref().and_then(|a| a.id()).map(|e| e.name);
let name = license_name(&req.req);
let leaf = accepted.iter().any(|a| a.allows(&name, ex)).then(|| BTreeSet::from([name]));
stack.push(leaf);
}
ExprNode::Op(op) => {
let b = stack.pop()?;
let a = stack.pop()?;
stack.push(combine(*op, a, b, accepted));
}
}
}
stack.pop().flatten()
}
fn combine(
op: Operator,
a: Option<BTreeSet<String>>,
b: Option<BTreeSet<String>>,
accepted: &[Accept],
) -> Option<BTreeSet<String>> {
match op {
Operator::And => match (a, b) {
(Some(mut x), Some(y)) => {
x.extend(y);
Some(x)
}
_ => None,
},
Operator::Or => match (a, b) {
(Some(x), Some(y)) => Some(if best(&x, accepted) <= best(&y, accepted) { x } else { y }),
(Some(x), None) | (None, Some(x)) => Some(x),
(None, None) => None,
},
}
}
fn best(set: &BTreeSet<String>, accepted: &[Accept]) -> usize {
set.iter().map(|l| accepted.iter().position(|a| a.license == *l).unwrap_or(usize::MAX)).min().unwrap_or(usize::MAX)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{DEFAULT_ACCEPTED, parse_accept};
fn pick_with(accepted: &[&str], s: &str) -> Option<Vec<String>> {
let acc: Vec<Accept> = accepted.iter().map(|s| parse_accept(s)).collect();
let e = spdx::Expression::parse_mode(s, spdx::ParseMode::LAX).unwrap();
choose(&e, &acc).map(|set| set.into_iter().collect())
}
fn pick(s: &str) -> Option<Vec<String>> {
pick_with(DEFAULT_ACCEPTED, s)
}
#[test]
fn or_picks_preferred() {
assert_eq!(pick("MIT OR Apache-2.0"), Some(vec!["MIT".into()]));
assert_eq!(pick("Apache-2.0 OR MIT"), Some(vec!["MIT".into()]));
assert_eq!(pick("Zlib OR Apache-2.0 OR MIT"), Some(vec!["MIT".into()]));
}
#[test]
fn and_unions_both() {
assert_eq!(pick("(MIT OR Apache-2.0) AND Unicode-3.0"), Some(vec!["MIT".into(), "Unicode-3.0".into()]));
}
#[test]
fn legacy_slash_is_or() {
assert_eq!(pick("MIT/Apache-2.0"), Some(vec!["MIT".into()]));
assert_eq!(pick("Unlicense/MIT"), Some(vec!["MIT".into()]));
}
#[test]
fn rejects_unaccepted() {
assert_eq!(pick("GPL-3.0-only"), None);
assert_eq!(pick("MIT AND GPL-3.0-only"), None);
}
#[test]
fn canonical_text_covers_spdx_licenses_and_exceptions() {
assert!(canonical_text("MIT").is_some());
assert!(canonical_text("MPL-2.0").is_some()); assert!(canonical_text("LLVM-exception").is_some()); assert!(canonical_text("NotARealLicense").is_none());
}
#[test]
fn exceptions_collected_only_for_the_chosen_license() {
let expr = |s: &str| spdx::Expression::parse_mode(s, spdx::ParseMode::LAX).unwrap();
let chosen: BTreeSet<String> = ["Apache-2.0".to_string()].into_iter().collect();
assert_eq!(exceptions_for(&expr("Apache-2.0 WITH LLVM-exception"), &chosen), vec!["LLVM-exception"]);
let mit_chosen: BTreeSet<String> = ["MIT".to_string()].into_iter().collect();
assert!(exceptions_for(&expr("(GPL-2.0 WITH GCC-exception-2.0) OR MIT"), &mit_chosen).is_empty());
}
#[test]
fn with_exception_attributes_the_base_license() {
assert_eq!(pick("Apache-2.0 WITH LLVM-exception"), Some(vec!["Apache-2.0".into()]));
assert_eq!(pick("GPL-3.0-only WITH Classpath-exception-2.0"), None); }
#[test]
fn accepted_with_pairing_allows_only_that_pairing() {
let acc = &["MIT", "GPL-2.0-only WITH Classpath-exception-2.0"];
assert_eq!(pick_with(acc, "GPL-2.0-only WITH Classpath-exception-2.0"), Some(vec!["GPL-2.0-only".into()]));
assert_eq!(pick_with(acc, "GPL-2.0-only"), None); assert_eq!(pick_with(acc, "GPL-2.0-only WITH GCC-exception-2.0"), None); assert_eq!(pick_with(acc, "(GPL-2.0-only WITH Classpath-exception-2.0) OR MIT"), Some(vec!["MIT".into()]));
}
#[test]
fn licenseref_matches_accepted_by_name() {
assert_eq!(pick_with(&["LicenseRef-weird"], "LicenseRef-weird"), Some(vec!["LicenseRef-weird".into()]));
assert_eq!(pick_with(&["MIT"], "LicenseRef-weird"), None);
assert_eq!(pick_with(&["MIT"], "MIT OR LicenseRef-weird"), Some(vec!["MIT".into()]));
}
#[test]
fn exception_allow_extends_accepted_per_crate() {
let acc = &["MIT", "MPL-2.0"];
assert_eq!(pick_with(acc, "MPL-2.0"), Some(vec!["MPL-2.0".into()]));
assert_eq!(pick_with(&["MIT"], "MPL-2.0"), None); assert_eq!(pick_with(acc, "MPL-2.0 OR MIT"), Some(vec!["MIT".into()]));
}
}