#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SpdxExpr {
Single(String),
AnyOf(Vec<String>),
AllOf(Vec<String>),
}
impl SpdxExpr {
pub fn ids(&self) -> &[String] {
match self {
Self::Single(s) => std::slice::from_ref(s),
Self::AnyOf(v) | Self::AllOf(v) => v,
}
}
pub fn is_single(&self) -> bool {
matches!(self, Self::Single(_))
}
}
pub fn parse_spdx_expression(expr: &str) -> SpdxExpr {
let trimmed = expr.trim();
if trimmed.is_empty() {
return SpdxExpr::Single(String::new());
}
let unwrapped = strip_outer_parens(trimmed);
if unwrapped.contains('(') || unwrapped.contains(')') {
return SpdxExpr::Single(unwrapped.to_string());
}
let has_or = has_top_level_keyword(unwrapped, "OR") || unwrapped.contains('/');
let has_and = has_top_level_keyword(unwrapped, "AND");
let has_with = has_top_level_keyword(unwrapped, "WITH");
if (has_or && has_and) || has_with {
return SpdxExpr::Single(unwrapped.to_string());
}
if has_or {
let ids = split_keyword(unwrapped, "OR");
if ids.len() >= 2 {
return SpdxExpr::AnyOf(ids);
}
}
if has_and {
let ids = split_keyword(unwrapped, "AND");
if ids.len() >= 2 {
return SpdxExpr::AllOf(ids);
}
}
SpdxExpr::Single(unwrapped.to_string())
}
fn strip_outer_parens(s: &str) -> &str {
let t = s.trim();
if !t.starts_with('(') || !t.ends_with(')') {
return t;
}
let mut depth = 0u32;
for (i, ch) in t.char_indices() {
match ch {
'(' => depth += 1,
')' => {
depth -= 1;
if depth == 0 && i != t.len() - 1 {
return t;
}
}
_ => {}
}
}
strip_outer_parens(t[1..t.len() - 1].trim())
}
fn has_top_level_keyword(expr: &str, kw: &str) -> bool {
expr.split_whitespace().any(|tok| tok == kw)
}
fn split_keyword(expr: &str, kw: &str) -> Vec<String> {
let normalized = if kw == "OR" {
expr.replace('/', " OR ")
} else {
expr.to_string()
};
normalized
.split_whitespace()
.collect::<Vec<_>>()
.split(|tok| *tok == kw)
.map(|chunk| chunk.join(" "))
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn single_id() {
assert_eq!(
parse_spdx_expression("MIT"),
SpdxExpr::Single("MIT".to_string())
);
assert!(parse_spdx_expression("MIT").is_single());
assert_eq!(parse_spdx_expression("MIT").ids(), &["MIT".to_string()]);
}
#[test]
fn single_id_trims_whitespace() {
assert_eq!(
parse_spdx_expression(" Apache-2.0 "),
SpdxExpr::Single("Apache-2.0".to_string())
);
}
#[test]
fn empty_is_empty_single() {
assert_eq!(parse_spdx_expression(""), SpdxExpr::Single(String::new()));
assert_eq!(
parse_spdx_expression(" "),
SpdxExpr::Single(String::new())
);
}
#[test]
fn or_two() {
assert_eq!(
parse_spdx_expression("Apache-2.0 OR MIT"),
SpdxExpr::AnyOf(vec!["Apache-2.0".to_string(), "MIT".to_string()])
);
}
#[test]
fn or_three() {
assert_eq!(
parse_spdx_expression("MIT OR Apache-2.0 OR BSD-3-Clause"),
SpdxExpr::AnyOf(vec![
"MIT".to_string(),
"Apache-2.0".to_string(),
"BSD-3-Clause".to_string(),
])
);
}
#[test]
fn and_two() {
assert_eq!(
parse_spdx_expression("Apache-2.0 AND MIT"),
SpdxExpr::AllOf(vec!["Apache-2.0".to_string(), "MIT".to_string()])
);
}
#[test]
fn legacy_slash_is_or() {
assert_eq!(
parse_spdx_expression("MIT/Apache-2.0"),
SpdxExpr::AnyOf(vec!["MIT".to_string(), "Apache-2.0".to_string()])
);
}
#[test]
fn legacy_slash_with_spaces() {
assert_eq!(
parse_spdx_expression("MIT / Apache-2.0"),
SpdxExpr::AnyOf(vec!["MIT".to_string(), "Apache-2.0".to_string()])
);
}
#[test]
fn with_exception_stays_literal() {
assert_eq!(
parse_spdx_expression("Apache-2.0 WITH LLVM-exception"),
SpdxExpr::Single("Apache-2.0 WITH LLVM-exception".to_string())
);
}
#[test]
fn mixed_connectives_stay_literal() {
assert_eq!(
parse_spdx_expression("Apache-2.0 AND MIT OR BSD-3-Clause"),
SpdxExpr::Single("Apache-2.0 AND MIT OR BSD-3-Clause".to_string())
);
}
#[test]
fn parenthesised_compound_stays_literal() {
assert_eq!(
parse_spdx_expression("(MIT OR Apache-2.0) AND BSD-3-Clause"),
SpdxExpr::Single("(MIT OR Apache-2.0) AND BSD-3-Clause".to_string())
);
}
#[test]
fn outer_parens_stripped_for_simple_or() {
assert_eq!(
parse_spdx_expression("(MIT OR Apache-2.0)"),
SpdxExpr::AnyOf(vec!["MIT".to_string(), "Apache-2.0".to_string()])
);
}
#[test]
fn outer_parens_stripped_for_bare_id() {
assert_eq!(
parse_spdx_expression("(MIT)"),
SpdxExpr::Single("MIT".to_string())
);
}
#[test]
fn non_wrapping_parens_kept_literal() {
assert_eq!(
parse_spdx_expression("(MIT) OR (Apache-2.0)"),
SpdxExpr::Single("(MIT) OR (Apache-2.0)".to_string())
);
}
#[test]
fn or_substring_in_id_not_split() {
assert_eq!(
parse_spdx_expression("CERN-OHL-S-2.0"),
SpdxExpr::Single("CERN-OHL-S-2.0".to_string())
);
}
#[test]
fn lowercase_or_is_not_a_connective() {
assert_eq!(
parse_spdx_expression("MIT or Apache-2.0"),
SpdxExpr::Single("MIT or Apache-2.0".to_string())
);
}
#[test]
fn dangling_or_keyword_falls_through_to_literal() {
assert_eq!(
parse_spdx_expression("MIT OR"),
SpdxExpr::Single("MIT OR".to_string())
);
assert_eq!(
parse_spdx_expression("OR"),
SpdxExpr::Single("OR".to_string())
);
}
#[test]
fn dangling_and_keyword_falls_through_to_literal() {
assert_eq!(
parse_spdx_expression("MIT AND"),
SpdxExpr::Single("MIT AND".to_string())
);
assert_eq!(
parse_spdx_expression("AND BSD-3-Clause"),
SpdxExpr::Single("AND BSD-3-Clause".to_string())
);
}
#[test]
fn ids_accessor_covers_all_variants() {
assert_eq!(
parse_spdx_expression("A OR B").ids(),
&["A".to_string(), "B".to_string()]
);
assert_eq!(
parse_spdx_expression("A AND B").ids(),
&["A".to_string(), "B".to_string()]
);
assert_eq!(parse_spdx_expression("A").ids(), &["A".to_string()]);
}
}