use std::collections::{HashMap, HashSet};
#[derive(Debug, Clone, PartialEq)]
pub enum SpdxExpression {
License(String),
WithException { license: String, exception: String },
Or(Box<Self>, Box<Self>),
And(Box<Self>, Box<Self>),
}
impl SpdxExpression {
pub(crate) fn parse(expr: &str) -> Self {
let expr = expr.trim();
if let Some(pos) = find_operator(expr, " OR ") {
let left = &expr[..pos];
let right = &expr[pos + 4..];
return Self::Or(Box::new(Self::parse(left)), Box::new(Self::parse(right)));
}
if let Some(pos) = find_operator(expr, " AND ") {
let left = &expr[..pos];
let right = &expr[pos + 5..];
return Self::And(Box::new(Self::parse(left)), Box::new(Self::parse(right)));
}
if let Some(pos) = expr.to_uppercase().find(" WITH ") {
let license = expr[..pos].trim().to_string();
let exception = expr[pos + 6..].trim().to_string();
return Self::WithException { license, exception };
}
let expr = expr.trim_start_matches('(').trim_end_matches(')').trim();
Self::License(expr.to_string())
}
pub(crate) fn is_choice(&self) -> bool {
match self {
Self::Or(_, _) => true,
Self::And(left, right) => left.is_choice() || right.is_choice(),
_ => false,
}
}
}
fn find_operator(expr: &str, op: &str) -> Option<usize> {
let upper = expr.to_uppercase();
let mut depth = 0;
for (i, c) in expr.chars().enumerate() {
match c {
'(' => depth += 1,
')' => depth -= 1,
_ => {}
}
if depth == 0 && upper[i..].starts_with(op) {
return Some(i);
}
}
None
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum LicenseCategory {
Permissive,
WeakCopyleft,
StrongCopyleft,
NetworkCopyleft,
Proprietary,
PublicDomain,
Unknown,
}
impl LicenseCategory {
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::Permissive => "Permissive",
Self::WeakCopyleft => "Weak Copyleft",
Self::StrongCopyleft => "Copyleft",
Self::NetworkCopyleft => "Network Copyleft",
Self::Proprietary => "Proprietary",
Self::PublicDomain => "Public Domain",
Self::Unknown => "Unknown",
}
}
pub(crate) const fn copyleft_strength(self) -> u8 {
match self {
Self::PublicDomain | Self::Permissive | Self::Unknown => 0,
Self::WeakCopyleft => 1,
Self::StrongCopyleft => 2,
Self::NetworkCopyleft => 3,
Self::Proprietary => 4, }
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum RiskLevel {
Low,
Medium,
High,
Critical,
}
impl RiskLevel {
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::Low => "Low",
Self::Medium => "Medium",
Self::High => "High",
Self::Critical => "Critical",
}
}
}
#[derive(Debug, Clone)]
pub struct LicenseInfo {
pub category: LicenseCategory,
pub risk_level: RiskLevel,
pub patent_grant: bool,
pub network_copyleft: bool,
pub family: &'static str,
}
impl LicenseInfo {
pub(crate) fn from_spdx(spdx_id: &str) -> Self {
let lower = spdx_id.to_lowercase();
if lower.contains("mit") {
return Self::permissive("MIT", false);
}
if lower.contains("apache") {
return Self {
category: LicenseCategory::Permissive,
risk_level: RiskLevel::Low,
patent_grant: true,
network_copyleft: false,
family: "Apache",
};
}
if lower.contains("bsd") {
let has_advertising = lower.contains("4-clause") || lower.contains("original");
return Self {
category: LicenseCategory::Permissive,
risk_level: if has_advertising {
RiskLevel::Medium
} else {
RiskLevel::Low
},
patent_grant: false,
network_copyleft: false,
family: "BSD",
};
}
if lower.contains("isc")
|| lower.contains("unlicense")
|| lower.contains("cc0")
|| lower.contains("wtfpl")
|| lower.contains("zlib")
{
let family = if lower.contains("cc0") {
"Creative Commons"
} else if lower.contains("zlib") {
"Zlib"
} else {
"Public Domain-like"
};
return Self {
category: if lower.contains("cc0") || lower.contains("unlicense") {
LicenseCategory::PublicDomain
} else {
LicenseCategory::Permissive
},
risk_level: RiskLevel::Low,
patent_grant: false,
network_copyleft: false,
family,
};
}
if lower.contains("agpl") {
return Self {
category: LicenseCategory::NetworkCopyleft,
risk_level: RiskLevel::Critical,
patent_grant: lower.contains('3'),
network_copyleft: true,
family: "GPL",
};
}
if lower.contains("lgpl") {
return Self {
category: LicenseCategory::WeakCopyleft,
risk_level: RiskLevel::Medium,
patent_grant: lower.contains('3'),
network_copyleft: false,
family: "GPL",
};
}
if lower.contains("gpl") {
return Self {
category: LicenseCategory::StrongCopyleft,
risk_level: RiskLevel::High,
patent_grant: lower.contains('3'),
network_copyleft: false,
family: "GPL",
};
}
if lower.contains("mpl") || lower.contains("mozilla") {
return Self {
category: LicenseCategory::WeakCopyleft,
risk_level: RiskLevel::Medium,
patent_grant: true,
network_copyleft: false,
family: "MPL",
};
}
if lower.contains("eclipse") || lower.contains("epl") {
return Self {
category: LicenseCategory::WeakCopyleft,
risk_level: RiskLevel::Medium,
patent_grant: true,
network_copyleft: false,
family: "Eclipse",
};
}
if lower.contains("cddl") {
return Self {
category: LicenseCategory::WeakCopyleft,
risk_level: RiskLevel::Medium,
patent_grant: true,
network_copyleft: false,
family: "CDDL",
};
}
if lower.contains("proprietary")
|| lower.contains("commercial")
|| lower.contains("private")
{
return Self {
category: LicenseCategory::Proprietary,
risk_level: RiskLevel::Critical,
patent_grant: false,
network_copyleft: false,
family: "Proprietary",
};
}
Self {
category: LicenseCategory::Unknown,
risk_level: RiskLevel::Medium,
patent_grant: false,
network_copyleft: false,
family: "Unknown",
}
}
const fn permissive(family: &'static str, patent_grant: bool) -> Self {
Self {
category: LicenseCategory::Permissive,
risk_level: RiskLevel::Low,
patent_grant,
network_copyleft: false,
family,
}
}
}
#[derive(Debug, Clone)]
pub struct CompatibilityResult {
pub compatible: bool,
pub score: u8,
pub warnings: Vec<String>,
}
pub fn check_compatibility(license_a: &str, license_b: &str) -> CompatibilityResult {
let info_a = LicenseInfo::from_spdx(license_a);
let info_b = LicenseInfo::from_spdx(license_b);
let mut warnings = Vec::new();
let mut compatible = true;
let mut score = 100u8;
if (info_a.category == LicenseCategory::Proprietary
|| info_b.category == LicenseCategory::Proprietary)
&& info_a.category != info_b.category
{
compatible = false;
score = 0;
warnings.push(format!(
"Proprietary license '{}' incompatible with '{}'",
if info_a.category == LicenseCategory::Proprietary {
license_a
} else {
license_b
},
if info_a.category == LicenseCategory::Proprietary {
license_b
} else {
license_a
}
));
}
if info_a.family == "GPL" || info_b.family == "GPL" {
let a_lower = license_a.to_lowercase();
let b_lower = license_b.to_lowercase();
if (a_lower.contains("gpl-2.0-only") && b_lower.contains("gpl-3"))
|| (b_lower.contains("gpl-2.0-only") && a_lower.contains("gpl-3"))
{
compatible = false;
score = 0;
warnings.push("GPL-2.0-only is incompatible with GPL-3.0".to_string());
}
if ((info_a.family == "Apache" && b_lower.contains("gpl-2"))
|| (info_b.family == "Apache" && a_lower.contains("gpl-2")))
&& !a_lower.contains("gpl-3")
&& !b_lower.contains("gpl-3")
{
warnings.push("Apache-2.0 has patent clauses incompatible with GPL-2.0".to_string());
score = score.saturating_sub(30);
}
}
if info_a.network_copyleft || info_b.network_copyleft {
warnings.push(
"Network copyleft license (AGPL) requires source disclosure for network use"
.to_string(),
);
score = score.saturating_sub(20);
}
if info_a.category != info_b.category {
let strength_diff = (info_a.category.copyleft_strength() as i8
- info_b.category.copyleft_strength() as i8)
.unsigned_abs();
if strength_diff > 1 {
warnings.push(format!(
"Mixing {} ({}) with {} ({}) may have licensing implications",
license_a,
info_a.category.as_str(),
license_b,
info_b.category.as_str()
));
score = score.saturating_sub(strength_diff * 10);
}
}
CompatibilityResult {
compatible,
score,
warnings,
}
}
pub fn analyze_license_compatibility(licenses: &[&str]) -> LicenseCompatibilityReport {
let mut issues = Vec::new();
let mut families: HashMap<&'static str, Vec<String>> = HashMap::new();
let mut categories: HashMap<LicenseCategory, Vec<String>> = HashMap::new();
for license in licenses {
let info = LicenseInfo::from_spdx(license);
families
.entry(info.family)
.or_default()
.push(license.to_string());
categories
.entry(info.category)
.or_default()
.push(license.to_string());
}
let unique: Vec<_> = licenses
.iter()
.collect::<HashSet<_>>()
.into_iter()
.collect();
for (i, &license_a) in unique.iter().enumerate() {
for &license_b in unique.iter().skip(i + 1) {
let result = check_compatibility(license_a, license_b);
if !result.compatible || result.score < 70 {
issues.push(CompatibilityIssue {
severity: if result.compatible {
IssueSeverity::Warning
} else {
IssueSeverity::Error
},
message: result.warnings.join("; "),
});
}
}
}
let overall_score = if issues.iter().any(|i| i.severity == IssueSeverity::Error) {
0
} else {
let warning_count = issues
.iter()
.filter(|i| i.severity == IssueSeverity::Warning)
.count();
100u8.saturating_sub((warning_count * 15) as u8)
};
LicenseCompatibilityReport {
overall_score,
issues,
families,
categories,
}
}
#[derive(Debug)]
pub struct LicenseCompatibilityReport {
pub overall_score: u8,
pub issues: Vec<CompatibilityIssue>,
pub families: HashMap<&'static str, Vec<String>>,
pub categories: HashMap<LicenseCategory, Vec<String>>,
}
#[derive(Debug, Clone)]
pub struct CompatibilityIssue {
pub severity: IssueSeverity,
pub message: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IssueSeverity {
Warning,
Error,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_spdx_parse_simple() {
let expr = SpdxExpression::parse("MIT");
assert_eq!(expr, SpdxExpression::License("MIT".to_string()));
}
#[test]
fn test_spdx_parse_or() {
let expr = SpdxExpression::parse("MIT OR Apache-2.0");
assert!(matches!(expr, SpdxExpression::Or(_, _)));
assert!(expr.is_choice());
}
#[test]
fn test_spdx_parse_with() {
let expr = SpdxExpression::parse("GPL-2.0 WITH Classpath-exception-2.0");
assert!(matches!(expr, SpdxExpression::WithException { .. }));
}
#[test]
fn test_license_category() {
assert_eq!(
LicenseInfo::from_spdx("MIT").category,
LicenseCategory::Permissive
);
assert_eq!(
LicenseInfo::from_spdx("GPL-3.0").category,
LicenseCategory::StrongCopyleft
);
assert_eq!(
LicenseInfo::from_spdx("LGPL-2.1").category,
LicenseCategory::WeakCopyleft
);
assert_eq!(
LicenseInfo::from_spdx("AGPL-3.0").category,
LicenseCategory::NetworkCopyleft
);
}
#[test]
fn test_compatibility_mit_apache() {
let result = check_compatibility("MIT", "Apache-2.0");
assert!(result.compatible);
assert!(result.score > 80);
}
#[test]
fn test_compatibility_gpl_proprietary() {
let result = check_compatibility("GPL-3.0", "Proprietary");
assert!(!result.compatible);
assert_eq!(result.score, 0);
}
}