Skip to main content

qrcode_parse/
vcard.rs

1//! vCard (`.vcf`) parsing and encoding.
2//!
3//! The encoder emits a minimal vCard 3.0 card; the parser is tolerant of vCard
4//! 2.1 / 3.0 / 4.0, accepts `\n` or `\r\n` line endings, unfolds folded lines,
5//! and ignores property parameters (e.g. the `;TYPE=cell` in `TEL;TYPE=cell:`).
6//! The `qrcode-rs` facade uses this same module for its convenience
7//! constructor, so [`encode_vcard`] and [`VCard::parse`] stay symmetric.
8
9#[cfg(not(feature = "std"))]
10#[allow(unused_imports)]
11use alloc::{borrow::ToOwned, format, string::String, vec, vec::Vec};
12
13use crate::ParseError;
14
15/// A parsed vCard contact recovered from a `BEGIN:VCARD` … `END:VCARD` payload.
16///
17/// Each field holds the first value seen for its property (`FN`/`N`, `TEL`,
18/// `EMAIL`, `ORG`, `URL`, `ADR`). The struct is `#[non_exhaustive]`: read via
19/// the accessors; additional fields may appear in 1.x.
20#[non_exhaustive]
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct VCard {
23    /// Formatted name (`FN`), falling back to the structured `N` property.
24    name: Option<String>,
25    /// Telephone number (`TEL`).
26    phone: Option<String>,
27    /// Email address (`EMAIL`).
28    email: Option<String>,
29    /// Organization (`ORG`).
30    organization: Option<String>,
31    /// URL (`URL`).
32    url: Option<String>,
33    /// Address (`ADR`), stored as the raw structured value.
34    address: Option<String>,
35}
36
37impl VCard {
38    /// Parses a vCard payload.
39    ///
40    /// Tolerant of versions 2.1 / 3.0 / 4.0, either line ending, line folding,
41    /// and property parameters. The name comes from `FN`, or from `N` when no
42    /// `FN` is present.
43    ///
44    /// # Errors
45    ///
46    /// Returns [`ParseError::InvalidFormat`] if no `BEGIN:VCARD` line is found.
47    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; // blank or keyless line — skip
60            };
61            // The property name is the segment before any `;` params.
62            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    /// The formatted name (`FN`), or a best-effort rendering of `N`.
83    #[must_use]
84    pub fn name(&self) -> Option<&str> {
85        self.name.as_deref()
86    }
87
88    /// The first telephone number (`TEL`).
89    #[must_use]
90    pub fn phone(&self) -> Option<&str> {
91        self.phone.as_deref()
92    }
93
94    /// The first email address (`EMAIL`).
95    #[must_use]
96    pub fn email(&self) -> Option<&str> {
97        self.email.as_deref()
98    }
99
100    /// The organization (`ORG`).
101    #[must_use]
102    pub fn organization(&self) -> Option<&str> {
103        self.organization.as_deref()
104    }
105
106    /// The URL (`URL`).
107    #[must_use]
108    pub fn url(&self) -> Option<&str> {
109        self.url.as_deref()
110    }
111
112    /// The raw structured address value (`ADR`).
113    #[must_use]
114    pub fn address(&self) -> Option<&str> {
115        self.address.as_deref()
116    }
117}
118
119/// Encodes a minimal vCard 3.0 card.
120///
121/// This is the single source of truth shared with the `qrcode-rs` facade's
122/// `QrCode::for_vcard` constructor.
123pub 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
127/// Splits the payload into unfolded logical lines, normalizing `\r\n` and `\n`.
128/// A line beginning with a space or tab is a continuation of the previous line
129/// (vCard line folding) and is appended to it.
130fn 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            // Continuation: drop the leading fold char and append.
136            // The fold char is ASCII (1 byte), so index 1 is a valid boundary.
137            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
147/// Joins the non-empty components of a structured `N` value
148/// (`Family;Given;Additional;Prefix;Suffix`) with single spaces.
149fn parse_n(value: &str) -> String {
150    let parts: Vec<&str> = value.split(';').filter(|p| !p.is_empty()).collect();
151    parts.join(" ")
152}
153
154/// Joins a multi-component value (`ORG` can be `Company;Unit`) with `; `.
155fn 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        // A folded URL: the second line is a continuation.
195        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}