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_with::DeserializeFromStr;
9use serde_with::SerializeDisplay;
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, SerializeDisplay, DeserializeFromStr)]
30pub struct LicenseExpression(spdx::Expression);
31
32impl LicenseExpression {
33    /// Returns a reference to the inner [`spdx::Expression`].
34    pub fn as_expression(&self) -> &spdx::Expression {
35        &self.0
36    }
37
38    /// Returns the canonical string form of the expression.
39    pub fn as_str(&self) -> &str {
40        self.0.as_ref()
41    }
42}
43
44impl fmt::Debug for LicenseExpression {
45    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
46        f.debug_tuple("LicenseExpression")
47            .field(&self.as_str())
48            .finish()
49    }
50}
51
52impl fmt::Display for LicenseExpression {
53    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
54        f.write_str(self.as_str())
55    }
56}
57
58impl PartialEq for LicenseExpression {
59    fn eq(&self, other: &Self) -> bool {
60        self.as_str() == other.as_str()
61    }
62}
63
64impl Eq for LicenseExpression {}
65
66impl Hash for LicenseExpression {
67    fn hash<H: Hasher>(&self, state: &mut H) {
68        self.as_str().hash(state);
69    }
70}
71
72impl FromStr for LicenseExpression {
73    type Err = LicenseError;
74
75    fn from_str(s: &str) -> Result<Self, Self::Err> {
76        let trimmed = s.trim();
77        if trimmed.is_empty() {
78            return Err(LicenseError::Empty);
79        }
80        let expr =
81            spdx::Expression::parse(trimmed).map_err(|e| LicenseError::Invalid(format!("{e}")))?;
82        Ok(Self(expr))
83    }
84}
85
86impl From<LicenseExpression> for String {
87    fn from(expr: LicenseExpression) -> Self {
88        expr.as_str().to_string()
89    }
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95
96    #[test]
97    fn accepts_simple_licenses() {
98        for s in ["MIT", "Apache-2.0", "BSD-3-Clause", "GPL-3.0-only"] {
99            assert!(s.parse::<LicenseExpression>().is_ok(), "rejected `{s}`");
100        }
101    }
102
103    #[test]
104    fn accepts_compound_licenses() {
105        for s in [
106            "MIT OR Apache-2.0",
107            "MIT AND Apache-2.0",
108            "(MIT OR Apache-2.0) AND BSD-3-Clause",
109            "Apache-2.0 WITH LLVM-exception",
110        ] {
111            assert!(s.parse::<LicenseExpression>().is_ok(), "rejected `{s}`");
112        }
113    }
114
115    #[test]
116    fn rejects_unknown_id() {
117        assert!("MIT-2.0".parse::<LicenseExpression>().is_err());
118    }
119
120    #[test]
121    fn rejects_empty() {
122        assert!("".parse::<LicenseExpression>().is_err());
123        assert!("   ".parse::<LicenseExpression>().is_err());
124    }
125
126    #[test]
127    fn round_trips_via_serde() {
128        let license: LicenseExpression = "MIT OR Apache-2.0".parse().unwrap();
129        let json = serde_json::to_string(&license).unwrap();
130        let parsed: LicenseExpression = serde_json::from_str(&json).unwrap();
131        assert_eq!(parsed, license);
132    }
133}