use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct UriInfoEntry {
pub data: String,
pub metadata: Vec<(String, String)>,
}
impl UriInfoEntry {
pub fn param(&self, key: &str) -> Option<&str> {
self.metadata
.iter()
.find_map(|(k, v)| {
if k.eq_ignore_ascii_case(key) {
Some(v.as_str())
} else {
None
}
})
}
pub fn purpose(&self) -> Option<&str> {
self.param("purpose")
}
}
impl fmt::Display for UriInfoEntry {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "<{}>", self.data)?;
for (key, value) in &self.metadata {
if value.is_empty() {
write!(f, ";{key}")?;
} else {
write!(f, ";{key}={value}")?;
}
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UriInfo(Vec<UriInfoEntry>);
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum UriInfoError {
Empty,
MissingAngleBrackets(String),
}
impl fmt::Display for UriInfoError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Empty => write!(f, "empty URI-info header value"),
Self::MissingAngleBrackets(raw) => {
write!(f, "missing angle brackets in URI-info entry: {raw}")
}
}
}
}
impl std::error::Error for UriInfoError {}
fn parse_entry(raw: &str) -> Result<UriInfoEntry, UriInfoError> {
let raw = raw.trim();
if raw.is_empty() {
return Err(UriInfoError::MissingAngleBrackets(raw.to_string()));
}
let (data_part, metadata_part) = match raw.split_once(';') {
Some((d, m)) => (d, Some(m)),
None => (raw, None),
};
let data = data_part
.trim()
.trim_matches(|c| c == '<' || c == '>')
.to_string();
if data.is_empty() {
return Err(UriInfoError::MissingAngleBrackets(raw.to_string()));
}
let mut metadata = Vec::new();
if let Some(meta_str) = metadata_part {
if !meta_str.is_empty() {
for segment in meta_str.split(';') {
let segment = segment.trim();
if segment.is_empty() {
continue;
}
if let Some((key, value)) = segment.split_once('=') {
metadata.push((
key.trim()
.to_ascii_lowercase(),
value
.trim()
.to_string(),
));
} else {
metadata.push((segment.to_ascii_lowercase(), String::new()));
}
}
}
}
Ok(UriInfoEntry { data, metadata })
}
use crate::split_comma_entries;
impl UriInfo {
pub fn parse(raw: &str) -> Result<Self, UriInfoError> {
let raw = raw.trim();
if raw.is_empty() {
return Err(UriInfoError::Empty);
}
Self::from_entries(split_comma_entries(raw))
}
pub fn from_entries<'a>(
entries: impl IntoIterator<Item = &'a str>,
) -> Result<Self, UriInfoError> {
let entries: Vec<_> = entries
.into_iter()
.map(parse_entry)
.collect::<Result<_, _>>()?;
if entries.is_empty() {
return Err(UriInfoError::Empty);
}
Ok(Self(entries))
}
pub fn entries(&self) -> &[UriInfoEntry] {
&self.0
}
pub fn into_entries(self) -> Vec<UriInfoEntry> {
self.0
}
pub fn len(&self) -> usize {
self.0
.len()
}
pub fn is_empty(&self) -> bool {
self.0
.is_empty()
}
}
impl fmt::Display for UriInfo {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
crate::fmt_joined(f, &self.0, ",")
}
}
impl<'a> IntoIterator for &'a UriInfo {
type Item = &'a UriInfoEntry;
type IntoIter = std::slice::Iter<'a, UriInfoEntry>;
fn into_iter(self) -> Self::IntoIter {
self.0
.iter()
}
}
impl IntoIterator for UriInfo {
type Item = UriInfoEntry;
type IntoIter = std::vec::IntoIter<UriInfoEntry>;
fn into_iter(self) -> Self::IntoIter {
self.0
.into_iter()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn entry_no_metadata() {
let entry = parse_entry("<data>").unwrap();
assert_eq!(entry.data, "data");
assert!(entry
.metadata
.is_empty());
}
#[test]
fn entry_no_metadata_trailing_semicolon() {
let entry = parse_entry("<data>;").unwrap();
assert_eq!(entry.data, "data");
assert!(entry
.metadata
.is_empty());
}
#[test]
fn entry_no_value_metadata() {
let entry = parse_entry("<data>;meta1").unwrap();
assert_eq!(
entry
.metadata
.len(),
1
);
assert_eq!(entry.metadata[0], ("meta1".to_string(), String::new()));
}
#[test]
fn entry_empty_value_metadata() {
let entry = parse_entry("<data>;meta1=").unwrap();
assert_eq!(
entry
.metadata
.len(),
1
);
assert_eq!(entry.metadata[0], ("meta1".to_string(), String::new()));
}
#[test]
fn entry_two_metadata_items() {
let entry = parse_entry("<data>;meta1=one;meta2=two;").unwrap();
assert_eq!(entry.data, "data");
assert_eq!(
entry
.metadata
.len(),
2
);
assert_eq!(entry.param("meta1"), Some("one"));
assert_eq!(entry.param("meta2"), Some("two"));
}
#[test]
fn entry_strips_angle_brackets() {
let entry = parse_entry("<data>;meta1=one;meta2=two;").unwrap();
assert_eq!(entry.data, "data");
}
#[test]
fn entry_uppercase_metadata_key_lowercased() {
let entry = parse_entry("<data>;Meta-1=one").unwrap();
assert!(entry
.metadata
.iter()
.all(|(k, _)| k == &k.to_ascii_lowercase()));
assert_eq!(entry.param("meta-1"), Some("one"));
}
#[test]
fn entry_display_no_trailing_semicolon() {
let entry = parse_entry("<data>;").unwrap();
let s = entry.to_string();
assert!(!s.ends_with(';'));
}
#[test]
fn entry_display_metadata_no_trailing_semicolon() {
let entry = parse_entry("<data>;meta=one;").unwrap();
let s = entry.to_string();
assert!(!s.ends_with(';'));
}
#[test]
fn entry_display_contains_all_metadata() {
let entry = parse_entry("<http://somedata/?arg=123>").unwrap();
let mut entry = entry;
entry
.metadata
.push(("meta1".to_string(), "one".to_string()));
entry
.metadata
.push(("meta2".to_string(), "two".to_string()));
let s = entry.to_string();
assert!(
s.matches(';')
.count()
>= 2
);
}
#[test]
fn entry_display_no_value_key() {
let entry = parse_entry("<data>;flagkey").unwrap();
assert_eq!(entry.to_string(), "<data>;flagkey");
}
const SAMPLE_EMERGENCY: &str = "\
<urn:emergency:uid:callid:20250401080740945abc123:bcf.example.com>;purpose=emergency-CallId,\
<urn:emergency:uid:incidentid:20250401080740945def456:bcf.example.com>;purpose=emergency-IncidentId,\
<https://adr.example.com/api/v1/adr/call/providerInfo/access?token=abc>;purpose=EmergencyCallData.ProviderInfo,\
<https://adr.example.com/api/v1/adr/call/serviceInfo?token=ghi>;purpose=EmergencyCallData.ServiceInfo";
const SAMPLE_WITH_SITE: &str = "\
<urn:emergency:uid:callid:test:bcf.example.com>;purpose=emergency-CallId;site=bcf.example.com,\
<urn:emergency:uid:incidentid:test:bcf.example.com>;purpose=emergency-IncidentId";
const SAMPLE_FULL: &str = "\
<urn:nena:callid:20190912100022147abc:bcf1.example.com>;purpose=nena-CallId,\
<https://eido.psap.example.com/EidoRetrievalService/urn:nena:incidentid:test>;purpose=emergency_incident_data_object,\
<urn:nena:incidentid:20190912100022147def:bcf1.example.com>;purpose=nena-IncidentId,\
<https://adr.example.com/api/v1/adr/call/providerInfo/access?token=a>;purpose=EmergencyCallData.ProviderInfo,\
<https://adr.example.com/api/v1/adr/call/providerInfo/telecom?token=b>;purpose=EmergencyCallData.ProviderInfo;site=bcf.example.com;,\
<https://adr.example.com/api/v1/adr/call/serviceInfo?token=c>;purpose=EmergencyCallData.ServiceInfo,\
<https://adr.example.com/api/v1/adr/call/subscriberInfo?token=d>;purpose=EmergencyCallData.SubscriberInfo,\
<https://adr.example.com/api/v1/adr/call/comment?token=e>;purpose=EmergencyCallData.Comment";
#[test]
fn parse_comma_separated() {
let info = UriInfo::parse(SAMPLE_EMERGENCY).unwrap();
assert_eq!(info.len(), 4);
assert_eq!(info.entries()[0].purpose(), Some("emergency-CallId"));
assert_eq!(info.entries()[1].purpose(), Some("emergency-IncidentId"));
}
#[test]
fn parse_full_fixture_all_entries() {
let info = UriInfo::parse(SAMPLE_FULL).unwrap();
assert_eq!(info.len(), 8);
}
#[test]
fn full_fixture_nena_prefix_callid() {
let info = UriInfo::parse(SAMPLE_FULL).unwrap();
let entry = info
.entries()
.iter()
.find(|e| e.purpose() == Some("nena-CallId"))
.unwrap();
assert!(entry
.data
.contains("callid"));
}
#[test]
fn full_fixture_legacy_eido_purpose() {
let info = UriInfo::parse(SAMPLE_FULL).unwrap();
let eido: Vec<_> = info
.entries()
.iter()
.filter(|e| {
e.purpose()
.is_some_and(|p| p.contains("incident_data_object"))
})
.collect();
assert_eq!(eido.len(), 1);
assert!(eido[0]
.data
.contains("EidoRetrievalService"));
}
#[test]
fn full_fixture_trailing_semicolon_with_site() {
let info = UriInfo::parse(SAMPLE_FULL).unwrap();
let with_site: Vec<_> = info
.entries()
.iter()
.filter(|e| {
e.param("site")
.is_some()
})
.collect();
assert_eq!(with_site.len(), 1);
assert_eq!(with_site[0].param("site"), Some("bcf.example.com"));
}
#[test]
fn find_by_purpose() {
let info = UriInfo::parse(SAMPLE_EMERGENCY).unwrap();
let call_id = info
.entries()
.iter()
.find(|e| e.purpose() == Some("emergency-CallId"))
.unwrap();
assert!(call_id
.data
.contains("callid"));
let incident = info
.entries()
.iter()
.find(|e| e.purpose() == Some("emergency-IncidentId"))
.unwrap();
assert!(incident
.data
.contains("incidentid"));
}
#[test]
fn param_lookup_by_purpose() {
let legacy = "<urn:nena:callid:test:example.ca>;purpose=nena-CallId";
let info = UriInfo::parse(legacy).unwrap();
assert_eq!(info.entries()[0].purpose(), Some("nena-CallId"));
let modern = "<urn:emergency:uid:callid:test:example.ca>;purpose=emergency-CallId";
let info = UriInfo::parse(modern).unwrap();
assert_eq!(info.entries()[0].purpose(), Some("emergency-CallId"));
}
#[test]
fn filter_entries_by_param() {
let info = UriInfo::parse(SAMPLE_EMERGENCY).unwrap();
let adr: Vec<_> = info
.entries()
.iter()
.filter(|e| {
e.purpose()
.is_some_and(|p| p.ends_with("Info"))
})
.collect();
assert_eq!(adr.len(), 2);
}
#[test]
fn metadata_param_lookup() {
let info = UriInfo::parse(SAMPLE_WITH_SITE).unwrap();
assert_eq!(info.entries()[0].param("site"), Some("bcf.example.com"));
assert_eq!(info.entries()[0].param("purpose"), Some("emergency-CallId"));
assert!(info.entries()[1]
.param("site")
.is_none());
}
#[test]
fn display_roundtrip() {
let raw = "<urn:example:test>;purpose=test-purpose;site=example.com";
let info = UriInfo::parse(raw).unwrap();
assert_eq!(info.to_string(), raw);
}
#[test]
fn display_comma_count_matches_entries() {
let info = UriInfo::parse(SAMPLE_EMERGENCY).unwrap();
let s = info.to_string();
assert_eq!(
s.matches(',')
.count()
+ 1,
info.len()
);
}
#[test]
fn empty_input() {
assert!(matches!(UriInfo::parse(""), Err(UriInfoError::Empty)));
}
}