1use std::collections::{HashMap, HashSet};
7
8#[derive(Debug, Clone, PartialEq)]
10pub enum SpdxExpression {
11 License(String),
13 WithException { license: String, exception: String },
15 Or(Box<SpdxExpression>, Box<SpdxExpression>),
17 And(Box<SpdxExpression>, Box<SpdxExpression>),
19}
20
21impl SpdxExpression {
22 pub fn parse(expr: &str) -> Self {
24 let expr = expr.trim();
25
26 if let Some(pos) = find_operator(expr, " OR ") {
28 let left = &expr[..pos];
29 let right = &expr[pos + 4..];
30 return SpdxExpression::Or(
31 Box::new(SpdxExpression::parse(left)),
32 Box::new(SpdxExpression::parse(right)),
33 );
34 }
35
36 if let Some(pos) = find_operator(expr, " AND ") {
38 let left = &expr[..pos];
39 let right = &expr[pos + 5..];
40 return SpdxExpression::And(
41 Box::new(SpdxExpression::parse(left)),
42 Box::new(SpdxExpression::parse(right)),
43 );
44 }
45
46 if let Some(pos) = expr.to_uppercase().find(" WITH ") {
48 let license = expr[..pos].trim().to_string();
49 let exception = expr[pos + 6..].trim().to_string();
50 return SpdxExpression::WithException { license, exception };
51 }
52
53 let expr = expr.trim_start_matches('(').trim_end_matches(')').trim();
55
56 SpdxExpression::License(expr.to_string())
58 }
59
60 pub fn licenses(&self) -> Vec<&str> {
62 match self {
63 SpdxExpression::License(l) => vec![l.as_str()],
64 SpdxExpression::WithException { license, .. } => vec![license.as_str()],
65 SpdxExpression::Or(left, right) | SpdxExpression::And(left, right) => {
66 let mut result = left.licenses();
67 result.extend(right.licenses());
68 result
69 }
70 }
71 }
72
73 pub fn is_choice(&self) -> bool {
75 match self {
76 SpdxExpression::Or(_, _) => true,
77 SpdxExpression::And(left, right) => left.is_choice() || right.is_choice(),
78 _ => false,
79 }
80 }
81
82 pub fn expression_type(&self) -> &'static str {
84 match self {
85 SpdxExpression::License(_) => "Single License",
86 SpdxExpression::WithException { .. } => "License with Exception",
87 SpdxExpression::Or(_, _) => "Dual/Multi License (Choice)",
88 SpdxExpression::And(_, _) => "Combined License (All Apply)",
89 }
90 }
91}
92
93fn find_operator(expr: &str, op: &str) -> Option<usize> {
95 let upper = expr.to_uppercase();
96 let mut depth = 0;
97
98 for (i, c) in expr.chars().enumerate() {
99 match c {
100 '(' => depth += 1,
101 ')' => depth -= 1,
102 _ => {}
103 }
104 if depth == 0 && upper[i..].starts_with(op) {
105 return Some(i);
106 }
107 }
108 None
109}
110
111#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
113pub enum LicenseCategory {
114 Permissive,
115 WeakCopyleft,
116 StrongCopyleft,
117 NetworkCopyleft,
118 Proprietary,
119 PublicDomain,
120 Unknown,
121}
122
123impl LicenseCategory {
124 pub fn as_str(&self) -> &'static str {
125 match self {
126 LicenseCategory::Permissive => "Permissive",
127 LicenseCategory::WeakCopyleft => "Weak Copyleft",
128 LicenseCategory::StrongCopyleft => "Copyleft",
129 LicenseCategory::NetworkCopyleft => "Network Copyleft",
130 LicenseCategory::Proprietary => "Proprietary",
131 LicenseCategory::PublicDomain => "Public Domain",
132 LicenseCategory::Unknown => "Unknown",
133 }
134 }
135
136 pub fn copyleft_strength(&self) -> u8 {
138 match self {
139 LicenseCategory::PublicDomain | LicenseCategory::Permissive => 0,
140 LicenseCategory::WeakCopyleft => 1,
141 LicenseCategory::StrongCopyleft => 2,
142 LicenseCategory::NetworkCopyleft => 3,
143 LicenseCategory::Proprietary => 4, LicenseCategory::Unknown => 0,
145 }
146 }
147}
148
149#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
151pub enum RiskLevel {
152 Low,
153 Medium,
154 High,
155 Critical,
156}
157
158impl RiskLevel {
159 pub fn as_str(&self) -> &'static str {
160 match self {
161 RiskLevel::Low => "Low",
162 RiskLevel::Medium => "Medium",
163 RiskLevel::High => "High",
164 RiskLevel::Critical => "Critical",
165 }
166 }
167}
168
169#[derive(Debug, Clone)]
171pub struct LicenseInfo {
172 pub spdx_id: String,
174 pub category: LicenseCategory,
176 pub risk_level: RiskLevel,
178 pub requires_attribution: bool,
180 pub requires_source_disclosure: bool,
182 pub patent_grant: bool,
184 pub modifications_must_be_disclosed: bool,
186 pub same_license_for_derivatives: bool,
188 pub network_copyleft: bool,
190 pub family: &'static str,
192}
193
194impl LicenseInfo {
195 pub fn from_spdx(spdx_id: &str) -> Self {
197 let lower = spdx_id.to_lowercase();
198
199 if lower.contains("mit") {
201 return Self::permissive("MIT", false);
202 }
203
204 if lower.contains("apache") {
206 return Self {
207 spdx_id: spdx_id.to_string(),
208 category: LicenseCategory::Permissive,
209 risk_level: RiskLevel::Low,
210 requires_attribution: true,
211 requires_source_disclosure: false,
212 patent_grant: true,
213 modifications_must_be_disclosed: false,
214 same_license_for_derivatives: false,
215 network_copyleft: false,
216 family: "Apache",
217 };
218 }
219
220 if lower.contains("bsd") {
222 let has_advertising = lower.contains("4-clause") || lower.contains("original");
223 return Self {
224 spdx_id: spdx_id.to_string(),
225 category: LicenseCategory::Permissive,
226 risk_level: if has_advertising {
227 RiskLevel::Medium
228 } else {
229 RiskLevel::Low
230 },
231 requires_attribution: true,
232 requires_source_disclosure: false,
233 patent_grant: false,
234 modifications_must_be_disclosed: false,
235 same_license_for_derivatives: false,
236 network_copyleft: false,
237 family: "BSD",
238 };
239 }
240
241 if lower.contains("isc")
243 || lower.contains("unlicense")
244 || lower.contains("cc0")
245 || lower.contains("wtfpl")
246 || lower.contains("zlib")
247 {
248 let family = if lower.contains("cc0") {
249 "Creative Commons"
250 } else if lower.contains("zlib") {
251 "Zlib"
252 } else {
253 "Public Domain-like"
254 };
255 return Self {
256 spdx_id: spdx_id.to_string(),
257 category: if lower.contains("cc0") || lower.contains("unlicense") {
258 LicenseCategory::PublicDomain
259 } else {
260 LicenseCategory::Permissive
261 },
262 risk_level: RiskLevel::Low,
263 requires_attribution: !lower.contains("cc0") && !lower.contains("unlicense"),
264 requires_source_disclosure: false,
265 patent_grant: false,
266 modifications_must_be_disclosed: false,
267 same_license_for_derivatives: false,
268 network_copyleft: false,
269 family,
270 };
271 }
272
273 if lower.contains("agpl") {
275 return Self {
276 spdx_id: spdx_id.to_string(),
277 category: LicenseCategory::NetworkCopyleft,
278 risk_level: RiskLevel::Critical,
279 requires_attribution: true,
280 requires_source_disclosure: true,
281 patent_grant: lower.contains("3"),
282 modifications_must_be_disclosed: true,
283 same_license_for_derivatives: true,
284 network_copyleft: true,
285 family: "GPL",
286 };
287 }
288
289 if lower.contains("lgpl") {
291 return Self {
292 spdx_id: spdx_id.to_string(),
293 category: LicenseCategory::WeakCopyleft,
294 risk_level: RiskLevel::Medium,
295 requires_attribution: true,
296 requires_source_disclosure: true,
297 patent_grant: lower.contains("3"),
298 modifications_must_be_disclosed: true,
299 same_license_for_derivatives: true, network_copyleft: false,
301 family: "GPL",
302 };
303 }
304
305 if lower.contains("gpl") {
307 return Self {
308 spdx_id: spdx_id.to_string(),
309 category: LicenseCategory::StrongCopyleft,
310 risk_level: RiskLevel::High,
311 requires_attribution: true,
312 requires_source_disclosure: true,
313 patent_grant: lower.contains("3"),
314 modifications_must_be_disclosed: true,
315 same_license_for_derivatives: true,
316 network_copyleft: false,
317 family: "GPL",
318 };
319 }
320
321 if lower.contains("mpl") || lower.contains("mozilla") {
323 return Self {
324 spdx_id: spdx_id.to_string(),
325 category: LicenseCategory::WeakCopyleft,
326 risk_level: RiskLevel::Medium,
327 requires_attribution: true,
328 requires_source_disclosure: true,
329 patent_grant: true,
330 modifications_must_be_disclosed: true,
331 same_license_for_derivatives: false, network_copyleft: false,
333 family: "MPL",
334 };
335 }
336
337 if lower.contains("eclipse") || lower.contains("epl") {
339 return Self {
340 spdx_id: spdx_id.to_string(),
341 category: LicenseCategory::WeakCopyleft,
342 risk_level: RiskLevel::Medium,
343 requires_attribution: true,
344 requires_source_disclosure: true,
345 patent_grant: true,
346 modifications_must_be_disclosed: true,
347 same_license_for_derivatives: false,
348 network_copyleft: false,
349 family: "Eclipse",
350 };
351 }
352
353 if lower.contains("cddl") {
355 return Self {
356 spdx_id: spdx_id.to_string(),
357 category: LicenseCategory::WeakCopyleft,
358 risk_level: RiskLevel::Medium,
359 requires_attribution: true,
360 requires_source_disclosure: true,
361 patent_grant: true,
362 modifications_must_be_disclosed: true,
363 same_license_for_derivatives: false,
364 network_copyleft: false,
365 family: "CDDL",
366 };
367 }
368
369 if lower.contains("proprietary")
371 || lower.contains("commercial")
372 || lower.contains("private")
373 {
374 return Self {
375 spdx_id: spdx_id.to_string(),
376 category: LicenseCategory::Proprietary,
377 risk_level: RiskLevel::Critical,
378 requires_attribution: false,
379 requires_source_disclosure: false,
380 patent_grant: false,
381 modifications_must_be_disclosed: false,
382 same_license_for_derivatives: false,
383 network_copyleft: false,
384 family: "Proprietary",
385 };
386 }
387
388 Self {
390 spdx_id: spdx_id.to_string(),
391 category: LicenseCategory::Unknown,
392 risk_level: RiskLevel::Medium, requires_attribution: true, requires_source_disclosure: false,
395 patent_grant: false,
396 modifications_must_be_disclosed: false,
397 same_license_for_derivatives: false,
398 network_copyleft: false,
399 family: "Unknown",
400 }
401 }
402
403 fn permissive(family: &'static str, patent_grant: bool) -> Self {
404 Self {
405 spdx_id: family.to_string(),
406 category: LicenseCategory::Permissive,
407 risk_level: RiskLevel::Low,
408 requires_attribution: true,
409 requires_source_disclosure: false,
410 patent_grant,
411 modifications_must_be_disclosed: false,
412 same_license_for_derivatives: false,
413 network_copyleft: false,
414 family,
415 }
416 }
417}
418
419#[derive(Debug, Clone)]
421pub struct CompatibilityResult {
422 pub compatible: bool,
424 pub score: u8,
426 pub warnings: Vec<String>,
428 pub resulting_category: LicenseCategory,
430}
431
432pub fn check_compatibility(license_a: &str, license_b: &str) -> CompatibilityResult {
434 let info_a = LicenseInfo::from_spdx(license_a);
435 let info_b = LicenseInfo::from_spdx(license_b);
436
437 let mut warnings = Vec::new();
438 let mut compatible = true;
439 let mut score = 100u8;
440
441 if (info_a.category == LicenseCategory::Proprietary
443 || info_b.category == LicenseCategory::Proprietary)
444 && info_a.category != info_b.category
445 {
446 compatible = false;
447 score = 0;
448 warnings.push(format!(
449 "Proprietary license '{}' incompatible with '{}'",
450 if info_a.category == LicenseCategory::Proprietary {
451 license_a
452 } else {
453 license_b
454 },
455 if info_a.category == LicenseCategory::Proprietary {
456 license_b
457 } else {
458 license_a
459 }
460 ));
461 }
462
463 if info_a.family == "GPL" || info_b.family == "GPL" {
465 let a_lower = license_a.to_lowercase();
467 let b_lower = license_b.to_lowercase();
468
469 if (a_lower.contains("gpl-2.0-only") && b_lower.contains("gpl-3"))
470 || (b_lower.contains("gpl-2.0-only") && a_lower.contains("gpl-3"))
471 {
472 compatible = false;
473 score = 0;
474 warnings.push("GPL-2.0-only is incompatible with GPL-3.0".to_string());
475 }
476
477 if ((info_a.family == "Apache" && b_lower.contains("gpl-2"))
479 || (info_b.family == "Apache" && a_lower.contains("gpl-2")))
480 && !a_lower.contains("gpl-3")
481 && !b_lower.contains("gpl-3")
482 {
483 warnings.push(
484 "Apache-2.0 has patent clauses incompatible with GPL-2.0".to_string(),
485 );
486 score = score.saturating_sub(30);
487 }
488 }
489
490 if info_a.network_copyleft || info_b.network_copyleft {
492 warnings.push("Network copyleft license (AGPL) requires source disclosure for network use".to_string());
493 score = score.saturating_sub(20);
494 }
495
496 if info_a.category != info_b.category {
498 let strength_diff =
499 (info_a.category.copyleft_strength() as i8 - info_b.category.copyleft_strength() as i8)
500 .unsigned_abs();
501
502 if strength_diff > 1 {
503 warnings.push(format!(
504 "Mixing {} ({}) with {} ({}) may have licensing implications",
505 license_a,
506 info_a.category.as_str(),
507 license_b,
508 info_b.category.as_str()
509 ));
510 score = score.saturating_sub(strength_diff * 10);
511 }
512 }
513
514 let resulting_category =
516 if info_a.category.copyleft_strength() > info_b.category.copyleft_strength() {
517 info_a.category
518 } else {
519 info_b.category
520 };
521
522 CompatibilityResult {
523 compatible,
524 score,
525 warnings,
526 resulting_category,
527 }
528}
529
530pub fn analyze_license_compatibility(licenses: &[&str]) -> LicenseCompatibilityReport {
532 let mut issues = Vec::new();
533 let mut families: HashMap<&'static str, Vec<String>> = HashMap::new();
534 let mut categories: HashMap<LicenseCategory, Vec<String>> = HashMap::new();
535
536 for license in licenses {
538 let info = LicenseInfo::from_spdx(license);
539 families
540 .entry(info.family)
541 .or_default()
542 .push(license.to_string());
543 categories
544 .entry(info.category)
545 .or_default()
546 .push(license.to_string());
547 }
548
549 let unique: Vec<_> = licenses.iter().collect::<HashSet<_>>().into_iter().collect();
551 for (i, &license_a) in unique.iter().enumerate() {
552 for &license_b in unique.iter().skip(i + 1) {
553 let result = check_compatibility(license_a, license_b);
554 if !result.compatible || result.score < 70 {
555 issues.push(CompatibilityIssue {
556 license_a: license_a.to_string(),
557 license_b: license_b.to_string(),
558 severity: if !result.compatible {
559 IssueSeverity::Error
560 } else {
561 IssueSeverity::Warning
562 },
563 message: result.warnings.join("; "),
564 });
565 }
566 }
567 }
568
569 let overall_score = if issues.iter().any(|i| i.severity == IssueSeverity::Error) {
571 0
572 } else {
573 let warning_count = issues
574 .iter()
575 .filter(|i| i.severity == IssueSeverity::Warning)
576 .count();
577 100u8.saturating_sub((warning_count * 15) as u8)
578 };
579
580 LicenseCompatibilityReport {
581 overall_score,
582 issues,
583 families,
584 categories,
585 }
586}
587
588#[derive(Debug)]
590pub struct LicenseCompatibilityReport {
591 pub overall_score: u8,
593 pub issues: Vec<CompatibilityIssue>,
595 pub families: HashMap<&'static str, Vec<String>>,
597 pub categories: HashMap<LicenseCategory, Vec<String>>,
599}
600
601#[derive(Debug, Clone)]
603pub struct CompatibilityIssue {
604 pub license_a: String,
605 pub license_b: String,
606 pub severity: IssueSeverity,
607 pub message: String,
608}
609
610#[derive(Debug, Clone, Copy, PartialEq, Eq)]
611pub enum IssueSeverity {
612 Warning,
613 Error,
614}
615
616#[derive(Debug, Default)]
618pub struct LicenseStats {
619 pub total_licenses: usize,
620 pub unique_licenses: usize,
621 pub by_category: HashMap<LicenseCategory, usize>,
622 pub by_risk: HashMap<RiskLevel, usize>,
623 pub by_family: HashMap<String, usize>,
624 pub copyleft_count: usize,
625 pub permissive_count: usize,
626}
627
628impl LicenseStats {
629 pub fn from_licenses(licenses: &[&str]) -> Self {
630 let mut stats = LicenseStats {
631 total_licenses: licenses.len(),
632 unique_licenses: 0,
633 by_category: HashMap::new(),
634 by_risk: HashMap::new(),
635 by_family: HashMap::new(),
636 copyleft_count: 0,
637 permissive_count: 0,
638 };
639
640 let unique: HashSet<_> = licenses.iter().collect();
641 stats.unique_licenses = unique.len();
642
643 for license in unique {
644 let info = LicenseInfo::from_spdx(license);
645
646 *stats.by_category.entry(info.category).or_default() += 1;
647 *stats.by_risk.entry(info.risk_level).or_default() += 1;
648 *stats
649 .by_family
650 .entry(info.family.to_string())
651 .or_default() += 1;
652
653 match info.category {
654 LicenseCategory::Permissive | LicenseCategory::PublicDomain => {
655 stats.permissive_count += 1;
656 }
657 LicenseCategory::WeakCopyleft
658 | LicenseCategory::StrongCopyleft
659 | LicenseCategory::NetworkCopyleft => {
660 stats.copyleft_count += 1;
661 }
662 _ => {}
663 }
664 }
665
666 stats
667 }
668}
669
670#[cfg(test)]
671mod tests {
672 use super::*;
673
674 #[test]
675 fn test_spdx_parse_simple() {
676 let expr = SpdxExpression::parse("MIT");
677 assert_eq!(expr, SpdxExpression::License("MIT".to_string()));
678 }
679
680 #[test]
681 fn test_spdx_parse_or() {
682 let expr = SpdxExpression::parse("MIT OR Apache-2.0");
683 assert!(matches!(expr, SpdxExpression::Or(_, _)));
684 assert!(expr.is_choice());
685 }
686
687 #[test]
688 fn test_spdx_parse_with() {
689 let expr = SpdxExpression::parse("GPL-2.0 WITH Classpath-exception-2.0");
690 assert!(matches!(expr, SpdxExpression::WithException { .. }));
691 }
692
693 #[test]
694 fn test_license_category() {
695 assert_eq!(
696 LicenseInfo::from_spdx("MIT").category,
697 LicenseCategory::Permissive
698 );
699 assert_eq!(
700 LicenseInfo::from_spdx("GPL-3.0").category,
701 LicenseCategory::StrongCopyleft
702 );
703 assert_eq!(
704 LicenseInfo::from_spdx("LGPL-2.1").category,
705 LicenseCategory::WeakCopyleft
706 );
707 assert_eq!(
708 LicenseInfo::from_spdx("AGPL-3.0").category,
709 LicenseCategory::NetworkCopyleft
710 );
711 }
712
713 #[test]
714 fn test_compatibility_mit_apache() {
715 let result = check_compatibility("MIT", "Apache-2.0");
716 assert!(result.compatible);
717 assert!(result.score > 80);
718 }
719
720 #[test]
721 fn test_compatibility_gpl_proprietary() {
722 let result = check_compatibility("GPL-3.0", "Proprietary");
723 assert!(!result.compatible);
724 assert_eq!(result.score, 0);
725 }
726}