use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::collections::BTreeMap;
use std::fmt;
use std::str::FromStr;
use tagged_urn::{TaggedUrn, TaggedUrnBuilder, TaggedUrnError};
use crate::media_urn::{MediaUrn, MediaUrnError, MEDIA_VOID, MEDIA_OBJECT};
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct CapUrn {
in_urn: String,
out_urn: String,
pub tags: BTreeMap<String, String>,
}
impl CapUrn {
pub const PREFIX: &'static str = "cap";
pub fn new(in_urn: String, out_urn: String, tags: BTreeMap<String, String>) -> Self {
let normalized_tags: BTreeMap<String, String> = tags
.into_iter()
.filter(|(k, _)| {
let k_lower = k.to_lowercase();
k_lower != "in" && k_lower != "out"
})
.map(|(k, v)| (k.to_lowercase(), v))
.collect();
Self {
in_urn,
out_urn,
tags: normalized_tags,
}
}
pub fn from_tags(mut tags: BTreeMap<String, String>) -> Result<Self, CapUrnError> {
let in_urn = tags.remove("in").ok_or(CapUrnError::MissingInSpec)?;
let out_urn = tags.remove("out").ok_or(CapUrnError::MissingOutSpec)?;
Ok(Self::new(in_urn, out_urn, tags))
}
pub fn from_string(s: &str) -> Result<Self, CapUrnError> {
let tagged = TaggedUrn::from_string(s).map_err(CapUrnError::from_tagged_urn_error)?;
if tagged.prefix != Self::PREFIX {
return Err(CapUrnError::MissingCapPrefix);
}
let in_urn = tagged
.tags
.get("in")
.ok_or(CapUrnError::MissingInSpec)?
.clone();
let out_urn = tagged
.tags
.get("out")
.ok_or(CapUrnError::MissingOutSpec)?
.clone();
let tags: BTreeMap<String, String> = tagged
.tags
.into_iter()
.filter(|(k, _)| k != "in" && k != "out")
.collect();
Ok(Self {
in_urn,
out_urn,
tags,
})
}
pub fn to_string(&self) -> String {
let mut builder = TaggedUrnBuilder::new(Self::PREFIX)
.tag("in", &self.in_urn).expect("in_urn guaranteed non-empty")
.tag("out", &self.out_urn).expect("out_urn guaranteed non-empty");
for (k, v) in &self.tags {
builder = builder.tag(k, v).expect("tag values validated at construction");
}
builder.build_allow_empty().to_string()
}
pub fn get_tag(&self, key: &str) -> Option<&String> {
let key_lower = key.to_lowercase();
match key_lower.as_str() {
"in" => Some(&self.in_urn),
"out" => Some(&self.out_urn),
_ => self.tags.get(&key_lower),
}
}
pub fn in_spec(&self) -> &str {
&self.in_urn
}
pub fn out_spec(&self) -> &str {
&self.out_urn
}
pub fn in_media_urn(&self) -> Result<MediaUrn, MediaUrnError> {
MediaUrn::from_string(&self.in_urn)
}
pub fn out_media_urn(&self) -> Result<MediaUrn, MediaUrnError> {
MediaUrn::from_string(&self.out_urn)
}
pub fn has_tag(&self, key: &str, value: &str) -> bool {
let key_lower = key.to_lowercase();
match key_lower.as_str() {
"in" => self.in_urn == value,
"out" => self.out_urn == value,
_ => self.tags.get(&key_lower).map_or(false, |v| v == value),
}
}
pub fn with_tag(mut self, key: String, value: String) -> Result<Self, CapUrnError> {
if value.is_empty() {
return Err(CapUrnError::EmptyValue(key));
}
let key_lower = key.to_lowercase();
if key_lower == "in" || key_lower == "out" {
return Ok(self);
}
self.tags.insert(key_lower, value);
Ok(self)
}
pub fn with_in_spec(mut self, in_urn: String) -> Self {
self.in_urn = in_urn;
self
}
pub fn with_out_spec(mut self, out_urn: String) -> Self {
self.out_urn = out_urn;
self
}
pub fn without_tag(mut self, key: &str) -> Self {
let key_lower = key.to_lowercase();
if key_lower == "in" || key_lower == "out" {
return self;
}
self.tags.remove(&key_lower);
self
}
pub fn matches(&self, request: &CapUrn) -> bool {
if self.in_urn != "*" && request.in_urn != "*" && self.in_urn != request.in_urn {
return false;
}
if self.out_urn != "*" && request.out_urn != "*" && self.out_urn != request.out_urn {
return false;
}
for (request_key, request_value) in &request.tags {
match self.tags.get(request_key) {
Some(cap_value) => {
if cap_value == "*" {
continue;
}
if request_value == "*" {
continue;
}
if cap_value != request_value {
return false;
}
}
None => {
continue;
}
}
}
true
}
pub fn matches_str(&self, request_str: &str) -> Result<bool, CapUrnError> {
let request = CapUrn::from_string(request_str)?;
Ok(self.matches(&request))
}
pub fn can_handle(&self, request: &CapUrn) -> bool {
self.matches(request)
}
pub fn specificity(&self) -> usize {
let mut count = 0;
if self.in_urn != "*" {
count += 1;
}
if self.out_urn != "*" {
count += 1;
}
count + self.tags.values().filter(|v| v.as_str() != "*").count()
}
pub fn is_more_specific_than(&self, other: &CapUrn) -> bool {
if !self.is_compatible_with(other) {
return false;
}
self.specificity() > other.specificity()
}
pub fn is_compatible_with(&self, other: &CapUrn) -> bool {
if self.in_urn != "*" && other.in_urn != "*" && self.in_urn != other.in_urn {
return false;
}
if self.out_urn != "*" && other.out_urn != "*" && self.out_urn != other.out_urn {
return false;
}
let mut all_keys = self
.tags
.keys()
.cloned()
.collect::<std::collections::HashSet<_>>();
all_keys.extend(other.tags.keys().cloned());
for key in all_keys {
match (self.tags.get(&key), other.tags.get(&key)) {
(Some(v1), Some(v2)) => {
if v1 != "*" && v2 != "*" && v1 != v2 {
return false;
}
}
(Some(_), None) | (None, Some(_)) => {
continue;
}
(None, None) => {
continue;
}
}
}
true
}
pub fn with_wildcard_tag(mut self, key: &str) -> Self {
let key_lower = key.to_lowercase();
match key_lower.as_str() {
"in" => {
self.in_urn = "*".to_string();
}
"out" => {
self.out_urn = "*".to_string();
}
_ => {
if self.tags.contains_key(&key_lower) {
self.tags.insert(key_lower, "*".to_string());
}
}
}
self
}
pub fn subset(&self, keys: &[&str]) -> Self {
let mut tags = BTreeMap::new();
for &key in keys {
let key_lower = key.to_lowercase();
if key_lower == "in" || key_lower == "out" {
continue;
}
if let Some(value) = self.tags.get(&key_lower) {
tags.insert(key_lower, value.clone());
}
}
Self {
in_urn: self.in_urn.clone(),
out_urn: self.out_urn.clone(),
tags,
}
}
pub fn merge(&self, other: &CapUrn) -> Self {
let mut tags = self.tags.clone();
for (key, value) in &other.tags {
tags.insert(key.clone(), value.clone());
}
Self {
in_urn: other.in_urn.clone(),
out_urn: other.out_urn.clone(),
tags,
}
}
pub fn canonical(cap_urn: &str) -> Result<String, CapUrnError> {
let cap_urn_deserialized = CapUrn::from_string(cap_urn)?;
Ok(cap_urn_deserialized.to_string())
}
pub fn canonical_option(cap_urn: Option<&str>) -> Result<Option<String>, CapUrnError> {
if let Some(cu) = cap_urn {
let cap_urn_deserialized = CapUrn::from_string(cu)?;
Ok(Some(cap_urn_deserialized.to_string()))
} else {
Ok(None)
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum CapUrnError {
Empty,
MissingCapPrefix,
InvalidTagFormat(String),
EmptyTagComponent(String),
InvalidCharacter(String),
DuplicateKey(String),
NumericKey(String),
UnterminatedQuote(usize),
InvalidEscapeSequence(usize),
MissingInSpec,
MissingOutSpec,
EmptyValue(String),
}
impl CapUrnError {
fn from_tagged_urn_error(e: TaggedUrnError) -> Self {
match e {
TaggedUrnError::Empty => CapUrnError::Empty,
TaggedUrnError::MissingPrefix => CapUrnError::MissingCapPrefix,
TaggedUrnError::EmptyPrefix => CapUrnError::MissingCapPrefix,
TaggedUrnError::InvalidTagFormat(s) => CapUrnError::InvalidTagFormat(s),
TaggedUrnError::EmptyTagComponent(s) => CapUrnError::EmptyTagComponent(s),
TaggedUrnError::InvalidCharacter(s) => CapUrnError::InvalidCharacter(s),
TaggedUrnError::DuplicateKey(s) => CapUrnError::DuplicateKey(s),
TaggedUrnError::NumericKey(s) => CapUrnError::NumericKey(s),
TaggedUrnError::UnterminatedQuote(pos) => CapUrnError::UnterminatedQuote(pos),
TaggedUrnError::InvalidEscapeSequence(pos) => CapUrnError::InvalidEscapeSequence(pos),
TaggedUrnError::PrefixMismatch { .. } => CapUrnError::MissingCapPrefix,
}
}
}
impl fmt::Display for CapUrnError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
CapUrnError::Empty => {
write!(f, "Cap identifier cannot be empty")
}
CapUrnError::MissingCapPrefix => {
write!(f, "Cap identifier must start with 'cap:'")
}
CapUrnError::InvalidTagFormat(tag) => {
write!(f, "Invalid tag format (must be key=value): {}", tag)
}
CapUrnError::EmptyTagComponent(tag) => {
write!(f, "Tag key or value cannot be empty: {}", tag)
}
CapUrnError::InvalidCharacter(tag) => {
write!(f, "Invalid character in tag: {}", tag)
}
CapUrnError::DuplicateKey(key) => {
write!(f, "Duplicate tag key: {}", key)
}
CapUrnError::NumericKey(key) => {
write!(f, "Tag key cannot be purely numeric: {}", key)
}
CapUrnError::UnterminatedQuote(pos) => {
write!(f, "Unterminated quote at position {}", pos)
}
CapUrnError::InvalidEscapeSequence(pos) => {
write!(
f,
"Invalid escape sequence at position {} (only \\\" and \\\\ allowed)",
pos
)
}
CapUrnError::MissingInSpec => {
write!(f, "Cap URN is missing required 'in' tag - caps must declare their input type (use {} for no input)", MEDIA_VOID)
}
CapUrnError::MissingOutSpec => {
write!(
f,
"Cap URN is missing required 'out' tag - caps must declare their output type"
)
}
CapUrnError::EmptyValue(key) => {
write!(
f,
"Empty value for key '{}' (use '*' for wildcard)",
key
)
}
}
}
}
impl std::error::Error for CapUrnError {}
impl FromStr for CapUrn {
type Err = CapUrnError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
CapUrn::from_string(s)
}
}
impl fmt::Display for CapUrn {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.to_string())
}
}
impl Serialize for CapUrn {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&self.to_string())
}
}
impl<'de> Deserialize<'de> for CapUrn {
fn deserialize<D>(deserializer: D) -> Result<CapUrn, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
CapUrn::from_string(&s).map_err(serde::de::Error::custom)
}
}
pub struct CapMatcher;
impl CapMatcher {
pub fn find_best_match<'a>(caps: &'a [CapUrn], request: &CapUrn) -> Option<&'a CapUrn> {
caps.iter()
.filter(|cap| cap.can_handle(request))
.max_by_key(|cap| cap.specificity())
}
pub fn find_all_matches<'a>(caps: &'a [CapUrn], request: &CapUrn) -> Vec<&'a CapUrn> {
let mut matches: Vec<&CapUrn> = caps.iter().filter(|cap| cap.can_handle(request)).collect();
matches.sort_by_key(|cap| std::cmp::Reverse(cap.specificity()));
matches
}
pub fn are_compatible(caps1: &[CapUrn], caps2: &[CapUrn]) -> bool {
caps1
.iter()
.any(|c1| caps2.iter().any(|c2| c1.is_compatible_with(c2)))
}
}
pub struct CapUrnBuilder {
in_urn: Option<String>,
out_urn: Option<String>,
tags: BTreeMap<String, String>,
}
impl CapUrnBuilder {
pub fn new() -> Self {
Self {
in_urn: None,
out_urn: None,
tags: BTreeMap::new(),
}
}
pub fn in_spec(mut self, spec: &str) -> Self {
self.in_urn = Some(spec.to_string());
self
}
pub fn out_spec(mut self, spec: &str) -> Self {
self.out_urn = Some(spec.to_string());
self
}
pub fn tag(mut self, key: &str, value: &str) -> Self {
let key_lower = key.to_lowercase();
if key_lower == "in" || key_lower == "out" {
return self;
}
self.tags.insert(key_lower, value.to_string());
self
}
pub fn solo_tag(mut self, key: &str) -> Self {
let key_lower = key.to_lowercase();
if key_lower == "in" || key_lower == "out" {
return self;
}
self.tags.insert(key_lower, "*".to_string());
self
}
pub fn build(self) -> Result<CapUrn, CapUrnError> {
let in_urn = self.in_urn.ok_or(CapUrnError::MissingInSpec)?;
let out_urn = self.out_urn.ok_or(CapUrnError::MissingOutSpec)?;
Ok(CapUrn::new(in_urn, out_urn, self.tags))
}
}
impl Default for CapUrnBuilder {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_urn(tags: &str) -> String {
if tags.is_empty() {
format!("cap:in=\"{}\";out=\"{}\"", MEDIA_VOID, MEDIA_OBJECT)
} else {
format!("cap:in=\"{}\";out=\"{}\";{}", MEDIA_VOID, MEDIA_OBJECT, tags)
}
}
fn test_urn_with_io(in_spec: &str, out_spec: &str, tags: &str) -> String {
if tags.is_empty() {
format!("cap:in=\"{}\";out=\"{}\"", in_spec, out_spec)
} else {
format!("cap:in=\"{}\";out=\"{}\";{}", in_spec, out_spec, tags)
}
}
#[test]
fn test_cap_urn_creation() {
let cap = CapUrn::from_string(&test_urn("op=generate;ext=pdf;target=thumbnail")).unwrap();
assert_eq!(cap.get_tag("op"), Some(&"generate".to_string()));
assert_eq!(cap.get_tag("target"), Some(&"thumbnail".to_string()));
assert_eq!(cap.get_tag("ext"), Some(&"pdf".to_string()));
assert_eq!(cap.in_spec(), MEDIA_VOID);
assert_eq!(cap.out_spec(), MEDIA_OBJECT);
}
#[test]
fn test_direction_specs_required() {
let result = CapUrn::from_string(&format!("cap:out=\"{}\";op=test", MEDIA_OBJECT));
assert!(result.is_err());
assert!(matches!(result, Err(CapUrnError::MissingInSpec)));
let result = CapUrn::from_string(&format!("cap:in=\"{}\";op=test", MEDIA_VOID));
assert!(result.is_err());
assert!(matches!(result, Err(CapUrnError::MissingOutSpec)));
let result = CapUrn::from_string(&format!("cap:in=\"{}\";out=\"{}\";op=test", MEDIA_VOID, MEDIA_OBJECT));
assert!(result.is_ok());
}
#[test]
fn test_direction_matching() {
let in_str = "media:string";
let out_obj = "media:object";
let in_bin = "media:binary";
let out_int = "media:integer";
let cap1 = CapUrn::from_string(&format!("cap:in=\"{}\";op=test;out=\"{}\"", in_str, out_obj)).unwrap();
let cap2 = CapUrn::from_string(&format!("cap:in=\"{}\";op=test;out=\"{}\"", in_str, out_obj)).unwrap();
assert!(cap1.matches(&cap2));
let cap3 = CapUrn::from_string(&format!("cap:in=\"{}\";op=test;out=\"{}\"", in_bin, out_obj)).unwrap();
assert!(!cap1.matches(&cap3));
let cap4 = CapUrn::from_string(&format!("cap:in=\"{}\";op=test;out=\"{}\"", in_str, out_int)).unwrap();
assert!(!cap1.matches(&cap4));
let cap5 = CapUrn::from_string(&format!("cap:in=*;op=test;out=\"{}\"", out_obj)).unwrap();
assert!(cap1.matches(&cap5));
assert!(cap5.matches(&cap1));
}
#[test]
fn test_unquoted_values_lowercased() {
let cap = CapUrn::from_string(&test_urn("OP=Generate;EXT=PDF;Target=Thumbnail")).unwrap();
assert_eq!(cap.get_tag("op"), Some(&"generate".to_string()));
assert_eq!(cap.get_tag("ext"), Some(&"pdf".to_string()));
assert_eq!(cap.get_tag("target"), Some(&"thumbnail".to_string()));
assert_eq!(cap.get_tag("OP"), Some(&"generate".to_string()));
assert_eq!(cap.get_tag("Op"), Some(&"generate".to_string()));
let cap2 = CapUrn::from_string(&test_urn("op=generate;ext=pdf;target=thumbnail")).unwrap();
assert_eq!(cap.to_string(), cap2.to_string());
assert_eq!(cap, cap2);
}
#[test]
fn test_quoted_values_preserve_case() {
let cap = CapUrn::from_string(&test_urn(r#"key="Value With Spaces""#)).unwrap();
assert_eq!(cap.get_tag("key"), Some(&"Value With Spaces".to_string()));
let cap2 = CapUrn::from_string(&test_urn(r#"KEY="Value With Spaces""#)).unwrap();
assert_eq!(cap2.get_tag("key"), Some(&"Value With Spaces".to_string()));
let unquoted = CapUrn::from_string(&test_urn("key=UPPERCASE")).unwrap();
let quoted = CapUrn::from_string(&test_urn(r#"key="UPPERCASE""#)).unwrap();
assert_eq!(unquoted.get_tag("key"), Some(&"uppercase".to_string())); assert_eq!(quoted.get_tag("key"), Some(&"UPPERCASE".to_string())); assert_ne!(unquoted, quoted); }
#[test]
fn test_quoted_value_special_chars() {
let cap = CapUrn::from_string(&test_urn(r#"key="value;with;semicolons""#)).unwrap();
assert_eq!(
cap.get_tag("key"),
Some(&"value;with;semicolons".to_string())
);
let cap2 = CapUrn::from_string(&test_urn(r#"key="value=with=equals""#)).unwrap();
assert_eq!(cap2.get_tag("key"), Some(&"value=with=equals".to_string()));
let cap3 = CapUrn::from_string(&test_urn(r#"key="hello world""#)).unwrap();
assert_eq!(cap3.get_tag("key"), Some(&"hello world".to_string()));
}
#[test]
fn test_quoted_value_escape_sequences() {
let cap = CapUrn::from_string(&test_urn(r#"key="value\"quoted\"""#)).unwrap();
assert_eq!(cap.get_tag("key"), Some(&r#"value"quoted""#.to_string()));
let cap2 = CapUrn::from_string(&test_urn(r#"key="path\\file""#)).unwrap();
assert_eq!(cap2.get_tag("key"), Some(&r#"path\file"#.to_string()));
let cap3 = CapUrn::from_string(&test_urn(r#"key="say \"hello\\world\"""#)).unwrap();
assert_eq!(
cap3.get_tag("key"),
Some(&r#"say "hello\world""#.to_string())
);
}
#[test]
fn test_mixed_quoted_unquoted() {
let cap = CapUrn::from_string(&test_urn(r#"a="Quoted";b=simple"#)).unwrap();
assert_eq!(cap.get_tag("a"), Some(&"Quoted".to_string()));
assert_eq!(cap.get_tag("b"), Some(&"simple".to_string()));
}
#[test]
fn test_unterminated_quote_error() {
let result = CapUrn::from_string(&test_urn(r#"key="unterminated"#));
assert!(result.is_err());
if let Err(e) = result {
assert!(matches!(e, CapUrnError::UnterminatedQuote(_)));
}
}
#[test]
fn test_invalid_escape_sequence_error() {
let result = CapUrn::from_string(&test_urn(r#"key="bad\n""#));
assert!(result.is_err());
if let Err(e) = result {
assert!(matches!(e, CapUrnError::InvalidEscapeSequence(_)));
}
let result2 = CapUrn::from_string(&test_urn(r#"key="bad\x""#));
assert!(result2.is_err());
if let Err(e) = result2 {
assert!(matches!(e, CapUrnError::InvalidEscapeSequence(_)));
}
}
#[test]
fn test_serialization_smart_quoting() {
let cap = CapUrnBuilder::new()
.in_spec(MEDIA_VOID)
.out_spec(MEDIA_OBJECT)
.tag("key", "simple")
.build()
.unwrap();
let s = cap.to_string();
assert!(s.contains("key=simple"));
let cap2 = CapUrnBuilder::new()
.in_spec(MEDIA_VOID)
.out_spec(MEDIA_OBJECT)
.tag("key", "has spaces")
.build()
.unwrap();
let s2 = cap2.to_string();
assert!(s2.contains(r#"key="has spaces""#));
let cap4 = CapUrnBuilder::new()
.in_spec(MEDIA_VOID)
.out_spec(MEDIA_OBJECT)
.tag("key", "HasUpper")
.build()
.unwrap();
let s4 = cap4.to_string();
assert!(s4.contains(r#"key="HasUpper""#));
}
#[test]
fn test_round_trip_simple() {
let original = test_urn("op=generate;ext=pdf");
let cap = CapUrn::from_string(&original).unwrap();
let serialized = cap.to_string();
let reparsed = CapUrn::from_string(&serialized).unwrap();
assert_eq!(cap, reparsed);
}
#[test]
fn test_round_trip_quoted() {
let original = test_urn(r#"key="Value With Spaces""#);
let cap = CapUrn::from_string(&original).unwrap();
let serialized = cap.to_string();
let reparsed = CapUrn::from_string(&serialized).unwrap();
assert_eq!(cap, reparsed);
assert_eq!(
reparsed.get_tag("key"),
Some(&"Value With Spaces".to_string())
);
}
#[test]
fn test_round_trip_escapes() {
let original = test_urn(r#"key="value\"with\\escapes""#);
let cap = CapUrn::from_string(&original).unwrap();
assert_eq!(
cap.get_tag("key"),
Some(&r#"value"with\escapes"#.to_string())
);
let serialized = cap.to_string();
let reparsed = CapUrn::from_string(&serialized).unwrap();
assert_eq!(cap, reparsed);
}
#[test]
fn test_cap_prefix_required() {
assert!(CapUrn::from_string(&format!(
"in=\"{}\";out=\"{}\";op=generate",
MEDIA_VOID, MEDIA_OBJECT
))
.is_err());
let cap = CapUrn::from_string(&test_urn("op=generate;ext=pdf")).unwrap();
assert_eq!(cap.get_tag("op"), Some(&"generate".to_string()));
let cap2 = CapUrn::from_string(&format!(
"CAP:in=\"{}\";out=\"{}\";op=generate",
MEDIA_VOID, MEDIA_OBJECT
))
.unwrap();
assert_eq!(cap2.get_tag("op"), Some(&"generate".to_string()));
}
#[test]
fn test_trailing_semicolon_equivalence() {
let cap1 = CapUrn::from_string(&test_urn("op=generate;ext=pdf")).unwrap();
let cap2 =
CapUrn::from_string(&format!("{};", test_urn("op=generate;ext=pdf"))).unwrap();
assert_eq!(cap1, cap2);
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher1 = DefaultHasher::new();
cap1.hash(&mut hasher1);
let hash1 = hasher1.finish();
let mut hasher2 = DefaultHasher::new();
cap2.hash(&mut hasher2);
let hash2 = hasher2.finish();
assert_eq!(hash1, hash2);
assert_eq!(cap1.to_string(), cap2.to_string());
assert!(cap1.matches(&cap2));
assert!(cap2.matches(&cap1));
}
#[test]
fn test_tag_matching() {
let cap = CapUrn::from_string(&test_urn("op=generate;ext=pdf;target=thumbnail")).unwrap();
let request1 =
CapUrn::from_string(&test_urn("op=generate;ext=pdf;target=thumbnail")).unwrap();
assert!(cap.matches(&request1));
let request2 = CapUrn::from_string(&test_urn("op=generate")).unwrap();
assert!(cap.matches(&request2));
let request3 = CapUrn::from_string(&test_urn("ext=*")).unwrap();
assert!(cap.matches(&request3));
let request4 = CapUrn::from_string(&test_urn("op=extract")).unwrap();
assert!(!cap.matches(&request4));
}
#[test]
fn test_matching_case_sensitive_values() {
let cap1 = CapUrn::from_string(&test_urn(r#"key="Value""#)).unwrap();
let cap2 = CapUrn::from_string(&test_urn(r#"key="value""#)).unwrap();
assert!(!cap1.matches(&cap2));
assert!(!cap2.matches(&cap1));
let cap3 = CapUrn::from_string(&test_urn(r#"key="Value""#)).unwrap();
assert!(cap1.matches(&cap3));
}
#[test]
fn test_missing_tag_handling() {
let cap = CapUrn::from_string(&test_urn("op=generate")).unwrap();
let request1 = CapUrn::from_string(&test_urn("ext=pdf")).unwrap();
assert!(cap.matches(&request1));
let cap2 = CapUrn::from_string(&test_urn("op=generate;ext=pdf")).unwrap();
let request2 = CapUrn::from_string(&test_urn("op=generate")).unwrap();
assert!(cap2.matches(&request2));
}
#[test]
fn test_specificity() {
let cap1 = CapUrn::from_string(&test_urn("type=general")).unwrap();
let cap2 = CapUrn::from_string(&test_urn("op=generate")).unwrap();
let cap3 = CapUrn::from_string(&test_urn("op=*;ext=pdf")).unwrap();
assert_eq!(cap1.specificity(), 3); assert_eq!(cap2.specificity(), 3); assert_eq!(cap3.specificity(), 3);
let cap4 =
CapUrn::from_string(&format!("cap:in=*;out=\"{}\";op=test", MEDIA_OBJECT)).unwrap();
assert_eq!(cap4.specificity(), 2); }
#[test]
fn test_builder() {
let cap = CapUrnBuilder::new()
.in_spec(MEDIA_VOID)
.out_spec(MEDIA_OBJECT)
.tag("op", "generate")
.tag("target", "thumbnail")
.tag("ext", "pdf")
.build()
.unwrap();
assert_eq!(cap.get_tag("op"), Some(&"generate".to_string()));
assert_eq!(cap.in_spec(), MEDIA_VOID);
assert_eq!(cap.out_spec(), MEDIA_OBJECT);
}
#[test]
fn test_builder_requires_direction() {
let result = CapUrnBuilder::new()
.out_spec(MEDIA_OBJECT)
.tag("op", "test")
.build();
assert!(result.is_err());
let result = CapUrnBuilder::new()
.in_spec(MEDIA_VOID)
.tag("op", "test")
.build();
assert!(result.is_err());
let result = CapUrnBuilder::new()
.in_spec(MEDIA_VOID)
.out_spec(MEDIA_OBJECT)
.build();
assert!(result.is_ok());
}
#[test]
fn test_builder_preserves_case() {
let cap = CapUrnBuilder::new()
.in_spec(MEDIA_VOID)
.out_spec(MEDIA_OBJECT)
.tag("KEY", "ValueWithCase")
.build()
.unwrap();
assert_eq!(cap.get_tag("key"), Some(&"ValueWithCase".to_string()));
}
#[test]
fn test_compatibility() {
let cap1 = CapUrn::from_string(&test_urn("op=generate;ext=pdf")).unwrap();
let cap2 = CapUrn::from_string(&test_urn("op=generate;format=*")).unwrap();
let cap3 = CapUrn::from_string(&test_urn("type=image;op=extract")).unwrap();
assert!(cap1.is_compatible_with(&cap2));
assert!(cap2.is_compatible_with(&cap1));
assert!(!cap1.is_compatible_with(&cap3));
let cap4 = CapUrn::from_string(&test_urn("op=generate")).unwrap();
assert!(cap1.is_compatible_with(&cap4));
assert!(cap4.is_compatible_with(&cap1));
let cap5 = CapUrn::from_string(&format!(
"cap:in=media:binary;out=\"{}\";op=generate",
MEDIA_OBJECT
))
.unwrap();
assert!(!cap1.is_compatible_with(&cap5));
}
#[test]
fn test_best_match() {
let caps = vec![
CapUrn::from_string(&test_urn("op=*")).unwrap(),
CapUrn::from_string(&test_urn("op=generate")).unwrap(),
CapUrn::from_string(&test_urn("op=generate;ext=pdf")).unwrap(),
];
let request = CapUrn::from_string(&test_urn("op=generate")).unwrap();
let best = CapMatcher::find_best_match(&caps, &request).unwrap();
assert_eq!(best.get_tag("ext"), Some(&"pdf".to_string()));
}
#[test]
fn test_merge_and_subset() {
let cap1 = CapUrn::from_string(&test_urn("op=generate")).unwrap();
let cap2 = CapUrn::from_string(&format!(
"cap:in=media:binary;out=media:integer;ext=pdf;output=binary"
))
.unwrap();
let merged = cap1.merge(&cap2);
assert_eq!(merged.in_spec(), "media:binary");
assert_eq!(merged.out_spec(), "media:integer");
assert_eq!(merged.get_tag("op"), Some(&"generate".to_string()));
assert_eq!(merged.get_tag("ext"), Some(&"pdf".to_string()));
let subset = merged.subset(&["type", "ext"]);
assert_eq!(subset.in_spec(), "media:binary");
assert_eq!(subset.get_tag("ext"), Some(&"pdf".to_string()));
assert_eq!(subset.get_tag("type"), None);
}
#[test]
fn test_wildcard_tag() {
let cap = CapUrn::from_string(&test_urn("ext=pdf")).unwrap();
let wildcarded = cap.clone().with_wildcard_tag("ext");
assert_eq!(wildcarded.get_tag("ext"), Some(&"*".to_string()));
let wildcard_in = cap.clone().with_wildcard_tag("in");
assert_eq!(wildcard_in.in_spec(), "*");
let wildcard_out = cap.clone().with_wildcard_tag("out");
assert_eq!(wildcard_out.out_spec(), "*");
}
#[test]
fn test_empty_cap_urn_not_allowed() {
let result = CapUrn::from_string("cap:");
assert!(result.is_err());
assert!(matches!(result, Err(CapUrnError::MissingInSpec)));
let result = CapUrn::from_string("cap:;");
assert!(result.is_err());
}
#[test]
fn test_minimal_cap_urn() {
let cap = CapUrn::from_string(&format!("cap:in=\"{}\";out=\"{}\"", MEDIA_VOID, MEDIA_OBJECT))
.unwrap();
assert_eq!(cap.in_spec(), MEDIA_VOID);
assert_eq!(cap.out_spec(), MEDIA_OBJECT);
assert!(cap.tags.is_empty());
}
#[test]
fn test_extended_character_support() {
let cap = CapUrn::from_string(&test_urn("url=https://example_org/api;path=/some/file"))
.unwrap();
assert_eq!(
cap.get_tag("url"),
Some(&"https://example_org/api".to_string())
);
assert_eq!(cap.get_tag("path"), Some(&"/some/file".to_string()));
}
#[test]
fn test_wildcard_restrictions() {
assert!(CapUrn::from_string(&test_urn("*=value")).is_err());
let cap = CapUrn::from_string(&test_urn("key=*")).unwrap();
assert_eq!(cap.get_tag("key"), Some(&"*".to_string()));
}
#[test]
fn test_duplicate_key_rejection() {
let result = CapUrn::from_string(&test_urn("key=value1;key=value2"));
assert!(result.is_err());
if let Err(e) = result {
assert!(matches!(e, CapUrnError::DuplicateKey(_)));
}
}
#[test]
fn test_numeric_key_restriction() {
assert!(CapUrn::from_string(&test_urn("123=value")).is_err());
assert!(CapUrn::from_string(&test_urn("key123=value")).is_ok());
assert!(CapUrn::from_string(&test_urn("123key=value")).is_ok());
assert!(CapUrn::from_string(&test_urn("key=123")).is_ok());
}
#[test]
fn test_empty_value_error() {
assert!(CapUrn::from_string(&test_urn("key=")).is_err());
assert!(CapUrn::from_string(&test_urn("key=;other=value")).is_err());
}
#[test]
fn test_has_tag_case_sensitive() {
let cap = CapUrn::from_string(&test_urn(r#"key="Value""#)).unwrap();
assert!(cap.has_tag("key", "Value"));
assert!(!cap.has_tag("key", "value"));
assert!(!cap.has_tag("key", "VALUE"));
assert!(cap.has_tag("KEY", "Value"));
assert!(cap.has_tag("Key", "Value"));
assert!(cap.has_tag("in", MEDIA_VOID));
assert!(cap.has_tag("out", MEDIA_OBJECT));
}
#[test]
fn test_with_tag_preserves_value() {
let cap = CapUrn::new(MEDIA_VOID.to_string(), MEDIA_OBJECT.to_string(), BTreeMap::new())
.with_tag("key".to_string(), "ValueWithCase".to_string()).unwrap();
assert_eq!(cap.get_tag("key"), Some(&"ValueWithCase".to_string()));
}
#[test]
fn test_with_tag_rejects_empty_value() {
let cap = CapUrn::new(MEDIA_VOID.to_string(), MEDIA_OBJECT.to_string(), BTreeMap::new());
let result = cap.with_tag("key".to_string(), "".to_string());
assert!(result.is_err());
}
#[test]
fn test_semantic_equivalence() {
let unquoted = CapUrn::from_string(&test_urn("key=simple")).unwrap();
let quoted = CapUrn::from_string(&test_urn(r#"key="simple""#)).unwrap();
assert_eq!(unquoted, quoted);
assert!(unquoted.to_string().contains("key=simple"));
assert!(quoted.to_string().contains("key=simple"));
}
#[test]
fn test_get_tag_returns_direction_specs() {
let in_str = "media:string";
let out_int = "media:integer";
let cap = CapUrn::from_string(&format!(
"cap:in=\"{}\";op=test;out=\"{}\"",
in_str, out_int
))
.unwrap();
assert_eq!(cap.get_tag("in"), Some(&in_str.to_string()));
assert_eq!(cap.get_tag("out"), Some(&out_int.to_string()));
assert_eq!(cap.get_tag("op"), Some(&"test".to_string()));
assert_eq!(cap.get_tag("IN"), Some(&in_str.to_string()));
assert_eq!(cap.get_tag("OUT"), Some(&out_int.to_string()));
}
#[test]
fn test_matching_semantics_test1_exact_match() {
let cap = CapUrn::from_string(&test_urn("op=generate;ext=pdf")).unwrap();
let request = CapUrn::from_string(&test_urn("op=generate;ext=pdf")).unwrap();
assert!(cap.matches(&request), "Test 1: Exact match should succeed");
}
#[test]
fn test_matching_semantics_test2_cap_missing_tag() {
let cap = CapUrn::from_string(&test_urn("op=generate")).unwrap();
let request = CapUrn::from_string(&test_urn("op=generate;ext=pdf")).unwrap();
assert!(
cap.matches(&request),
"Test 2: Cap missing tag should match (implicit wildcard)"
);
}
#[test]
fn test_matching_semantics_test3_cap_has_extra_tag() {
let cap = CapUrn::from_string(&test_urn("op=generate;ext=pdf;version=2")).unwrap();
let request = CapUrn::from_string(&test_urn("op=generate;ext=pdf")).unwrap();
assert!(
cap.matches(&request),
"Test 3: Cap with extra tag should match"
);
}
#[test]
fn test_matching_semantics_test4_request_has_wildcard() {
let cap = CapUrn::from_string(&test_urn("op=generate;ext=pdf")).unwrap();
let request = CapUrn::from_string(&test_urn("op=generate;ext=*")).unwrap();
assert!(
cap.matches(&request),
"Test 4: Request wildcard should match"
);
}
#[test]
fn test_matching_semantics_test5_cap_has_wildcard() {
let cap = CapUrn::from_string(&test_urn("op=generate;ext=*")).unwrap();
let request = CapUrn::from_string(&test_urn("op=generate;ext=pdf")).unwrap();
assert!(cap.matches(&request), "Test 5: Cap wildcard should match");
}
#[test]
fn test_matching_semantics_test6_value_mismatch() {
let cap = CapUrn::from_string(&test_urn("op=generate;ext=pdf")).unwrap();
let request = CapUrn::from_string(&test_urn("op=generate;ext=docx")).unwrap();
assert!(
!cap.matches(&request),
"Test 6: Value mismatch should not match"
);
}
#[test]
fn test_matching_semantics_test7_fallback_pattern() {
let in_bin = "media:binary";
let cap = CapUrn::from_string(&format!(
"cap:in=\"{}\";op=generate_thumbnail;out=\"{}\"",
in_bin, in_bin
))
.unwrap();
let request = CapUrn::from_string(&format!(
"cap:ext=wav;in=\"{}\";op=generate_thumbnail;out=\"{}\"",
in_bin, in_bin
))
.unwrap();
assert!(
cap.matches(&request),
"Test 7: Fallback pattern should match (cap missing ext = implicit wildcard)"
);
}
#[test]
fn test_matching_semantics_test7b_thumbnail_void_input() {
let out_bin = "media:binary";
let cap = CapUrn::from_string(&format!(
"cap:in=\"{}\";op=generate_thumbnail;out=\"{}\"",
MEDIA_VOID, out_bin
))
.unwrap();
let request = CapUrn::from_string(&format!(
"cap:ext=wav;in=\"{}\";op=generate_thumbnail;out=\"{}\"",
MEDIA_VOID, out_bin
))
.unwrap();
assert!(
cap.matches(&request),
"Test 7b: Thumbnail fallback with void input should match"
);
}
#[test]
fn test_matching_semantics_test8_wildcard_direction_matches_anything() {
let cap = CapUrn::from_string("cap:in=*;out=*").unwrap();
let request = CapUrn::from_string(&format!(
"cap:ext=pdf;in=media:string;op=generate;out=\"{}\"",
MEDIA_OBJECT
))
.unwrap();
assert!(
cap.matches(&request),
"Test 8: Wildcard direction should match any direction"
);
}
#[test]
fn test_matching_semantics_test9_cross_dimension_independence() {
let cap = CapUrn::from_string(&test_urn("op=generate")).unwrap();
let request = CapUrn::from_string(&test_urn("ext=pdf")).unwrap();
assert!(
cap.matches(&request),
"Test 9: Cross-dimension independence should match"
);
}
#[test]
fn test_matching_semantics_test10_direction_mismatch() {
let cap = CapUrn::from_string(&format!(
"cap:in=media:string;op=generate;out=\"{}\"",
MEDIA_OBJECT
))
.unwrap();
let request = CapUrn::from_string(&format!(
"cap:in=media:binary;op=generate;out=\"{}\"",
MEDIA_OBJECT
))
.unwrap();
assert!(
!cap.matches(&request),
"Test 10: Direction mismatch should not match"
);
}
}