1pub fn unfold(text: &str) -> Vec<String> {
15 let mut lines: Vec<String> = Vec::new();
16 for raw in text.split('\n') {
17 let line = raw.strip_suffix('\r').unwrap_or(raw);
18 match line.strip_prefix([' ', '\t']) {
19 Some(rest) if !lines.is_empty() => lines.last_mut().into_iter().for_each(|last| last.push_str(rest)),
20 _ if line.is_empty() => {}
21 _ => lines.push(line.to_string()),
22 }
23 }
24 lines
25}
26
27pub fn fold(lines: &[String]) -> String {
29 let mut out = String::new();
30 for line in lines {
31 let mut width = 0;
32 for c in line.chars() {
33 let size = c.len_utf8();
34 if width + size > 75 {
35 out.push_str("\r\n ");
36 width = 1;
37 }
38 out.push(c);
39 width += size;
40 }
41 out.push_str("\r\n");
42 }
43 out
44}
45
46pub fn name(line: &str) -> String {
48 let head = line.split([';', ':']).next().unwrap_or("");
49 head.rsplit('.').next().unwrap_or(head).to_ascii_uppercase()
50}
51
52fn colon(line: &str) -> Option<usize> {
54 let mut quoted = false;
55 for (i, c) in line.char_indices() {
56 match c {
57 '"' => quoted = !quoted,
58 ':' if !quoted => return Some(i),
59 _ => {}
60 }
61 }
62 None
63}
64
65pub fn value(line: &str) -> &str {
67 colon(line).map_or("", |i| &line[i + 1..])
68}
69
70pub fn params(line: &str) -> Vec<(String, String)> {
74 let head = colon(line).map_or(line, |i| &line[..i]);
76 let mut parts = Vec::new();
77 let mut quoted = false;
78 let mut start = 0;
79 for (i, c) in head.char_indices() {
80 match c {
81 '"' => quoted = !quoted,
82 ';' if !quoted => {
83 parts.push(&head[start..i]);
84 start = i + 1;
85 }
86 _ => {}
87 }
88 }
89 parts.push(&head[start..]);
90 parts
91 .iter()
92 .skip(1)
93 .map(|p| match p.split_once('=') {
94 Some((name, value)) => (name.trim().to_ascii_uppercase(), caret_decode(value.trim().trim_matches('"'))),
95 None => (p.trim().to_ascii_uppercase(), String::new()),
96 })
97 .collect()
98}
99
100pub fn param(line: &str, name: &str) -> Option<String> {
102 params(line).into_iter().find(|(n, _)| n == name).map(|(_, v)| v)
103}
104
105pub fn param_value(value: &str) -> String {
108 let clean = value.replace('^', "^^").replace('"', "^'").replace("\r\n", "^n").replace(['\n', '\r'], "^n");
109 let clean: String = clean.chars().filter(|c| !c.is_control() || *c == '\t').collect();
111 if clean.contains([':', ';', ',']) { format!("\"{clean}\"") } else { clean }
112}
113
114fn caret_decode(value: &str) -> String {
116 let mut out = String::with_capacity(value.len());
117 let mut chars = value.chars();
118 while let Some(c) = chars.next() {
119 if c != '^' {
120 out.push(c);
121 continue;
122 }
123 match chars.next() {
124 Some('n') => out.push('\n'),
125 Some('\'') => out.push('"'),
126 Some('^') => out.push('^'),
127 Some(other) => {
128 out.push('^');
129 out.push(other);
130 }
131 None => out.push('^'),
132 }
133 }
134 out
135}
136
137pub fn escape(text: &str) -> String {
141 let text: String = text.chars().filter(|c| !c.is_control() || matches!(c, '\n' | '\r' | '\t')).collect();
142 text.replace('\\', "\\\\").replace(';', "\\;").replace(',', "\\,").replace("\r\n", "\\n").replace(['\n', '\r'], "\\n")
143}
144
145pub fn unescape(text: &str) -> String {
147 let mut out = String::with_capacity(text.len());
148 let mut chars = text.chars();
149 while let Some(c) = chars.next() {
150 if c != '\\' {
151 out.push(c);
152 continue;
153 }
154 match chars.next() {
155 Some('n' | 'N') => out.push('\n'),
156 Some(other) => out.push(other),
157 None => out.push('\\'),
158 }
159 }
160 out
161}
162
163pub fn structured(parts: &[String]) -> String {
165 parts.iter().map(|p| escape(p)).collect::<Vec<_>>().join(";")
166}
167
168const BASE64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
169
170pub fn base64_encode(bytes: &[u8]) -> String {
172 let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
173 for chunk in bytes.chunks(3) {
174 let n = (u32::from(chunk[0]) << 16) | (u32::from(*chunk.get(1).unwrap_or(&0)) << 8) | u32::from(*chunk.get(2).unwrap_or(&0));
175 for i in 0..4 {
176 if i <= chunk.len() {
177 out.push(BASE64[((n >> (18 - 6 * i)) & 63) as usize] as char);
178 } else {
179 out.push('=');
180 }
181 }
182 }
183 out
184}
185
186pub fn base64_decode(text: &str) -> Option<Vec<u8>> {
188 let mut out = Vec::with_capacity(text.len() * 3 / 4);
189 let (mut buffer, mut bits) = (0u32, 0u32);
190 for c in text.bytes().filter(|c| !c.is_ascii_whitespace()) {
191 if c == b'=' {
192 break;
193 }
194 let value = BASE64.iter().position(|&b| b == c)? as u32;
195 buffer = (buffer << 6) | value;
196 bits += 6;
197 if bits >= 8 {
198 bits -= 8;
199 out.push((buffer >> bits) as u8);
200 buffer &= (1 << bits) - 1;
201 }
202 }
203 Some(out)
204}
205
206#[cfg(test)]
207mod tests {
208 use super::*;
209
210 #[test]
211 fn folds_and_unfolds() {
212 let long = format!("NOTE:{}", "é".repeat(60));
213 let text = fold(&[long.clone(), "FN:A".into()]);
214 assert!(text.lines().all(|l| l.len() <= 76), "{text}");
215 assert_eq!(unfold(&text), vec![long, "FN:A".to_string()]);
216 assert_eq!(name("item1.EMAIL;TYPE=work:a@example.org"), "EMAIL");
217 assert_eq!(value("ATTENDEE;CN=\"Doe: Jane\":mailto:jane@example.org"), "mailto:jane@example.org");
218 assert_eq!(params("CONTACT;ALTREP=\"sioul:contact/a;b\";LANGUAGE=fr:Jane"), vec![("ALTREP".to_string(), "sioul:contact/a;b".to_string()), ("LANGUAGE".to_string(), "fr".to_string())]);
219 assert_eq!(param("RELATED-TO;reltype=DEPENDS-ON:x", "RELTYPE").as_deref(), Some("DEPENDS-ON"));
220 assert_eq!((param_value("Venue"), param_value("Letter: 2")), ("Venue".to_string(), "\"Letter: 2\"".to_string()));
221 assert_eq!(param(&format!("LINK;LABEL={}:x", param_value("The \"letter\"; 2^")), "LABEL").as_deref(), Some("The \"letter\"; 2^"));
222 assert_eq!(unescape(&escape("a, b; c\\d\ne")), "a, b; c\\d\ne");
223 assert_eq!(base64_encode(b"jane:pass word"), "amFuZTpwYXNzIHdvcmQ=");
224 assert_eq!(base64_decode("amFuZTpwYXNz\r\n IHdvcmQ=").unwrap(), b"jane:pass word");
225 }
226
227 #[test]
228 fn odd_lines_from_elsewhere() {
229 assert_eq!(params("X-FOO;A=é"), vec![("A".to_string(), "é".to_string())]);
231 assert_eq!(value("X-FOO;A=é"), "");
232 let title = escape("Crash\rSTATUS:COMPLETED\u{7}");
234 assert!(!title.contains(['\r', '\n', '\u{7}']), "{title}");
235 assert_eq!(unescape(&title), "Crash\nSTATUS:COMPLETED");
236 assert!(!param_value("a\rb").contains('\r'));
237 }
238}