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