1pub const FORMULA_PREFIXES: [char; 6] = ['=', '+', '-', '@', '\t', '\r'];
11
12#[must_use]
14pub fn escape(field: &str) -> String {
15 let guarded = if field.starts_with(FORMULA_PREFIXES) {
16 format!("'{field}")
17 } else {
18 field.to_owned()
19 };
20 if guarded.contains([',', '"', '\n', '\r']) {
21 format!("\"{}\"", guarded.replace('"', "\"\""))
22 } else {
23 guarded
24 }
25}
26
27#[must_use]
29pub fn row(fields: &[&str]) -> String {
30 let mut line = fields
31 .iter()
32 .map(|field| escape(field))
33 .collect::<Vec<_>>()
34 .join(",");
35 line.push('\n');
36 line
37}
38
39#[cfg(test)]
40mod tests {
41 use super::*;
42
43 #[test]
44 fn plain_fields_pass_through() {
45 assert_eq!(escape("nick@example.com"), "nick@example.com");
46 assert_eq!(escape("pending"), "pending");
47 }
48
49 #[test]
50 fn formula_leading_cells_are_prefixed() {
51 for evil in ["=cmd|' /C", "+1", "-1", "@SUM(A1)", "\ttab", "\rCR"] {
52 let escaped = escape(evil);
53 let unquoted = escaped.trim_matches('"');
55 assert!(unquoted.starts_with('\''), "{evil:?} -> {escaped:?}");
56 assert!(!unquoted.starts_with(evil.chars().next().unwrap()));
57 }
58 }
59
60 #[test]
61 fn quoting_doubles_embedded_quotes() {
62 assert_eq!(escape("a\"b"), "\"a\"\"b\"");
63 assert_eq!(escape("a,b"), "\"a,b\"");
64 }
65
66 #[test]
67 fn formula_guard_applies_before_quoting() {
68 assert_eq!(escape("=a,b"), "\"'=a,b\"");
70 }
71
72 #[test]
73 fn row_joins_and_terminates() {
74 assert_eq!(row(&["a", "=b", "c"]), "a,'=b,c\n");
75 }
76}