use std::fmt::{self, Write as _};
use std::hash::{Hash, Hasher};
use sip_uri::{Uri, UriRedact};
use crate::diagnostic::{Field, ParseWarning};
use crate::error::ParseError;
use crate::list::CommaList;
use crate::params::HeaderParams;
use crate::redact::{HeaderRedaction, Redact, RedactedList};
use crate::span::{relocated, Located, Relocation, Span};
use crate::traits::sealed;
use crate::uri_info::read_uri_entry;
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct SipGeolocationEntry {
uri: Uri,
params: HeaderParams,
span: Option<Span>,
uri_span: Option<Span>,
}
header_params!(SipGeolocationEntry, clear_spans);
impl PartialEq for SipGeolocationEntry {
fn eq(&self, other: &Self) -> bool {
self.uri == other.uri && self.params == other.params
}
}
impl Eq for SipGeolocationEntry {}
impl Hash for SipGeolocationEntry {
fn hash<H: Hasher>(&self, state: &mut H) {
self.uri
.hash(state);
self.params
.hash(state);
}
}
impl Located for SipGeolocationEntry {
fn relocate_spans(&mut self, to: &Relocation<'_>) {
relocated(&mut self.span, to);
relocated(&mut self.uri_span, to);
self.params
.relocate_spans(to);
}
}
impl SipGeolocationEntry {
pub fn new(uri: Uri) -> Result<Self, ParseError> {
crate::check::checked_uri(Field::Reference, uri).map(Self::unchecked)
}
fn unchecked(uri: Uri) -> Self {
SipGeolocationEntry {
uri,
params: HeaderParams::default(),
span: None,
uri_span: None,
}
}
pub fn uri(&self) -> &Uri {
&self.uri
}
pub fn span(&self) -> Option<Span> {
self.span
}
pub fn uri_span(&self) -> Option<Span> {
self.uri_span
}
pub fn cid(&self) -> Option<&str> {
self.uri
.as_other()
.filter(|o| o.scheme() == Some("cid"))
.map(|o| o.rest())
}
}
impl fmt::Display for SipGeolocationEntry {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "<{}>{}", self.uri, self.params)
}
}
impl sealed::Sealed for SipGeolocationEntry {}
impl Redact for SipGeolocationEntry {
fn redacted<'a>(&'a self, how: &'a HeaderRedaction) -> impl fmt::Display + 'a {
RedactedEntry(self, how)
}
}
struct RedactedEntry<'a>(&'a SipGeolocationEntry, &'a HeaderRedaction);
impl fmt::Display for RedactedEntry<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let RedactedEntry(entry, how) = self;
f.write_char('<')?;
if how.masks_location() {
if let Some(scheme) = entry
.uri
.scheme()
{
write!(f, "{scheme}:")?;
}
f.write_str("***")?;
} else {
write!(
f,
"{}",
entry
.uri
.redacted(how.uri())
)?;
}
write!(
f,
">{}",
entry
.params
.masked(how)
)
}
}
impl Redact for SipGeolocation {
fn redacted<'a>(&'a self, how: &'a HeaderRedaction) -> impl fmt::Display + 'a {
RedactedList(self.entries(), how)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub struct SipGeolocation(Vec<SipGeolocationEntry>);
list_type!(SipGeolocation, SipGeolocationEntry, non_empty);
impl SipGeolocation {
pub fn uris(&self) -> impl Iterator<Item = &Uri> {
self.0
.iter()
.map(SipGeolocationEntry::uri)
}
pub fn cid(&self) -> Option<&str> {
self.cids()
.next()
}
pub fn url(&self) -> Option<&Uri> {
self.urls()
.next()
}
pub fn cids(&self) -> impl Iterator<Item = &str> {
self.0
.iter()
.filter_map(SipGeolocationEntry::cid)
}
pub fn urls(&self) -> impl Iterator<Item = &Uri> {
self.0
.iter()
.filter(|e| {
e.cid()
.is_none()
})
.map(SipGeolocationEntry::uri)
}
}
#[cfg(feature = "serde")]
serde_parts!(SipGeolocationEntry, SipGeolocationEntryParts);
#[cfg(feature = "serde")]
#[derive(serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct SipGeolocationEntryParts {
uri: Uri,
#[serde(default, deserialize_with = "crate::params::deserialize_unchecked")]
params: HeaderParams,
}
#[cfg(feature = "serde")]
impl SipGeolocationEntryParts {
fn into_value(p: Self) -> Result<SipGeolocationEntry, ParseError> {
let entry = SipGeolocationEntry {
params: p.params,
..SipGeolocationEntry::unchecked(p.uri)
};
crate::list::entry_reads_back::<SipGeolocation>(entry)
}
fn from_value(e: SipGeolocationEntry) -> Self {
SipGeolocationEntryParts {
uri: e.uri,
params: e.params,
}
}
}
fn read_entry(
entry: &str,
warnings: &mut Vec<ParseWarning>,
) -> Result<SipGeolocationEntry, ParseError> {
let read = read_uri_entry(entry, warnings)?;
Ok(SipGeolocationEntry {
params: HeaderParams::read(entry, read.params, warnings),
span: Some(read.span),
uri_span: Some(read.uri_span),
..SipGeolocationEntry::unchecked(read.uri)
})
}
impl CommaList for SipGeolocation {
type Entry = SipGeolocationEntry;
fn parse_entry(
entry: &str,
warnings: &mut Vec<ParseWarning>,
) -> Result<SipGeolocationEntry, ParseError> {
read_entry(entry, warnings)
}
fn from_parsed(entries: Vec<SipGeolocationEntry>) -> Result<Self, ParseError> {
Self::new(entries)
}
}
list_parse!(SipGeolocation);
entry_parse!(SipGeolocationEntry, SipGeolocation);
#[cfg(test)]
mod tests {
use super::*;
use crate::diagnostic::{Field, WarningCode};
use crate::list::testing::{self, Seen};
use crate::{HeaderParse, ListParse};
use sip_uri::WarningKind;
fn parse(raw: &str) -> SipGeolocation {
SipGeolocation::parse(raw).unwrap()
}
#[test]
fn parse_cid_and_url() {
let raw = "<cid:32863354-18b4-4069-bd00-7bced5fc6c9b>, <https://lis.example.com/api/v1/held/test>";
let geo = parse(raw);
assert_eq!(geo.len(), 2);
assert_eq!(geo.cid(), Some("32863354-18b4-4069-bd00-7bced5fc6c9b"));
assert!(geo
.url()
.unwrap()
.to_string()
.contains("lis.example.com"));
}
#[test]
fn single_cid() {
let geo = parse("<cid:abc-123>");
assert_eq!(geo.len(), 1);
assert_eq!(geo.cid(), Some("abc-123"));
assert!(geo
.url()
.is_none());
}
#[test]
fn single_url() {
let geo = parse("<https://lis.example.com/location>");
assert_eq!(geo.len(), 1);
assert!(geo
.cid()
.is_none());
assert_eq!(
geo.url()
.map(ToString::to_string)
.as_deref(),
Some("https://lis.example.com/location")
);
}
#[test]
fn empty_input() {
assert_eq!(
SipGeolocation::parse(""),
Err(ParseError::empty(Field::Value))
);
assert_eq!(
SipGeolocation::parse(" \t"),
Err(ParseError::empty(Field::Value))
);
}
fn lenient(raw: &str) -> (SipGeolocation, Vec<Seen>) {
testing::lenient(raw)
}
#[test]
fn empty_brackets_skipped() {
let (geo, seen) = lenient("<>, <cid:test>");
assert_eq!(geo.len(), 1);
assert_eq!(geo.cid(), Some("test"));
assert_eq!(
seen,
vec![(
Field::Entry,
WarningCode::SkippedEntry,
WarningKind::Lost,
Some(0),
Some(0)
)]
);
}
#[test]
fn blank_entry_dropped_with_empty_entry() {
let (geo, seen) = lenient("<cid:a>,, <cid:b>");
assert_eq!(geo.len(), 2);
assert_eq!(
seen,
vec![(
Field::Entry,
WarningCode::EmptyEntry,
WarningKind::Recovered,
Some("<cid:a>,".len()),
Some(1)
)]
);
}
#[test]
fn display_roundtrip() {
let raw = "<cid:abc-123>;inserted-by=example.org, <https://lis.example.com/test>";
let geo = parse(raw);
assert_eq!(geo.to_string(), raw);
}
#[test]
fn geoloc_params_kept() {
let geo = parse("<cid:x@example.com> ; Inserted-By = y ; flag");
let entry = &geo.entries()[0];
assert_eq!(entry.cid(), Some("x@example.com"));
assert_eq!(entry.param("inserted-by"), Some(Some("y")));
assert_eq!(entry.param("flag"), Some(None));
assert_eq!(
entry
.params()
.iter()
.collect::<Vec<_>>(),
vec![("inserted-by", Some("y")), ("flag", None)]
);
assert_eq!(entry.to_string(), "<cid:x@example.com>;inserted-by=y;flag");
}
#[test]
fn with_param_clears_spans() {
let geo = parse("<cid:a>;inserted-by=x");
let entry = geo.entries()[0].clone();
assert!(entry
.span()
.is_some());
assert!(entry
.uri_span()
.is_some());
let entry = entry
.with_param("flag", None)
.unwrap();
assert_eq!(entry.span(), None);
assert_eq!(entry.uri_span(), None);
}
#[test]
fn with_quoted_param_clears_spans() {
let geo = parse("<cid:a>;inserted-by=x");
let entry = geo.entries()[0]
.clone()
.with_quoted_param("note", "v")
.unwrap();
assert_eq!(entry.span(), None);
assert_eq!(entry.uri_span(), None);
}
#[test]
fn text_after_bracket_is_dropped_with_warning() {
let raw = "<cid:a>junk;inserted-by=x";
let (geo, seen) = lenient(raw);
assert_eq!(geo.entries()[0].param("inserted-by"), Some(Some("x")));
assert_eq!(
seen,
vec![(
Field::Param,
WarningCode::TrailingContent,
WarningKind::Lost,
raw.find('j'),
Some(0)
)]
);
}
#[test]
fn param_unterminated_quote_is_warned() {
let raw = r#"<cid:a>;note="x;inserted-by=y"#;
let (geo, seen) = lenient(raw);
assert_eq!(geo.entries()[0].param("inserted-by"), Some(Some("y")));
assert_eq!(
seen,
vec![(
Field::Param,
WarningCode::UnterminatedQuote,
WarningKind::Recovered,
raw.find('"'),
Some(0)
)]
);
}
#[test]
fn comma_inside_brackets_not_split() {
let geo = parse("<https://example.com/a,b>");
assert_eq!(
geo.uris()
.map(ToString::to_string)
.collect::<Vec<_>>(),
vec!["https://example.com/a,b"]
);
}
#[test]
fn cid_scheme_case_insensitive() {
let geo = parse("<CID:x@example.com>");
assert_eq!(geo.cid(), Some("x@example.com"));
}
#[test]
fn bare_uri_kept_with_missing_brackets() {
let raw = "http://198.51.100.1:8080/a/b-c.d";
let parsed = SipGeolocation::parse_with_warnings(raw).unwrap();
let (geo, seen) = lenient(raw);
assert_eq!(geo.to_string(), format!("<{raw}>"));
assert_eq!(
seen,
vec![(
Field::Entry,
WarningCode::MissingBrackets,
WarningKind::Recovered,
Some(0),
Some(0)
)]
);
assert_eq!(
parsed.warnings[0]
.span()
.map(|s| s.get(raw)),
Some(Ok(raw))
);
let raw = "<cid:a>, cid:x@example.com;inserted-by=y, <https://example.com/loc>";
let (geo, seen) = lenient(raw);
assert_eq!(geo.len(), 3);
assert_eq!(geo.entries()[1].cid(), Some("x@example.com"));
assert_eq!(geo.entries()[1].param("inserted-by"), Some(Some("y")));
assert_eq!(
seen,
vec![(
Field::Entry,
WarningCode::MissingBrackets,
WarningKind::Recovered,
raw.find("cid:x"),
Some(1)
)]
);
}
#[test]
fn unclosed_bracket_kept_with_its_own_warning() {
let raw = "<cid:a@example.com;inserted-by=x";
let parsed = SipGeolocation::parse_with_warnings(raw).unwrap();
let (geo, seen) = lenient(raw);
assert_eq!(geo.to_string(), "<cid:a@example.com>;inserted-by=x");
assert_eq!(
seen,
vec![(
Field::Entry,
WarningCode::UnclosedBracket,
WarningKind::Recovered,
Some(0),
Some(0)
)]
);
assert_eq!(
parsed.warnings[0]
.span()
.map(|s| s.get(raw)),
Some(Ok("<cid:a@example.com"))
);
}
#[test]
fn from_entries_matches_parse() {
let split =
SipGeolocation::from_entries(["<cid:a>;inserted-by=x", "<https://example.com/a,b>"])
.unwrap();
let joined = parse("<cid:a>;inserted-by=x, <https://example.com/a,b>");
assert_eq!(split, joined);
assert_eq!(split.len(), 2);
}
#[test]
fn multiple_cids() {
let raw = "<cid:first>, <cid:second>, <https://example.com/loc>";
let geo = parse(raw);
let cids: Vec<_> = geo
.cids()
.collect();
assert_eq!(cids, vec!["first", "second"]);
let urls: Vec<_> = geo
.urls()
.map(ToString::to_string)
.collect();
assert_eq!(urls, vec!["https://example.com/loc"]);
}
#[test]
fn warnings_api_and_from_entries() {
let raw = "<cid:a>, <https://example.com/loc>";
let parsed = SipGeolocation::parse_with_warnings(raw).unwrap();
assert!(!parsed.has_warnings());
assert_eq!(
SipGeolocation::parse_strict(raw),
Ok(parsed
.value
.clone())
);
let split = SipGeolocation::from_entries_with_warnings(["<cid:a>", "<>"]).unwrap();
assert_eq!(
split
.value
.len(),
1
);
assert_eq!(split.warnings[0].code, WarningCode::SkippedEntry);
assert!(matches!(
SipGeolocation::from_entries([" <>"]),
Err(ParseError::Malformed(f)) if (f.position, f.row, f.entry) == (Some(1), Some(0), Some(0))
));
assert_eq!(
SipGeolocation::parse_with_warnings(" "),
Err(ParseError::empty(Field::Value))
);
}
}