Skip to main content

wdl_modules/
license.rs

1//! SPDX license expression validation.
2
3use 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/// An error parsing a [`LicenseExpression`].
13#[derive(Debug, Error)]
14pub enum LicenseError {
15    /// The expression is empty.
16    #[error("license expression cannot be empty")]
17    Empty,
18
19    /// The expression is not a valid SPDX license expression.
20    #[error("invalid SPDX license expression: {0}")]
21    Invalid(String),
22}
23
24/// A validated SPDX license expression.
25///
26/// Validates both the expression syntax and the license identifiers
27/// against the SPDX license list (so typos like `MIT-2.0` are rejected
28/// even though they would parse syntactically).
29#[derive(Clone, Serialize, Deserialize)]
30#[serde(into = "String", try_from = "String")]
31pub struct LicenseExpression(spdx::Expression);
32
33impl LicenseExpression {
34    /// Returns a reference to the inner [`spdx::Expression`].
35    pub fn as_expression(&self) -> &spdx::Expression {
36        &self.0
37    }
38
39    /// Returns the canonical string form of the expression.
40    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}