reliakit_csv/writer.rs
1//! Deterministic CSV writing.
2
3use alloc::string::String;
4
5/// Builds CSV text one record at a time.
6///
7/// Output is deterministic: a field is quoted only when it contains a delimiter
8/// (`,`), a quote (`"`), or a line break (`\r`/`\n`); an embedded quote is
9/// doubled; and every record is terminated with `\r\n` (per RFC 4180).
10/// In-memory writing cannot fail.
11///
12/// ```
13/// use reliakit_csv::CsvWriter;
14///
15/// let mut writer = CsvWriter::new();
16/// writer.write_record(["id", "note"]);
17/// writer.write_record(["1", "has \"quotes\""]);
18/// assert_eq!(writer.into_string(), "id,note\r\n1,\"has \"\"quotes\"\"\"\r\n");
19/// ```
20#[derive(Debug, Clone, Default)]
21pub struct CsvWriter {
22 out: String,
23}
24
25impl CsvWriter {
26 /// Creates an empty writer.
27 pub const fn new() -> Self {
28 Self { out: String::new() }
29 }
30
31 /// Writes one record from an iterator of field values, terminated by `\r\n`.
32 pub fn write_record<I, S>(&mut self, fields: I)
33 where
34 I: IntoIterator<Item = S>,
35 S: AsRef<str>,
36 {
37 let mut first = true;
38 for field in fields {
39 if !first {
40 self.out.push(',');
41 }
42 first = false;
43 write_field(&mut self.out, field.as_ref());
44 }
45 self.out.push_str("\r\n");
46 }
47
48 /// Returns the number of bytes written so far.
49 pub fn len(&self) -> usize {
50 self.out.len()
51 }
52
53 /// Returns `true` if no records have been written.
54 pub fn is_empty(&self) -> bool {
55 self.out.is_empty()
56 }
57
58 /// Returns a borrowed view of the CSV written so far.
59 pub fn as_str(&self) -> &str {
60 &self.out
61 }
62
63 /// Consumes the writer and returns the CSV text.
64 pub fn into_string(self) -> String {
65 self.out
66 }
67}
68
69/// Appends a single field to `out`, quoting and escaping only as required.
70fn write_field(out: &mut String, field: &str) {
71 if needs_quoting(field) {
72 out.push('"');
73 for c in field.chars() {
74 if c == '"' {
75 out.push('"');
76 }
77 out.push(c);
78 }
79 out.push('"');
80 } else {
81 out.push_str(field);
82 }
83}
84
85/// A field needs quoting if it contains a delimiter, a quote, or a line break.
86fn needs_quoting(field: &str) -> bool {
87 field
88 .bytes()
89 .any(|b| matches!(b, b',' | b'"' | b'\r' | b'\n'))
90}