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 Malformed(String),
83}
84
85impl fmt::Display for UriInfoError {
86 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
87 match self {
88 Self::Empty => write!(f, "empty URI-info header value"),
89 Self::MissingAngleBrackets(raw) => {
90 write!(
91 f,
92 "missing angle brackets in URI-info entry ({} bytes)",
93 raw.len()
94 )
95 }
96 Self::Malformed(reason) => write!(f, "malformed URI-info value: {reason}"),
97 }
98 }
99}
100
101impl std::error::Error for UriInfoError {}
102
103fn parse_entry(raw: &str) -> Result<UriInfoEntry, UriInfoError> {
104 let raw = raw.trim();
105 if raw.is_empty() {
106 return Err(UriInfoError::MissingAngleBrackets(raw.to_string()));
107 }
108
109 let bracketed = raw
110 .strip_prefix('<')
111 .and_then(|s| s.split_once('>'))
112 .filter(|(_, rest)| {
113 let rest = rest.trim_start();
114 rest.is_empty() || rest.starts_with(';')
115 });
116 let (data, params) = bracketed.unwrap_or_else(|| {
119 let (data, params) = raw
120 .split_once(';')
121 .unwrap_or((raw, ""));
122 (
123 data.trim()
124 .trim_matches(|c| c == '<' || c == '>'),
125 params,
126 )
127 });
128 if data.is_empty() {
129 return Err(UriInfoError::MissingAngleBrackets(raw.to_string()));
130 }
131
132 let metadata = crate::parse_params(params)
133 .into_iter()
134 .map(|p| {
135 (
136 p.key
137 .to_ascii_lowercase(),
138 p.value
139 .unwrap_or("")
140 .to_string(),
141 )
142 })
143 .collect();
144
145 Ok(UriInfoEntry {
146 data: data.to_string(),
147 metadata,
148 })
149}
150
151use crate::split_comma_entries;
152
153impl UriInfo {
154 pub fn parse(raw: &str) -> Result<Self, UriInfoError> {
156 let raw = raw.trim();
157 if raw.is_empty() {
158 return Err(UriInfoError::Empty);
159 }
160 Self::from_entries(split_comma_entries(raw))
161 }
162
163 pub fn from_entries<'a>(
172 entries: impl IntoIterator<Item = &'a str>,
173 ) -> Result<Self, UriInfoError> {
174 let parsed: Vec<_> = entries
175 .into_iter()
176 .filter_map(|raw| parse_entry(raw).ok())
177 .collect();
178 if parsed.is_empty() {
179 return Err(UriInfoError::Empty);
180 }
181 Ok(Self(parsed))
182 }
183
184 pub fn entries(&self) -> &[UriInfoEntry] {
186 &self.0
187 }
188
189 pub fn into_entries(self) -> Vec<UriInfoEntry> {
191 self.0
192 }
193
194 pub fn len(&self) -> usize {
196 self.0
197 .len()
198 }
199
200 pub fn is_empty(&self) -> bool {
202 self.0
203 .is_empty()
204 }
205}
206
207impl fmt::Display for UriInfo {
208 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
209 crate::fmt_joined(f, &self.0, ",")
210 }
211}
212
213impl<'a> IntoIterator for &'a UriInfo {
214 type Item = &'a UriInfoEntry;
215 type IntoIter = std::slice::Iter<'a, UriInfoEntry>;
216
217 fn into_iter(self) -> Self::IntoIter {
218 self.0
219 .iter()
220 }
221}
222
223impl IntoIterator for UriInfo {
224 type Item = UriInfoEntry;
225 type IntoIter = std::vec::IntoIter<UriInfoEntry>;
226
227 fn into_iter(self) -> Self::IntoIter {
228 self.0
229 .into_iter()
230 }
231}
232
233#[cfg(test)]
234mod tests {
235 use super::*;
236
237 #[test]
240 fn entry_no_metadata() {
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_metadata_trailing_semicolon() {
250 let entry = parse_entry("<data>;").unwrap();
251 assert_eq!(entry.data, "data");
252 assert!(entry
253 .metadata
254 .is_empty());
255 }
256
257 #[test]
258 fn entry_no_value_metadata() {
259 let entry = parse_entry("<data>;meta1").unwrap();
260 assert_eq!(
261 entry
262 .metadata
263 .len(),
264 1
265 );
266 assert_eq!(entry.metadata[0], ("meta1".to_string(), String::new()));
267 }
268
269 #[test]
270 fn entry_empty_value_metadata() {
271 let entry = parse_entry("<data>;meta1=").unwrap();
272 assert_eq!(
273 entry
274 .metadata
275 .len(),
276 1
277 );
278 assert_eq!(entry.metadata[0], ("meta1".to_string(), String::new()));
279 }
280
281 #[test]
282 fn entry_two_metadata_items() {
283 let entry = parse_entry("<data>;meta1=one;meta2=two;").unwrap();
284 assert_eq!(entry.data, "data");
285 assert_eq!(
286 entry
287 .metadata
288 .len(),
289 2
290 );
291 assert_eq!(entry.param("meta1"), Some("one"));
292 assert_eq!(entry.param("meta2"), Some("two"));
293 }
294
295 #[test]
296 fn entry_strips_angle_brackets() {
297 let entry = parse_entry("<data>;meta1=one;meta2=two;").unwrap();
298 assert_eq!(entry.data, "data");
299 }
300
301 #[test]
302 fn entry_uppercase_metadata_key_lowercased() {
303 let entry = parse_entry("<data>;Meta-1=one").unwrap();
304 assert!(entry
305 .metadata
306 .iter()
307 .all(|(k, _)| k == &k.to_ascii_lowercase()));
308 assert_eq!(entry.param("meta-1"), Some("one"));
309 }
310
311 #[test]
312 fn entry_display_no_trailing_semicolon() {
313 let entry = parse_entry("<data>;").unwrap();
314 let s = entry.to_string();
315 assert!(!s.ends_with(';'));
316 }
317
318 #[test]
319 fn entry_display_metadata_no_trailing_semicolon() {
320 let entry = parse_entry("<data>;meta=one;").unwrap();
321 let s = entry.to_string();
322 assert!(!s.ends_with(';'));
323 }
324
325 #[test]
326 fn entry_display_contains_all_metadata() {
327 let entry = parse_entry("<http://somedata/?arg=123>").unwrap();
328 let mut entry = entry;
330 entry
331 .metadata
332 .push(("meta1".to_string(), "one".to_string()));
333 entry
334 .metadata
335 .push(("meta2".to_string(), "two".to_string()));
336 let s = entry.to_string();
337 assert!(
338 s.matches(';')
339 .count()
340 >= 2
341 );
342 }
343
344 #[test]
345 fn entry_display_no_value_key() {
346 let entry = parse_entry("<data>;flagkey").unwrap();
347 assert_eq!(entry.to_string(), "<data>;flagkey");
348 }
349
350 const SAMPLE_EMERGENCY: &str = "\
353<urn:emergency:uid:callid:20250401080740945abc123:bcf.example.com>;purpose=emergency-CallId,\
354<urn:emergency:uid:incidentid:20250401080740945def456:bcf.example.com>;purpose=emergency-IncidentId,\
355<https://adr.example.com/api/v1/adr/call/providerInfo/access?token=abc>;purpose=EmergencyCallData.ProviderInfo,\
356<https://adr.example.com/api/v1/adr/call/serviceInfo?token=ghi>;purpose=EmergencyCallData.ServiceInfo";
357
358 const SAMPLE_WITH_SITE: &str = "\
359<urn:emergency:uid:callid:test:bcf.example.com>;purpose=emergency-CallId;site=bcf.example.com,\
360<urn:emergency:uid:incidentid:test:bcf.example.com>;purpose=emergency-IncidentId";
361
362 const SAMPLE_FULL: &str = "\
365<urn:nena:callid:20190912100022147abc:bcf1.example.com>;purpose=nena-CallId,\
366<https://eido.psap.example.com/EidoRetrievalService/urn:nena:incidentid:test>;purpose=emergency_incident_data_object,\
367<urn:nena:incidentid:20190912100022147def:bcf1.example.com>;purpose=nena-IncidentId,\
368<https://adr.example.com/api/v1/adr/call/providerInfo/access?token=a>;purpose=EmergencyCallData.ProviderInfo,\
369<https://adr.example.com/api/v1/adr/call/providerInfo/telecom?token=b>;purpose=EmergencyCallData.ProviderInfo;site=bcf.example.com;,\
370<https://adr.example.com/api/v1/adr/call/serviceInfo?token=c>;purpose=EmergencyCallData.ServiceInfo,\
371<https://adr.example.com/api/v1/adr/call/subscriberInfo?token=d>;purpose=EmergencyCallData.SubscriberInfo,\
372<https://adr.example.com/api/v1/adr/call/comment?token=e>;purpose=EmergencyCallData.Comment";
373
374 #[test]
375 fn parse_comma_separated() {
376 let info = UriInfo::parse(SAMPLE_EMERGENCY).unwrap();
377 assert_eq!(info.len(), 4);
378 assert_eq!(info.entries()[0].purpose(), Some("emergency-CallId"));
379 assert_eq!(info.entries()[1].purpose(), Some("emergency-IncidentId"));
380 }
381
382 #[test]
383 fn parse_full_fixture_all_entries() {
384 let info = UriInfo::parse(SAMPLE_FULL).unwrap();
385 assert_eq!(info.len(), 8);
386 }
387
388 #[test]
389 fn full_fixture_nena_prefix_callid() {
390 let info = UriInfo::parse(SAMPLE_FULL).unwrap();
391 let entry = info
392 .entries()
393 .iter()
394 .find(|e| e.purpose() == Some("nena-CallId"))
395 .unwrap();
396 assert!(entry
397 .data
398 .contains("callid"));
399 }
400
401 #[test]
402 fn full_fixture_legacy_eido_purpose() {
403 let info = UriInfo::parse(SAMPLE_FULL).unwrap();
404 let eido: Vec<_> = info
405 .entries()
406 .iter()
407 .filter(|e| {
408 e.purpose()
409 .is_some_and(|p| p.contains("incident_data_object"))
410 })
411 .collect();
412 assert_eq!(eido.len(), 1);
413 assert!(eido[0]
414 .data
415 .contains("EidoRetrievalService"));
416 }
417
418 #[test]
419 fn full_fixture_trailing_semicolon_with_site() {
420 let info = UriInfo::parse(SAMPLE_FULL).unwrap();
421 let with_site: Vec<_> = info
422 .entries()
423 .iter()
424 .filter(|e| {
425 e.param("site")
426 .is_some()
427 })
428 .collect();
429 assert_eq!(with_site.len(), 1);
430 assert_eq!(with_site[0].param("site"), Some("bcf.example.com"));
431 }
432
433 #[test]
434 fn find_by_purpose() {
435 let info = UriInfo::parse(SAMPLE_EMERGENCY).unwrap();
436
437 let call_id = info
438 .entries()
439 .iter()
440 .find(|e| e.purpose() == Some("emergency-CallId"))
441 .unwrap();
442 assert!(call_id
443 .data
444 .contains("callid"));
445
446 let incident = info
447 .entries()
448 .iter()
449 .find(|e| e.purpose() == Some("emergency-IncidentId"))
450 .unwrap();
451 assert!(incident
452 .data
453 .contains("incidentid"));
454 }
455
456 #[test]
457 fn param_lookup_by_purpose() {
458 let legacy = "<urn:nena:callid:test:example.ca>;purpose=nena-CallId";
459 let info = UriInfo::parse(legacy).unwrap();
460 assert_eq!(info.entries()[0].purpose(), Some("nena-CallId"));
461
462 let modern = "<urn:emergency:uid:callid:test:example.ca>;purpose=emergency-CallId";
463 let info = UriInfo::parse(modern).unwrap();
464 assert_eq!(info.entries()[0].purpose(), Some("emergency-CallId"));
465 }
466
467 #[test]
468 fn filter_entries_by_param() {
469 let info = UriInfo::parse(SAMPLE_EMERGENCY).unwrap();
470 let adr: Vec<_> = info
471 .entries()
472 .iter()
473 .filter(|e| {
474 e.purpose()
475 .is_some_and(|p| p.ends_with("Info"))
476 })
477 .collect();
478 assert_eq!(adr.len(), 2);
479 }
480
481 #[test]
482 fn metadata_param_lookup() {
483 let info = UriInfo::parse(SAMPLE_WITH_SITE).unwrap();
484 assert_eq!(info.entries()[0].param("site"), Some("bcf.example.com"));
485 assert_eq!(info.entries()[0].param("purpose"), Some("emergency-CallId"));
486 assert!(info.entries()[1]
487 .param("site")
488 .is_none());
489 }
490
491 #[test]
492 fn display_roundtrip() {
493 let raw = "<urn:example:test>;purpose=test-purpose;site=example.com";
494 let info = UriInfo::parse(raw).unwrap();
495 assert_eq!(info.to_string(), raw);
496 }
497
498 #[test]
499 fn display_comma_count_matches_entries() {
500 let info = UriInfo::parse(SAMPLE_EMERGENCY).unwrap();
501 let s = info.to_string();
502 assert_eq!(
503 s.matches(',')
504 .count()
505 + 1,
506 info.len()
507 );
508 }
509
510 #[test]
511 fn empty_input() {
512 assert!(matches!(UriInfo::parse(""), Err(UriInfoError::Empty)));
513 }
514
515 #[test]
516 fn parse_tolerates_trailing_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 assert_eq!(info.entries()[0].purpose(), Some("emergency-IncidentId"));
522 }
523
524 #[test]
525 fn parse_tolerates_leading_comma() {
526 let raw =
527 ",<urn:emergency:uid:incidentid:abc:bcf.example.com>;purpose=emergency-IncidentId";
528 let info = UriInfo::parse(raw).unwrap();
529 assert_eq!(info.len(), 1);
530 }
531
532 #[test]
533 fn parse_tolerates_double_comma_between_valids() {
534 let raw = "<urn:emergency:uid:incidentid:abc:bcf.example.com>;purpose=emergency-IncidentId,,<https://adr.example.com/x>;purpose=EmergencyCallData.ProviderInfo";
535 let info = UriInfo::parse(raw).unwrap();
536 assert_eq!(info.len(), 2);
537 }
538
539 #[test]
540 fn parse_fails_only_when_all_entries_bad() {
541 assert!(matches!(UriInfo::parse(",,, "), Err(UriInfoError::Empty)));
542 }
543
544 #[test]
545 fn semicolon_inside_brackets_stays_in_data() {
546 let entry = parse_entry("<sip:a@example.com;lr>;purpose=icon").unwrap();
547 assert_eq!(entry.data, "sip:a@example.com;lr");
548 assert_eq!(entry.purpose(), Some("icon"));
549 assert_eq!(entry.to_string(), "<sip:a@example.com;lr>;purpose=icon");
550 }
551
552 #[test]
553 fn quoted_param_keeps_semicolon_and_quotes() {
554 let entry = parse_entry(r#"<https://example.com/a>;note="x;y";Purpose=info"#).unwrap();
555 assert_eq!(entry.data, "https://example.com/a");
556 assert_eq!(
557 entry.metadata,
558 vec![
559 ("note".to_string(), r#""x;y""#.to_string()),
560 ("purpose".to_string(), "info".to_string()),
561 ]
562 );
563 assert_eq!(
564 entry.to_string(),
565 r#"<https://example.com/a>;note="x;y";purpose=info"#
566 );
567 }
568
569 #[test]
570 fn sws_around_params() {
571 let entry = parse_entry("<urn:example:1> ; purpose = icon ; flag").unwrap();
572 assert_eq!(
573 entry.metadata,
574 vec![
575 ("purpose".to_string(), "icon".to_string()),
576 ("flag".to_string(), String::new()),
577 ]
578 );
579 }
580
581 #[test]
582 fn unbracketed_and_trailing_junk_keep_fallback() {
583 let entry = parse_entry("urn:example:1;purpose=icon").unwrap();
584 assert_eq!(entry.data, "urn:example:1");
585 assert_eq!(entry.purpose(), Some("icon"));
586
587 let entry = parse_entry("<urn:example:1>junk;purpose=icon").unwrap();
588 assert_eq!(entry.data, "urn:example:1>junk");
589 assert_eq!(entry.purpose(), Some("icon"));
590
591 let entry = parse_entry("<urn:example:1;purpose=icon").unwrap();
592 assert_eq!(entry.data, "urn:example:1");
593 assert_eq!(entry.purpose(), Some("icon"));
594 }
595
596 #[test]
597 fn empty_brackets_rejected() {
598 assert!(parse_entry("<>").is_err());
599 assert!(parse_entry("<>;purpose=icon").is_err());
600 }
601
602 #[test]
603 fn missing_angle_brackets_display_omits_input() {
604 let e = UriInfoError::MissingAngleBrackets("secret".to_string());
605 assert!(!e
606 .to_string()
607 .contains("secret"));
608 }
609
610 #[test]
613 fn malformed_display() {
614 let e = UriInfoError::Malformed("too many entries".to_string());
615 assert_eq!(e.to_string(), "malformed URI-info value: too many entries");
616 }
617}