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