use core::fmt;
use alloc::{
string::{String, ToString},
vec,
vec::Vec,
};
use crate::{
prop::{VcardProp, VcardPropKind},
tree::{
codec::mode::VcardEscaper,
error::VcardParseError,
line::VcardLine,
prop::{cardinality::VcardPropCardinality, lens::VcardPropLens, spec::prop_spec},
},
value::VcardValue,
version::VcardVersion,
};
#[derive(Clone, Debug)]
pub struct VcardCst<'a> {
pub begin: Option<VcardLine<'a>>,
pub props: Vec<VcardLine<'a>>,
pub end: Option<VcardLine<'a>>,
}
impl<'a> VcardCst<'a> {
pub fn v4() -> Self {
Self {
begin: Some(VcardLine::text("BEGIN", "VCARD")),
props: vec![VcardLine::text("VERSION", &*VcardVersion::V4_0)],
end: Some(VcardLine::text("END", "VCARD")),
}
}
pub fn parse<T: AsRef<[u8]> + ?Sized>(input: &'a T) -> Result<Self, VcardParseError> {
let input = trim_leading_eol(input.as_ref());
let (first, _rest) = VcardLine::take(input)?;
if first.name.get().eq_ignore_ascii_case("BEGIN") {
Self::take_card(input).map(|(card, _rest)| card)
} else {
Self::parse_bare(input)
}
}
fn parse_bare(input: &'a [u8]) -> Result<Self, VcardParseError> {
let mut props: Vec<VcardLine<'a>> = Vec::new();
let mut rest = trim_leading_eol(input);
while !rest.is_empty() {
let (line, tail) = VcardLine::take(rest)?;
props.push(line);
rest = trim_leading_eol(tail);
}
let escaper = props
.iter()
.find(|line| line.name.get().eq_ignore_ascii_case("VERSION"))
.map(|line| VcardEscaper::for_version_str(line.raw_value_str().as_ref()))
.unwrap_or_default();
for line in &mut props {
line.value.escaper = escaper;
}
Ok(Self {
begin: None,
props,
end: None,
})
}
pub fn parse_many<T: AsRef<[u8]> + ?Sized>(
input: &'a T,
) -> impl Iterator<Item = Result<Self, VcardParseError>> {
let mut rest = input.as_ref();
core::iter::from_fn(move || {
rest = trim_leading_eol(rest);
if rest.is_empty() {
return None;
}
match Self::take_card(rest) {
Ok((card, tail)) => {
rest = tail;
Some(Ok(card))
}
Err(error) => {
rest = b"";
Some(Err(error))
}
}
})
}
fn take_card(input: &'a [u8]) -> Result<(Self, &'a [u8]), VcardParseError> {
let (begin, mut rest) = VcardLine::take(input)?;
if !begin.name.get().eq_ignore_ascii_case("BEGIN") {
return Err(VcardParseError::ExpectedBegin(begin.name.get().to_string()));
}
let mut props: Vec<VcardLine<'a>> = Vec::new();
let mut depth = 0usize;
loop {
if rest.is_empty() {
return Err(VcardParseError::MissingEnd(
String::from_utf8_lossy(input).into_owned(),
));
}
let (line, tail) = VcardLine::take(rest)?;
rest = tail;
let name = line.name.get();
if name.eq_ignore_ascii_case("END") {
if let Some(next) = depth.checked_sub(1) {
depth = next;
props.push(line);
continue;
}
let escaper = props
.iter()
.find(|line| line.name.get().eq_ignore_ascii_case("VERSION"))
.map(|line| VcardEscaper::for_version_str(line.raw_value_str().as_ref()))
.unwrap_or_default();
for line in &mut props {
line.value.escaper = escaper;
}
return Ok((
Self {
begin: Some(begin),
props,
end: Some(line),
},
rest,
));
}
if name.eq_ignore_ascii_case("BEGIN") {
depth += 1;
}
props.push(line);
}
}
pub fn version_line(&self) -> Option<&VcardLine<'a>> {
self.props
.iter()
.find(|line| line.name.get().eq_ignore_ascii_case("VERSION"))
}
pub fn version(&self) -> VcardVersion {
self.version_line()
.and_then(|line| line.raw_value_str().parse().ok())
.unwrap_or(VcardVersion::V4_0)
}
pub fn push(&mut self, prop: VcardProp<'a>) -> &mut Self {
let escaper = self
.version_line()
.map(|line| VcardEscaper::for_version_str(line.raw_value_str().as_ref()))
.unwrap_or_default();
self.props.push(prop.encode(escaper));
self
}
pub fn remove<L: VcardPropLens>(&mut self) -> &mut Self {
self.props
.retain(|line| !line.name.get().eq_ignore_ascii_case(&L::KIND));
self
}
pub fn fill_required(&mut self) -> &mut Self {
let version = self.version();
for kind in VcardPropKind::ALL {
let spec = prop_spec(kind);
if !(spec.allowed_versions)().contains(&version) {
continue;
}
let required = matches!(
(spec.cardinality)(version),
VcardPropCardinality::ExactlyOne | VcardPropCardinality::OneOrMore,
);
let present = self
.props
.iter()
.any(|line| line.name.get().eq_ignore_ascii_case(&kind));
if !required || present {
continue;
}
if let Some(value_kind) = (spec.allowed_values)(version).first() {
self.push(VcardProp {
name: kind.into(),
params: Vec::new(),
value: VcardValue::empty(*value_kind),
});
}
}
self
}
pub fn prop<L: VcardPropLens>(&self) -> Option<L::Target<'_>> {
let version = self.version();
self.props
.iter()
.find(|line| line.name.get().eq_ignore_ascii_case(&L::KIND))
.map(|line| L::decode(line, version))
}
pub fn prop_mut<L: VcardPropLens>(&mut self) -> Option<L::Cursor<'_, 'a>> {
self.props
.iter_mut()
.find(|line| line.name.get().eq_ignore_ascii_case(&L::KIND))
.map(|line| L::cursor(line))
}
pub fn into_static(self) -> VcardCst<'static> {
VcardCst {
begin: self.begin.map(VcardLine::into_static),
props: self.props.into_iter().map(VcardLine::into_static).collect(),
end: self.end.map(VcardLine::into_static),
}
}
pub fn to_bytes(&self) -> Vec<u8> {
let mut out = Vec::new();
if let Some(begin) = &self.begin {
begin.write_bytes(&mut out);
}
for prop in &self.props {
prop.write_bytes(&mut out);
}
if let Some(end) = &self.end {
end.write_bytes(&mut out);
}
out
}
}
fn trim_leading_eol(mut bytes: &[u8]) -> &[u8] {
while let Some((first, rest)) = bytes.split_first() {
if matches!(first, b'\r' | b'\n') {
bytes = rest;
} else {
break;
}
}
bytes
}
impl fmt::Display for VcardCst<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if let Some(begin) = &self.begin {
write!(f, "{begin}")?;
}
for prop in &self.props {
write!(f, "{prop}")?;
}
if let Some(end) = &self.end {
write!(f, "{end}")?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use alloc::{borrow::Cow, string::ToString, vec, vec::Vec};
use crate::prop::VcardPropKind;
use crate::version::VcardVersion;
use crate::{
param::VcardParam,
prop::VcardProp,
tree::{cst::VcardCst, prop::n::N},
value::{VcardValue, VcardValueUnknown, n::VcardN, text::VcardText},
vcard::Vcard,
};
const CARD: &str = concat!(
"BEGIN:VCARD\r\n",
"VERSION:4.0\r\n",
"N;PID=1:Doe;John;;Dr.;\r\n",
"FN:John Doe\r\n",
"END:VCARD\r\n",
);
#[test]
fn round_trips_byte_for_byte() {
let card = VcardCst::parse(CARD).unwrap();
assert_eq!(card.to_string(), CARD);
}
#[test]
fn round_trips_a_non_utf8_value_byte_for_byte() {
use crate::tree::prop::note::NOTE;
let mut raw = Vec::new();
raw.extend_from_slice(b"BEGIN:VCARD\r\nVERSION:2.1\r\nNOTE;CHARSET=ISO-8859-1:caf");
raw.push(0xE9);
raw.extend_from_slice(b"\r\nEND:VCARD\r\n");
let cst = VcardCst::parse(&raw).unwrap();
assert_eq!(cst.to_bytes(), raw);
let mut cst = cst;
assert_eq!(
cst.prop_mut::<NOTE>().unwrap().bytes().as_ref(),
&[b'c', b'a', b'f', 0xE9],
);
assert!(
cst.decode().properties[0]
.params
.contains(&VcardParam::Charset(Cow::Borrowed("ISO-8859-1"))),
);
}
#[test]
fn rejects_a_non_utf8_property_name() {
use crate::tree::error::VcardParseError;
let mut raw = Vec::new();
raw.extend_from_slice(b"BEGIN:VCARD\r\nVERSION:4.0\r\nX-");
raw.push(0xFF);
raw.extend_from_slice(b":v\r\nEND:VCARD\r\n");
assert!(matches!(
VcardCst::parse(&raw),
Err(VcardParseError::NonUtf8Header(_)),
));
}
#[test]
fn round_trips_fuzz_regressions() {
let cases: &[&[u8]] = &[
b"BEGIN:VCARD\r\nVERSION:2.1\r\nNOTE;ENCODING=QUOTED-PRINTABLE:x==\r\n\r\nEND:VCARD\r\n",
b"BEGIN:VCARD\r\nVERSION:2.1\r\nNOTE;ENCODING=QUOTED-PRINTABLE:Luo\r\n =\r\nEND:VCARD\r\n",
b"BEGIN:VCARD\r\nVERSION:4.0\r\nNOTE:a\r\n\r\n FN:b\r\nEND:VCARD\r\n",
b"\t\r:",
];
for raw in cases {
let cst = VcardCst::parse(raw).unwrap_or_else(|e| panic!("parse {raw:?}: {e}"));
let bytes = cst.to_bytes();
let reparsed =
VcardCst::parse(&bytes).unwrap_or_else(|e| panic!("reparse {raw:?}: {e}"));
assert_eq!(reparsed.to_bytes(), bytes, "not idempotent: {raw:?}");
}
}
#[test]
fn parses_a_bare_directory_record_without_an_envelope() {
let raw = "cn:Babs Jensen\r\nemail:babs@umich.edu\r\n";
let cst = VcardCst::parse(raw).unwrap();
assert!(cst.begin.is_none());
assert!(cst.end.is_none());
assert_eq!(cst.props.len(), 2);
assert_eq!(cst.to_string(), raw);
}
#[test]
fn parse_many_refuses_a_bare_record() {
use crate::tree::error::VcardParseError;
let raw = "cn:Babs Jensen\r\nemail:babs@umich.edu\r\n";
let first = VcardCst::parse_many(raw).next().unwrap();
assert!(matches!(first, Err(VcardParseError::ExpectedBegin(_))));
}
#[test]
fn parses_many_cards_from_one_input() {
let a = "BEGIN:VCARD\r\nVERSION:4.0\r\nFN:A\r\nEND:VCARD\r\n";
let b = "BEGIN:VCARD\r\nVERSION:4.0\r\nFN:B\r\nEND:VCARD\r\n";
let input = concat!(
"BEGIN:VCARD\r\nVERSION:4.0\r\nFN:A\r\nEND:VCARD\r\n",
"\r\n",
"BEGIN:VCARD\r\nVERSION:4.0\r\nFN:B\r\nEND:VCARD\r\n",
);
let cards = VcardCst::parse_many(input)
.collect::<Result<Vec<_>, _>>()
.unwrap();
assert_eq!(cards.len(), 2);
assert_eq!(cards[0].to_string(), a);
assert_eq!(cards[1].to_string(), b);
}
#[test]
fn keeps_a_nested_agent_card_intact() {
let raw = concat!(
"BEGIN:VCARD\r\n",
"VERSION:2.1\r\n",
"FN:Has Agent\r\n",
"AGENT:\r\n",
"BEGIN:VCARD\r\n",
"VERSION:2.1\r\n",
"N:Friday;Fred\r\n",
"TEL;WORK;VOICE:+1-213-555-1234\r\n",
"END:VCARD\r\n",
"END:VCARD\r\n",
);
assert_eq!(VcardCst::parse(raw).unwrap().to_string(), raw);
}
#[test]
fn reads_a_property_through_its_lens() {
let card = VcardCst::parse(CARD).unwrap();
let name = card.prop::<N>().expect("an N property");
assert_eq!(name.family, vec![Cow::Borrowed("Doe")]);
assert_eq!(name.given, vec![Cow::Borrowed("John")]);
assert_eq!(name.prefixes, vec![Cow::Borrowed("Dr.")]);
}
#[test]
fn pushes_a_typed_property_onto_a_parsed_card() {
let mut card = VcardCst::parse(CARD).unwrap();
card.push(VcardProp {
name: VcardPropKind::Email.into(),
params: [].into(),
value: VcardValue::Text("john@doe.example".into()),
});
let out = card.to_string();
assert!(out.contains("N;PID=1:Doe;John;;Dr.;\r\n"), "{out}");
assert!(out.contains("EMAIL:john@doe.example\r\n"), "{out}");
}
#[test]
fn removes_every_property_of_a_kind() {
let mut card = VcardCst::parse(CARD).unwrap();
card.remove::<N>();
assert_eq!(
card.to_string(),
"BEGIN:VCARD\r\nVERSION:4.0\r\nFN:John Doe\r\nEND:VCARD\r\n",
);
}
#[test]
fn fill_required_injects_the_mandatory_empty_n_into_a_3_0_card() {
let mut card =
VcardCst::parse("BEGIN:VCARD\r\nVERSION:3.0\r\nUID:x\r\nFN:Only\r\nEND:VCARD\r\n")
.unwrap();
card.fill_required();
let out = card.to_string();
assert!(out.contains("N:;;;;\r\n"), "{out}");
assert!(out.contains("UID:x\r\n"), "{out}");
assert!(out.contains("FN:Only\r\n"), "{out}");
assert!(card.decode().validate().is_ok());
}
#[test]
fn fill_required_is_idempotent_and_leaves_valid_cards_untouched() {
let mut once =
VcardCst::parse("BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Only\r\nEND:VCARD\r\n").unwrap();
once.fill_required().fill_required();
assert_eq!(once.to_string().matches("N:;;;;").count(), 1);
let mut v4 =
VcardCst::parse("BEGIN:VCARD\r\nVERSION:4.0\r\nFN:Only\r\nEND:VCARD\r\n").unwrap();
let before = v4.to_string();
assert_eq!(v4.fill_required().to_string(), before);
let mut has = VcardCst::parse(
"BEGIN:VCARD\r\nVERSION:3.0\r\nN:Doe;Jane;;;\r\nFN:Jane\r\nEND:VCARD\r\n",
)
.unwrap();
let before = has.to_string();
assert_eq!(has.fill_required().to_string(), before);
}
#[test]
fn builds_a_card_from_decoded_types() {
let card = Vcard {
version: VcardVersion::V4_0,
properties: vec![VcardProp {
name: "N".into(),
params: Vec::new(),
value: VcardValue::N(VcardN {
family: vec![Cow::Borrowed("Doe")],
given: vec![Cow::Borrowed("John")],
additional: Vec::new(),
prefixes: vec![Cow::Borrowed("Dr.")],
suffixes: Vec::new(),
}),
}],
};
assert_eq!(
card.to_string(),
"BEGIN:VCARD\r\nVERSION:4.0\r\nN:Doe;John;;Dr.;\r\nEND:VCARD\r\n",
);
}
#[test]
fn encodes_a_built_card_with_version_specific_escaping() {
let note = |version| Vcard {
version,
properties: vec![VcardProp {
name: "NOTE".into(),
params: Vec::new(),
value: VcardValue::Text(VcardText(Cow::Borrowed("a,b;c"))),
}],
};
assert_eq!(
note(VcardVersion::V2_1).to_string(),
"BEGIN:VCARD\r\nVERSION:2.1\r\nNOTE:a,b\\;c\r\nEND:VCARD\r\n",
);
assert_eq!(
note(VcardVersion::V4_0).to_string(),
"BEGIN:VCARD\r\nVERSION:4.0\r\nNOTE:a\\,b\\;c\r\nEND:VCARD\r\n",
);
let mut card =
VcardCst::parse("BEGIN:VCARD\r\nVERSION:2.1\r\nFN:X\r\nEND:VCARD\r\n").unwrap();
card.push(VcardProp {
name: "NOTE".into(),
params: Vec::new(),
value: VcardValue::Text(VcardText(Cow::Borrowed("a,b;c"))),
});
assert!(
card.to_string().contains("NOTE:a,b\\;c\r\n"),
"{}",
card.to_string(),
);
}
#[test]
fn decodes_the_whole_card() {
let cst = VcardCst::parse(CARD).unwrap();
let vcard = cst.decode();
assert_eq!(vcard.version, VcardVersion::V4_0);
assert_eq!(vcard.properties.len(), 2);
let n = &vcard.properties[0];
assert_eq!(&*n.name, "N");
assert_eq!(n.params, vec![VcardParam::Pid(vec![Cow::Borrowed("1")])]);
assert!(matches!(n.value, VcardValue::N(_)));
let fnn = &vcard.properties[1];
assert_eq!(&*fnn.name, "FN");
assert_eq!(
fnn.value,
VcardValue::Text(VcardText(Cow::Borrowed("John Doe"))),
);
assert_eq!(vcard.to_string(), CARD);
}
#[test]
fn keeps_an_unknown_property_round_tripping() {
let card = "BEGIN:VCARD\r\nVERSION:4.0\r\nX-CUSTOM:a;b,c\r\nEND:VCARD\r\n";
let cst = VcardCst::parse(card).unwrap();
let vcard = cst.decode();
match &vcard.properties[0].value {
VcardValue::Unknown(VcardValueUnknown { components }) => {
assert_eq!(components.len(), 2);
assert_eq!(components[1], vec![Cow::Borrowed("b"), Cow::Borrowed("c")]);
}
other => panic!("expected Unknown, got {other:?}"),
}
assert_eq!(vcard.to_string(), card);
}
#[test]
fn reads_and_edits_a_legacy_property_through_its_lens() {
use crate::tree::prop::label::LABEL;
let mut card =
VcardCst::parse("BEGIN:VCARD\r\nVERSION:3.0\r\nLABEL:Old\r\nFN:X\r\nEND:VCARD\r\n")
.unwrap();
assert_eq!(
card.prop::<LABEL>().unwrap(),
VcardText(Cow::Borrowed("Old"))
);
card.prop_mut::<LABEL>().unwrap().set_text("New");
assert_eq!(
card.to_string(),
"BEGIN:VCARD\r\nVERSION:3.0\r\nLABEL:New\r\nFN:X\r\nEND:VCARD\r\n",
);
}
#[test]
fn parses_version_anywhere_keeping_its_position() {
let card = "BEGIN:VCARD\r\nBDAY:1980-01-02\r\nVERSION:3.0\r\nFN:X\r\nEND:VCARD\r\n";
let cst = VcardCst::parse(card).unwrap();
assert_eq!(cst.to_string(), card);
let vcard = cst.decode();
assert_eq!(vcard.version, VcardVersion::V3_0);
assert_eq!(vcard.properties.len(), 2);
}
#[test]
fn parses_a_card_with_no_version() {
let card = "BEGIN:VCARD\r\nFN:X\r\nEND:VCARD\r\n";
let cst = VcardCst::parse(card).unwrap();
assert_eq!(cst.to_string(), card);
assert_eq!(cst.decode().version, VcardVersion::V4_0);
}
#[test]
fn unfolds_folded_lines_across_the_card() {
let folded = "BEGIN:VCARD\r\nVERSION:4.0\r\nNOTE:a long\r\n note\r\nEND:VCARD\r\n";
let card = VcardCst::parse(folded).unwrap();
assert_eq!(
card.to_string(),
"BEGIN:VCARD\r\nVERSION:4.0\r\nNOTE:a long note\r\nEND:VCARD\r\n",
);
let output = card.to_string();
let reparsed = VcardCst::parse(&output).unwrap();
assert_eq!(reparsed.to_string(), output);
}
#[test]
fn tolerates_blank_lines_and_a_missing_final_break() {
let input = "BEGIN:VCARD\r\nVERSION:4.0\r\n\r\nFN:John\r\nEND:VCARD";
let card = VcardCst::parse(input).unwrap();
assert_eq!(card.decode().properties.len(), 1);
assert_eq!(
card.to_string(),
"BEGIN:VCARD\r\nVERSION:4.0\r\nFN:John\r\nEND:VCARD",
);
}
}