use crate::core::SipError;
use crate::sip::uri::SipUri;
use std::fmt;
#[derive(Debug, Clone, PartialEq)]
pub struct NameAddr {
pub display_name: Option<String>,
pub uri: SipUri,
pub parameters: Vec<(String, Option<String>)>,
}
impl NameAddr {
pub fn parse(input: &str) -> Result<NameAddr, SipError> {
let s = input.trim();
if s.is_empty() {
return Err(SipError::InvalidHeader(
"empty name-address value".to_string(),
));
}
let (display_name, rest) = if s.starts_with('"') {
let (name, after) = parse_quoted_string(s)?;
let after = after.trim_start();
if !after.starts_with('<') {
return Err(SipError::InvalidHeader(format!(
"quoted display name must be followed by '<addr-spec>': {input:?}",
)));
}
(Some(name), after)
} else if let Some(lt) = find_angle_bracket(s) {
let leading = s[..lt].trim();
if leading.is_empty() {
(None, &s[lt..])
} else {
(Some(leading.to_string()), &s[lt..])
}
} else {
(None, s)
};
let (uri_text, after_uri) = if let Some(rest_inner) = rest.strip_prefix('<') {
let close = rest_inner.find('>').ok_or_else(|| {
SipError::InvalidHeader(format!("missing '>' in name-address: {input:?}"))
})?;
(&rest_inner[..close], &rest_inner[close + 1..])
} else {
match rest.find(';') {
Some(i) => (&rest[..i], &rest[i..]),
None => (rest, ""),
}
};
let uri = SipUri::parse(uri_text.trim())
.map_err(|e| SipError::InvalidHeader(format!("invalid URI in name-address: {e}")))?;
let parameters = parse_params(after_uri)?;
Ok(NameAddr {
display_name,
uri,
parameters,
})
}
pub fn tag(&self) -> Option<&str> {
self.parameters
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case("tag"))
.and_then(|(_, v)| v.as_deref())
}
}
impl fmt::Display for NameAddr {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if let Some(ref name) = self.display_name {
if needs_quoting(name) {
write!(f, "\"{}\" ", escape_quoted(name))?;
} else {
write!(f, "{} ", name)?;
}
}
write!(f, "<{}>", self.uri)?;
for (key, value) in &self.parameters {
if let Some(ref v) = value {
write!(f, ";{}={}", key, v)?;
} else {
write!(f, ";{}", key)?;
}
}
Ok(())
}
}
fn parse_quoted_string(s: &str) -> Result<(String, &str), SipError> {
debug_assert!(s.starts_with('"'));
let bytes = s.as_bytes();
let mut out: Vec<u8> = Vec::new();
let mut i = 1;
while i < bytes.len() {
let b = bytes[i];
match b {
b'"' => {
let unquoted = String::from_utf8(out).map_err(|e| {
SipError::InvalidHeader(format!("invalid UTF-8 in quoted display name: {e}",))
})?;
return Ok((unquoted, &s[i + 1..]));
}
b'\\' => {
if i + 1 >= bytes.len() {
return Err(SipError::InvalidHeader(
"trailing backslash in quoted display name".to_string(),
));
}
out.push(bytes[i + 1]);
i += 2;
}
_ => {
out.push(b);
i += 1;
}
}
}
Err(SipError::InvalidHeader(
"unterminated quoted-string in display name".to_string(),
))
}
fn find_angle_bracket(s: &str) -> Option<usize> {
let bytes = s.as_bytes();
let mut i = 0;
let mut in_quoted = false;
while i < bytes.len() {
let b = bytes[i];
if in_quoted {
if b == b'\\' && i + 1 < bytes.len() {
i += 2;
continue;
}
if b == b'"' {
in_quoted = false;
}
i += 1;
continue;
}
match b {
b'"' => in_quoted = true,
b'<' => return Some(i),
_ => {}
}
i += 1;
}
None
}
fn parse_params(s: &str) -> Result<Vec<(String, Option<String>)>, SipError> {
let s = s.trim_start();
if s.is_empty() {
return Ok(Vec::new());
}
if !s.starts_with(';') {
return Err(SipError::InvalidHeader(format!(
"trailing data after URI in name-address (expected ';' or end): {s:?}",
)));
}
let mut params = Vec::new();
let bytes = s.as_bytes();
let mut i = 1; while i < bytes.len() {
let start = i;
let mut in_quoted = false;
while i < bytes.len() {
let b = bytes[i];
if in_quoted {
if b == b'\\' && i + 1 < bytes.len() {
i += 2;
continue;
}
if b == b'"' {
in_quoted = false;
}
i += 1;
continue;
}
match b {
b'"' => in_quoted = true,
b';' => break,
_ => {}
}
i += 1;
}
let chunk = &s[start..i];
let chunk = chunk.trim();
if !chunk.is_empty() {
if let Some(eq) = chunk.find('=') {
let (k, v) = chunk.split_at(eq);
params.push((k.trim().to_string(), Some(v[1..].trim().to_string())));
} else {
params.push((chunk.to_string(), None));
}
}
if i < bytes.len() && bytes[i] == b';' {
i += 1;
}
}
Ok(params)
}
fn needs_quoting(s: &str) -> bool {
if s.is_empty() {
return true;
}
s.bytes().any(|b| !is_token_char(b))
}
fn is_token_char(b: u8) -> bool {
matches!(b,
b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' |
b'-' | b'.' | b'!' | b'%' | b'*' | b'_' | b'+' | b'`' | b'\'' | b'~'
)
}
fn escape_quoted(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
if c == '"' || c == '\\' {
out.push('\\');
}
out.push(c);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(input: &str) -> NameAddr {
NameAddr::parse(input).unwrap_or_else(|e| panic!("parse({input:?}) failed: {e}"))
}
#[test]
fn test_bracketed_no_display_name() {
let na = parse("<sip:bob@example.com>");
assert_eq!(na.display_name, None);
assert_eq!(na.uri.user(), Some("bob"));
assert_eq!(na.uri.host(), "example.com");
assert!(na.parameters.is_empty());
}
#[test]
fn test_token_display_name() {
let na = parse("Alice <sip:alice@example.com>");
assert_eq!(na.display_name, Some("Alice".to_string()));
assert_eq!(na.uri.user(), Some("alice"));
}
#[test]
fn test_quoted_display_name_with_space() {
let na = parse("\"Alice Smith\" <sip:alice@example.com>");
assert_eq!(na.display_name, Some("Alice Smith".to_string()));
}
#[test]
fn test_quoted_display_name_with_escapes() {
let na = parse(r#""He said \"hi\"" <sip:a@b>"#);
assert_eq!(na.display_name, Some(r#"He said "hi""#.to_string()));
}
#[test]
fn test_quoted_display_name_with_backslash_escape() {
let na = parse(r#""C:\\file" <sip:a@b>"#);
assert_eq!(na.display_name, Some(r"C:\file".to_string()));
}
#[test]
fn quoted_display_preserves_utf8_multibyte() {
let na = NameAddr::parse(r#""Björn" <sip:b@example.com>"#).unwrap();
assert_eq!(na.display_name.as_deref(), Some("Björn"));
}
#[test]
fn quoted_display_preserves_utf8_with_escape() {
let na = NameAddr::parse(r#""Björn \"the boss\"" <sip:b@example.com>"#).unwrap();
assert_eq!(na.display_name.as_deref(), Some(r#"Björn "the boss""#));
}
#[test]
fn quoted_display_invalid_utf8_rejects() {
let mut bytes: Vec<u8> = Vec::new();
bytes.push(b'"');
bytes.push(0xC3);
bytes.extend_from_slice(b"\" <sip:b@example.com>");
let s = std::str::from_utf8(&bytes);
assert!(s.is_err(), "test input must be invalid UTF-8");
}
#[test]
fn test_bare_addr_spec_no_brackets_no_display() {
let na = parse("sip:bob@example.com");
assert_eq!(na.display_name, None);
assert_eq!(na.uri.user(), Some("bob"));
assert!(na.parameters.is_empty());
}
#[test]
fn test_bracketed_with_tag() {
let na = parse("<sip:bob@example.com>;tag=abc123");
assert_eq!(na.tag(), Some("abc123"));
}
#[test]
fn test_token_display_with_tag_and_extra() {
let na = parse("Alice <sip:alice@example.com>;tag=1928301774;foo=bar");
assert_eq!(na.display_name, Some("Alice".to_string()));
assert_eq!(na.tag(), Some("1928301774"));
assert_eq!(
na.parameters,
vec![
("tag".to_string(), Some("1928301774".to_string())),
("foo".to_string(), Some("bar".to_string())),
]
);
}
#[test]
fn test_bare_addr_spec_with_tag() {
let na = parse("sip:bob@example.com;tag=xyz");
assert_eq!(na.uri.user(), Some("bob"));
assert_eq!(na.uri.host(), "example.com");
assert!(na.uri.params().count() == 0);
assert_eq!(na.tag(), Some("xyz"));
}
#[test]
fn test_tag_lookup_case_insensitive() {
let na = parse("<sip:a@b>;TAG=upper");
assert_eq!(na.tag(), Some("upper"));
}
#[test]
fn test_flag_parameter() {
let na = parse("<sip:a@b>;lr;tag=t");
assert_eq!(na.parameters[0], ("lr".to_string(), None));
assert_eq!(na.tag(), Some("t"));
}
#[test]
fn test_quoted_param_value() {
let na = parse(r#"<sip:a@b>;foo="x;y""#);
assert_eq!(
na.parameters,
vec![("foo".to_string(), Some(r#""x;y""#.to_string()))]
);
}
#[test]
fn test_empty_input_rejected() {
assert!(NameAddr::parse("").is_err());
assert!(NameAddr::parse(" ").is_err());
}
#[test]
fn test_quoted_unterminated_rejected() {
assert!(NameAddr::parse(r#""Alice <sip:a@b>"#).is_err());
}
#[test]
fn test_quoted_without_addr_spec_rejected() {
assert!(NameAddr::parse(r#""Alice""#).is_err());
assert!(NameAddr::parse(r#""Alice" sip:a@b"#).is_err());
}
#[test]
fn test_bracketed_unclosed_rejected() {
assert!(NameAddr::parse("Alice <sip:a@b").is_err());
}
#[test]
fn test_trailing_data_after_uri_rejected() {
assert!(NameAddr::parse("<sip:a@b> garbage").is_err());
}
#[test]
fn test_invalid_uri_rejected() {
assert!(NameAddr::parse("<not-a-uri>").is_err());
}
#[test]
fn test_display_round_trip_token_name() {
let na = parse("Alice <sip:alice@example.com>;tag=1");
assert_eq!(na.to_string(), "Alice <sip:alice@example.com>;tag=1");
}
#[test]
fn test_display_round_trip_quoted_name() {
let na = parse(r#""Alice Smith" <sip:a@b>;tag=1"#);
assert_eq!(na.to_string(), r#""Alice Smith" <sip:a@b>;tag=1"#);
}
#[test]
fn test_display_quotes_when_required() {
let mut na = parse("<sip:a@b>");
na.display_name = Some("has space".to_string());
let s = na.to_string();
assert!(s.starts_with(r#""has space""#), "got: {s}");
}
#[test]
fn test_display_no_brackets_for_bare_input_still_emits_brackets() {
let na = parse("sip:bob@example.com");
assert_eq!(na.to_string(), "<sip:bob@example.com>");
}
#[test]
fn test_parse_then_display_with_flag_param() {
let na = parse("<sip:a@b>;lr");
assert_eq!(na.to_string(), "<sip:a@b>;lr");
}
#[test]
fn test_token_char_class_minimal() {
assert!(is_token_char(b'a'));
assert!(is_token_char(b'Z'));
assert!(is_token_char(b'0'));
assert!(is_token_char(b'-'));
assert!(is_token_char(b'+'));
assert!(!is_token_char(b' '));
assert!(!is_token_char(b';'));
assert!(!is_token_char(b'<'));
}
#[test]
fn test_needs_quoting_empty_and_token() {
assert!(needs_quoting(""));
assert!(!needs_quoting("Alice"));
assert!(needs_quoting("Alice Smith"));
assert!(needs_quoting("Alice;Bob"));
}
#[test]
fn test_escape_quoted_handles_quotes_and_backslash() {
assert_eq!(escape_quoted(r#"a"b"#), r#"a\"b"#);
assert_eq!(escape_quoted(r"c\d"), r"c\\d");
}
#[test]
fn test_find_angle_bracket_skips_quoted() {
assert_eq!(find_angle_bracket(r#""a<b" <sip:x@y>"#), Some(6));
}
#[test]
fn test_find_angle_bracket_none() {
assert_eq!(find_angle_bracket("sip:a@b"), None);
}
}