1use std::fmt;
7
8#[derive(Debug, Clone, PartialEq, Eq)]
13#[non_exhaustive]
14pub struct UriInfoEntry {
15 pub data: String,
17 pub metadata: Vec<(String, String)>,
21}
22
23impl UriInfoEntry {
24 pub fn param(&self, key: &str) -> Option<&str> {
26 self.metadata
27 .iter()
28 .find_map(|(k, v)| {
29 if k.eq_ignore_ascii_case(key) {
30 Some(v.as_str())
31 } else {
32 None
33 }
34 })
35 }
36
37 pub fn purpose(&self) -> Option<&str> {
39 self.param("purpose")
40 }
41}
42
43impl fmt::Display for UriInfoEntry {
44 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
45 write!(f, "<{}>", self.data)?;
46 for (key, value) in &self.metadata {
47 if value.is_empty() {
48 write!(f, ";{key}")?;
49 } else {
50 write!(f, ";{key}={value}")?;
51 }
52 }
53 Ok(())
54 }
55}
56
57#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct UriInfo(Vec<UriInfoEntry>);
71
72#[derive(Debug, Clone, PartialEq, Eq)]
74#[non_exhaustive]
75pub enum UriInfoError {
76 Empty,
78 MissingAngleBrackets(String),
80}
81
82impl fmt::Display for UriInfoError {
83 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
84 match self {
85 Self::Empty => write!(f, "empty URI-info header value"),
86 Self::MissingAngleBrackets(raw) => {
87 write!(f, "missing angle brackets in URI-info entry: {raw}")
88 }
89 }
90 }
91}
92
93impl std::error::Error for UriInfoError {}
94
95fn parse_entry(raw: &str) -> Result<UriInfoEntry, UriInfoError> {
96 let raw = raw.trim();
97 if raw.is_empty() {
98 return Err(UriInfoError::MissingAngleBrackets(raw.to_string()));
99 }
100
101 let (data_part, metadata_part) = match raw.split_once(';') {
104 Some((d, m)) => (d, Some(m)),
105 None => (raw, None),
106 };
107
108 let data = data_part
109 .trim()
110 .trim_matches(|c| c == '<' || c == '>')
111 .to_string();
112 if data.is_empty() {
113 return Err(UriInfoError::MissingAngleBrackets(raw.to_string()));
114 }
115
116 let mut metadata = Vec::new();
117 if let Some(meta_str) = metadata_part {
118 if !meta_str.is_empty() {
119 for segment in meta_str.split(';') {
120 let segment = segment.trim();
121 if segment.is_empty() {
122 continue;
123 }
124 if let Some((key, value)) = segment.split_once('=') {
125 metadata.push((
126 key.trim()
127 .to_ascii_lowercase(),
128 value
129 .trim()
130 .to_string(),
131 ));
132 } else {
133 metadata.push((segment.to_ascii_lowercase(), String::new()));
134 }
135 }
136 }
137 }
138
139 Ok(UriInfoEntry { data, metadata })
140}
141
142use crate::split_comma_entries;
143
144impl UriInfo {
145 pub fn parse(raw: &str) -> Result<Self, UriInfoError> {
147 let raw = raw.trim();
148 if raw.is_empty() {
149 return Err(UriInfoError::Empty);
150 }
151 Self::from_entries(split_comma_entries(raw))
152 }
153
154 pub fn from_entries<'a>(
163 entries: impl IntoIterator<Item = &'a str>,
164 ) -> Result<Self, UriInfoError> {
165 let parsed: Vec<_> = entries
166 .into_iter()
167 .filter_map(|raw| parse_entry(raw).ok())
168 .collect();
169 if parsed.is_empty() {
170 return Err(UriInfoError::Empty);
171 }
172 Ok(Self(parsed))
173 }
174
175 pub fn entries(&self) -> &[UriInfoEntry] {
177 &self.0
178 }
179
180 pub fn into_entries(self) -> Vec<UriInfoEntry> {
182 self.0
183 }
184
185 pub fn len(&self) -> usize {
187 self.0
188 .len()
189 }
190
191 pub fn is_empty(&self) -> bool {
193 self.0
194 .is_empty()
195 }
196}
197
198impl fmt::Display for UriInfo {
199 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
200 crate::fmt_joined(f, &self.0, ",")
201 }
202}
203
204impl<'a> IntoIterator for &'a UriInfo {
205 type Item = &'a UriInfoEntry;
206 type IntoIter = std::slice::Iter<'a, UriInfoEntry>;
207
208 fn into_iter(self) -> Self::IntoIter {
209 self.0
210 .iter()
211 }
212}
213
214impl IntoIterator for UriInfo {
215 type Item = UriInfoEntry;
216 type IntoIter = std::vec::IntoIter<UriInfoEntry>;
217
218 fn into_iter(self) -> Self::IntoIter {
219 self.0
220 .into_iter()
221 }
222}
223
224#[cfg(test)]
225mod tests {
226 use super::*;
227
228 #[test]
231 fn entry_no_metadata() {
232 let entry = parse_entry("<data>").unwrap();
233 assert_eq!(entry.data, "data");
234 assert!(entry
235 .metadata
236 .is_empty());
237 }
238
239 #[test]
240 fn entry_no_metadata_trailing_semicolon() {
241 let entry = parse_entry("<data>;").unwrap();
242 assert_eq!(entry.data, "data");
243 assert!(entry
244 .metadata
245 .is_empty());
246 }
247
248 #[test]
249 fn entry_no_value_metadata() {
250 let entry = parse_entry("<data>;meta1").unwrap();
251 assert_eq!(
252 entry
253 .metadata
254 .len(),
255 1
256 );
257 assert_eq!(entry.metadata[0], ("meta1".to_string(), String::new()));
258 }
259
260 #[test]
261 fn entry_empty_value_metadata() {
262 let entry = parse_entry("<data>;meta1=").unwrap();
263 assert_eq!(
264 entry
265 .metadata
266 .len(),
267 1
268 );
269 assert_eq!(entry.metadata[0], ("meta1".to_string(), String::new()));
270 }
271
272 #[test]
273 fn entry_two_metadata_items() {
274 let entry = parse_entry("<data>;meta1=one;meta2=two;").unwrap();
275 assert_eq!(entry.data, "data");
276 assert_eq!(
277 entry
278 .metadata
279 .len(),
280 2
281 );
282 assert_eq!(entry.param("meta1"), Some("one"));
283 assert_eq!(entry.param("meta2"), Some("two"));
284 }
285
286 #[test]
287 fn entry_strips_angle_brackets() {
288 let entry = parse_entry("<data>;meta1=one;meta2=two;").unwrap();
289 assert_eq!(entry.data, "data");
290 }
291
292 #[test]
293 fn entry_uppercase_metadata_key_lowercased() {
294 let entry = parse_entry("<data>;Meta-1=one").unwrap();
295 assert!(entry
296 .metadata
297 .iter()
298 .all(|(k, _)| k == &k.to_ascii_lowercase()));
299 assert_eq!(entry.param("meta-1"), Some("one"));
300 }
301
302 #[test]
303 fn entry_display_no_trailing_semicolon() {
304 let entry = parse_entry("<data>;").unwrap();
305 let s = entry.to_string();
306 assert!(!s.ends_with(';'));
307 }
308
309 #[test]
310 fn entry_display_metadata_no_trailing_semicolon() {
311 let entry = parse_entry("<data>;meta=one;").unwrap();
312 let s = entry.to_string();
313 assert!(!s.ends_with(';'));
314 }
315
316 #[test]
317 fn entry_display_contains_all_metadata() {
318 let entry = parse_entry("<http://somedata/?arg=123>").unwrap();
319 let mut entry = entry;
321 entry
322 .metadata
323 .push(("meta1".to_string(), "one".to_string()));
324 entry
325 .metadata
326 .push(("meta2".to_string(), "two".to_string()));
327 let s = entry.to_string();
328 assert!(
329 s.matches(';')
330 .count()
331 >= 2
332 );
333 }
334
335 #[test]
336 fn entry_display_no_value_key() {
337 let entry = parse_entry("<data>;flagkey").unwrap();
338 assert_eq!(entry.to_string(), "<data>;flagkey");
339 }
340
341 const SAMPLE_EMERGENCY: &str = "\
344<urn:emergency:uid:callid:20250401080740945abc123:bcf.example.com>;purpose=emergency-CallId,\
345<urn:emergency:uid:incidentid:20250401080740945def456:bcf.example.com>;purpose=emergency-IncidentId,\
346<https://adr.example.com/api/v1/adr/call/providerInfo/access?token=abc>;purpose=EmergencyCallData.ProviderInfo,\
347<https://adr.example.com/api/v1/adr/call/serviceInfo?token=ghi>;purpose=EmergencyCallData.ServiceInfo";
348
349 const SAMPLE_WITH_SITE: &str = "\
350<urn:emergency:uid:callid:test:bcf.example.com>;purpose=emergency-CallId;site=bcf.example.com,\
351<urn:emergency:uid:incidentid:test:bcf.example.com>;purpose=emergency-IncidentId";
352
353 const SAMPLE_FULL: &str = "\
356<urn:nena:callid:20190912100022147abc:bcf1.example.com>;purpose=nena-CallId,\
357<https://eido.psap.example.com/EidoRetrievalService/urn:nena:incidentid:test>;purpose=emergency_incident_data_object,\
358<urn:nena:incidentid:20190912100022147def:bcf1.example.com>;purpose=nena-IncidentId,\
359<https://adr.example.com/api/v1/adr/call/providerInfo/access?token=a>;purpose=EmergencyCallData.ProviderInfo,\
360<https://adr.example.com/api/v1/adr/call/providerInfo/telecom?token=b>;purpose=EmergencyCallData.ProviderInfo;site=bcf.example.com;,\
361<https://adr.example.com/api/v1/adr/call/serviceInfo?token=c>;purpose=EmergencyCallData.ServiceInfo,\
362<https://adr.example.com/api/v1/adr/call/subscriberInfo?token=d>;purpose=EmergencyCallData.SubscriberInfo,\
363<https://adr.example.com/api/v1/adr/call/comment?token=e>;purpose=EmergencyCallData.Comment";
364
365 #[test]
366 fn parse_comma_separated() {
367 let info = UriInfo::parse(SAMPLE_EMERGENCY).unwrap();
368 assert_eq!(info.len(), 4);
369 assert_eq!(info.entries()[0].purpose(), Some("emergency-CallId"));
370 assert_eq!(info.entries()[1].purpose(), Some("emergency-IncidentId"));
371 }
372
373 #[test]
374 fn parse_full_fixture_all_entries() {
375 let info = UriInfo::parse(SAMPLE_FULL).unwrap();
376 assert_eq!(info.len(), 8);
377 }
378
379 #[test]
380 fn full_fixture_nena_prefix_callid() {
381 let info = UriInfo::parse(SAMPLE_FULL).unwrap();
382 let entry = info
383 .entries()
384 .iter()
385 .find(|e| e.purpose() == Some("nena-CallId"))
386 .unwrap();
387 assert!(entry
388 .data
389 .contains("callid"));
390 }
391
392 #[test]
393 fn full_fixture_legacy_eido_purpose() {
394 let info = UriInfo::parse(SAMPLE_FULL).unwrap();
395 let eido: Vec<_> = info
396 .entries()
397 .iter()
398 .filter(|e| {
399 e.purpose()
400 .is_some_and(|p| p.contains("incident_data_object"))
401 })
402 .collect();
403 assert_eq!(eido.len(), 1);
404 assert!(eido[0]
405 .data
406 .contains("EidoRetrievalService"));
407 }
408
409 #[test]
410 fn full_fixture_trailing_semicolon_with_site() {
411 let info = UriInfo::parse(SAMPLE_FULL).unwrap();
412 let with_site: Vec<_> = info
413 .entries()
414 .iter()
415 .filter(|e| {
416 e.param("site")
417 .is_some()
418 })
419 .collect();
420 assert_eq!(with_site.len(), 1);
421 assert_eq!(with_site[0].param("site"), Some("bcf.example.com"));
422 }
423
424 #[test]
425 fn find_by_purpose() {
426 let info = UriInfo::parse(SAMPLE_EMERGENCY).unwrap();
427
428 let call_id = info
429 .entries()
430 .iter()
431 .find(|e| e.purpose() == Some("emergency-CallId"))
432 .unwrap();
433 assert!(call_id
434 .data
435 .contains("callid"));
436
437 let incident = info
438 .entries()
439 .iter()
440 .find(|e| e.purpose() == Some("emergency-IncidentId"))
441 .unwrap();
442 assert!(incident
443 .data
444 .contains("incidentid"));
445 }
446
447 #[test]
448 fn param_lookup_by_purpose() {
449 let legacy = "<urn:nena:callid:test:example.ca>;purpose=nena-CallId";
450 let info = UriInfo::parse(legacy).unwrap();
451 assert_eq!(info.entries()[0].purpose(), Some("nena-CallId"));
452
453 let modern = "<urn:emergency:uid:callid:test:example.ca>;purpose=emergency-CallId";
454 let info = UriInfo::parse(modern).unwrap();
455 assert_eq!(info.entries()[0].purpose(), Some("emergency-CallId"));
456 }
457
458 #[test]
459 fn filter_entries_by_param() {
460 let info = UriInfo::parse(SAMPLE_EMERGENCY).unwrap();
461 let adr: Vec<_> = info
462 .entries()
463 .iter()
464 .filter(|e| {
465 e.purpose()
466 .is_some_and(|p| p.ends_with("Info"))
467 })
468 .collect();
469 assert_eq!(adr.len(), 2);
470 }
471
472 #[test]
473 fn metadata_param_lookup() {
474 let info = UriInfo::parse(SAMPLE_WITH_SITE).unwrap();
475 assert_eq!(info.entries()[0].param("site"), Some("bcf.example.com"));
476 assert_eq!(info.entries()[0].param("purpose"), Some("emergency-CallId"));
477 assert!(info.entries()[1]
478 .param("site")
479 .is_none());
480 }
481
482 #[test]
483 fn display_roundtrip() {
484 let raw = "<urn:example:test>;purpose=test-purpose;site=example.com";
485 let info = UriInfo::parse(raw).unwrap();
486 assert_eq!(info.to_string(), raw);
487 }
488
489 #[test]
490 fn display_comma_count_matches_entries() {
491 let info = UriInfo::parse(SAMPLE_EMERGENCY).unwrap();
492 let s = info.to_string();
493 assert_eq!(
494 s.matches(',')
495 .count()
496 + 1,
497 info.len()
498 );
499 }
500
501 #[test]
502 fn empty_input() {
503 assert!(matches!(UriInfo::parse(""), Err(UriInfoError::Empty)));
504 }
505
506 #[test]
507 fn parse_tolerates_trailing_comma() {
508 let raw =
509 "<urn:emergency:uid:incidentid:abc:bcf.example.com>;purpose=emergency-IncidentId, ";
510 let info = UriInfo::parse(raw).unwrap();
511 assert_eq!(info.len(), 1);
512 assert_eq!(info.entries()[0].purpose(), Some("emergency-IncidentId"));
513 }
514
515 #[test]
516 fn parse_tolerates_leading_comma() {
517 let raw =
518 ",<urn:emergency:uid:incidentid:abc:bcf.example.com>;purpose=emergency-IncidentId";
519 let info = UriInfo::parse(raw).unwrap();
520 assert_eq!(info.len(), 1);
521 }
522
523 #[test]
524 fn parse_tolerates_double_comma_between_valids() {
525 let raw = "<urn:emergency:uid:incidentid:abc:bcf.example.com>;purpose=emergency-IncidentId,,<https://adr.example.com/x>;purpose=EmergencyCallData.ProviderInfo";
526 let info = UriInfo::parse(raw).unwrap();
527 assert_eq!(info.len(), 2);
528 }
529
530 #[test]
531 fn parse_fails_only_when_all_entries_bad() {
532 assert!(matches!(UriInfo::parse(",,, "), Err(UriInfoError::Empty)));
533 }
534}