1#[cfg(not(feature = "std"))]
10#[allow(unused_imports)]
11use alloc::{borrow::ToOwned, format, string::String, vec, vec::Vec};
12
13use crate::ParseError;
14
15#[non_exhaustive]
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct VCard {
23 name: Option<String>,
25 phone: Option<String>,
27 email: Option<String>,
29 organization: Option<String>,
31 url: Option<String>,
33 address: Option<String>,
35}
36
37impl VCard {
38 pub fn parse(s: &str) -> Result<Self, ParseError> {
48 let mut began = false;
49 let mut fn_name = None;
50 let mut n_name = None;
51 let mut phone = None;
52 let mut email = None;
53 let mut organization = None;
54 let mut url = None;
55 let mut address = None;
56
57 for line in unfold(s) {
58 let Some((prop, value)) = line.split_once(':') else {
59 continue; };
61 let key = prop.split(';').next().unwrap_or("").to_ascii_uppercase();
63 match key.as_str() {
64 "BEGIN" if value.eq_ignore_ascii_case("VCARD") => began = true,
65 "FN" if fn_name.is_none() => fn_name = Some(value.to_owned()),
66 "N" if n_name.is_none() => n_name = Some(parse_n(value)),
67 "TEL" if phone.is_none() => phone = Some(value.to_owned()),
68 "EMAIL" if email.is_none() => email = Some(value.to_owned()),
69 "ORG" if organization.is_none() => organization = Some(parse_semicolons(value)),
70 "URL" if url.is_none() => url = Some(value.to_owned()),
71 "ADR" if address.is_none() => address = Some(value.to_owned()),
72 _ => {}
73 }
74 }
75
76 if !began {
77 return Err(ParseError::InvalidFormat);
78 }
79 Ok(Self { name: fn_name.or(n_name), phone, email, organization, url, address })
80 }
81
82 #[must_use]
84 pub fn name(&self) -> Option<&str> {
85 self.name.as_deref()
86 }
87
88 #[must_use]
90 pub fn phone(&self) -> Option<&str> {
91 self.phone.as_deref()
92 }
93
94 #[must_use]
96 pub fn email(&self) -> Option<&str> {
97 self.email.as_deref()
98 }
99
100 #[must_use]
102 pub fn organization(&self) -> Option<&str> {
103 self.organization.as_deref()
104 }
105
106 #[must_use]
108 pub fn url(&self) -> Option<&str> {
109 self.url.as_deref()
110 }
111
112 #[must_use]
114 pub fn address(&self) -> Option<&str> {
115 self.address.as_deref()
116 }
117}
118
119pub fn encode_vcard(name: &str, phone: &str, email: &str) -> String {
124 format!("BEGIN:VCARD\r\nVERSION:3.0\r\nFN:{name}\r\nTEL:{phone}\r\nEMAIL:{email}\r\nEND:VCARD\r\n")
125}
126
127fn unfold(s: &str) -> Vec<String> {
131 let mut out: Vec<String> = Vec::new();
132 for raw in s.split('\n') {
133 let line = raw.strip_suffix('\r').unwrap_or(raw);
134 if (line.starts_with(' ') || line.starts_with('\t')) && out.last().is_some() {
135 if let Some(last) = out.last_mut() {
138 last.push_str(&line[1..]);
139 }
140 } else {
141 out.push(line.to_owned());
142 }
143 }
144 out
145}
146
147fn parse_n(value: &str) -> String {
150 let parts: Vec<&str> = value.split(';').filter(|p| !p.is_empty()).collect();
151 parts.join(" ")
152}
153
154fn parse_semicolons(value: &str) -> String {
156 value.split(';').filter(|p| !p.is_empty()).collect::<Vec<_>>().join("; ")
157}
158
159#[cfg(test)]
160mod tests {
161 use super::*;
162
163 #[test]
164 fn round_trip_minimal_card() {
165 let payload = encode_vcard("John Doe", "+1234567890", "john@example.com");
166 let card = VCard::parse(&payload).unwrap();
167 assert_eq!(card.name(), Some("John Doe"));
168 assert_eq!(card.phone(), Some("+1234567890"));
169 assert_eq!(card.email(), Some("john@example.com"));
170 assert_eq!(card.organization(), None);
171 }
172
173 #[test]
174 fn parse_vcard4_with_params_and_lf() {
175 let s = "BEGIN:VCARD\nVERSION:4.0\nFN:Jane Roe\nTEL;TYPE=cell:+15551234\nEMAIL:jane@example.org\nORG:Acme;Widgets\nURL:https://example.org\nADR;TYPE=home:;;123 Main St;Springfield;IL;62701;USA\nEND:VCARD\n";
176 let card = VCard::parse(s).unwrap();
177 assert_eq!(card.name(), Some("Jane Roe"));
178 assert_eq!(card.phone(), Some("+15551234"));
179 assert_eq!(card.email(), Some("jane@example.org"));
180 assert_eq!(card.organization(), Some("Acme; Widgets"));
181 assert_eq!(card.url(), Some("https://example.org"));
182 assert_eq!(card.address(), Some(";;123 Main St;Springfield;IL;62701;USA"));
183 }
184
185 #[test]
186 fn name_falls_back_to_structured_n() {
187 let s = "BEGIN:VCARD\nVERSION:3.0\nN:Doe;John;;;Jr\nEND:VCARD\n";
188 let card = VCard::parse(s).unwrap();
189 assert_eq!(card.name(), Some("Doe John Jr"));
190 }
191
192 #[test]
193 fn unfolds_folded_lines() {
194 let s = "BEGIN:VCARD\nVERSION:3.0\nFN:Fold\nURL:https://exa\n mple.org/x\nEND:VCARD\n";
196 let card = VCard::parse(s).unwrap();
197 assert_eq!(card.url(), Some("https://example.org/x"));
198 }
199
200 #[test]
201 fn missing_begin_errors() {
202 assert_eq!(VCard::parse("VERSION:3.0\nFN:Nope\n"), Err(ParseError::InvalidFormat));
203 }
204}