1use std::fmt;
4use std::hash::Hash;
5use std::hash::Hasher;
6use std::str::FromStr;
7
8use serde::Deserialize;
9use serde::Serialize;
10use thiserror::Error;
11
12#[derive(Debug, Error)]
14pub enum LicenseError {
15 #[error("license expression cannot be empty")]
17 Empty,
18
19 #[error("invalid SPDX license expression: {0}")]
21 Invalid(String),
22}
23
24#[derive(Clone, Serialize, Deserialize)]
30#[serde(into = "String", try_from = "String")]
31pub struct LicenseExpression(spdx::Expression);
32
33impl LicenseExpression {
34 pub fn as_expression(&self) -> &spdx::Expression {
36 &self.0
37 }
38
39 pub fn as_str(&self) -> &str {
41 self.0.as_ref()
42 }
43}
44
45impl fmt::Debug for LicenseExpression {
46 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
47 f.debug_tuple("LicenseExpression")
48 .field(&self.as_str())
49 .finish()
50 }
51}
52
53impl fmt::Display for LicenseExpression {
54 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
55 f.write_str(self.as_str())
56 }
57}
58
59impl PartialEq for LicenseExpression {
60 fn eq(&self, other: &Self) -> bool {
61 self.as_str() == other.as_str()
62 }
63}
64
65impl Eq for LicenseExpression {}
66
67impl Hash for LicenseExpression {
68 fn hash<H: Hasher>(&self, state: &mut H) {
69 self.as_str().hash(state);
70 }
71}
72
73impl TryFrom<String> for LicenseExpression {
74 type Error = LicenseError;
75
76 fn try_from(s: String) -> Result<Self, Self::Error> {
77 let trimmed = s.trim();
78 if trimmed.is_empty() {
79 return Err(LicenseError::Empty);
80 }
81 let expr =
82 spdx::Expression::parse(trimmed).map_err(|e| LicenseError::Invalid(format!("{e}")))?;
83 Ok(Self(expr))
84 }
85}
86
87impl FromStr for LicenseExpression {
88 type Err = LicenseError;
89
90 fn from_str(s: &str) -> Result<Self, Self::Err> {
91 Self::try_from(s.to_string())
92 }
93}
94
95impl From<LicenseExpression> for String {
96 fn from(expr: LicenseExpression) -> Self {
97 expr.as_str().to_string()
98 }
99}
100
101#[cfg(test)]
102mod tests {
103 use super::*;
104
105 #[test]
106 fn accepts_simple_licenses() {
107 for s in ["MIT", "Apache-2.0", "BSD-3-Clause", "GPL-3.0-only"] {
108 assert!(s.parse::<LicenseExpression>().is_ok(), "rejected `{s}`");
109 }
110 }
111
112 #[test]
113 fn accepts_compound_licenses() {
114 for s in [
115 "MIT OR Apache-2.0",
116 "MIT AND Apache-2.0",
117 "(MIT OR Apache-2.0) AND BSD-3-Clause",
118 "Apache-2.0 WITH LLVM-exception",
119 ] {
120 assert!(s.parse::<LicenseExpression>().is_ok(), "rejected `{s}`");
121 }
122 }
123
124 #[test]
125 fn rejects_unknown_id() {
126 assert!("MIT-2.0".parse::<LicenseExpression>().is_err());
127 }
128
129 #[test]
130 fn rejects_empty() {
131 assert!("".parse::<LicenseExpression>().is_err());
132 assert!(" ".parse::<LicenseExpression>().is_err());
133 }
134
135 #[test]
136 fn round_trips_via_serde() {
137 let license: LicenseExpression = "MIT OR Apache-2.0".parse().unwrap();
138 let json = serde_json::to_string(&license).unwrap();
139 let parsed: LicenseExpression = serde_json::from_str(&json).unwrap();
140 assert_eq!(parsed, license);
141 }
142}