Skip to main content

datasynth_output/
fast_csv.rs

1//! Fast CSV writing utilities using itoa/ryu for zero-allocation number formatting.
2//!
3//! The standard `format!()` macro allocates a new String per row. This module provides
4//! write-through helpers that format numbers directly into the output buffer using
5//! `itoa` (integers) and `ryu` (floats), avoiding intermediate allocations entirely.
6
7use std::io::Write;
8
9/// Write a CSV-escaped string field directly to a writer.
10///
11/// Only quotes the string if it contains special characters (comma, quote, newline).
12/// This avoids the allocation that `format!("\"{}\"", s.replace('"', "\"\""))` incurs.
13#[inline]
14pub fn write_csv_field<W: Write>(w: &mut W, s: &str) -> std::io::Result<()> {
15    if s.contains(',') || s.contains('"') || s.contains('\n') {
16        w.write_all(b"\"")?;
17        for byte in s.as_bytes() {
18            if *byte == b'"' {
19                w.write_all(b"\"\"")?;
20            } else {
21                w.write_all(std::slice::from_ref(byte))?;
22            }
23        }
24        w.write_all(b"\"")?;
25    } else {
26        w.write_all(s.as_bytes())?;
27    }
28    Ok(())
29}
30
31/// Write an Option<String> field, writing empty string for None.
32#[inline]
33pub fn write_csv_opt_field<W: Write>(w: &mut W, opt: &Option<String>) -> std::io::Result<()> {
34    match opt {
35        Some(s) => write_csv_field(w, s),
36        None => Ok(()),
37    }
38}
39
40/// Write an integer field using itoa (no allocation).
41#[inline]
42pub fn write_csv_int<W: Write, I: itoa::Integer>(w: &mut W, val: I) -> std::io::Result<()> {
43    let mut buf = itoa::Buffer::new();
44    w.write_all(buf.format(val).as_bytes())
45}
46
47/// Write a float field using ryu (no allocation).
48#[inline]
49pub fn write_csv_float<W: Write, F: ryu::Float>(w: &mut W, val: F) -> std::io::Result<()> {
50    let mut buf = ryu::Buffer::new();
51    w.write_all(buf.format(val).as_bytes())
52}
53
54/// Write a rust_decimal::Decimal field directly (avoids to_string() allocation).
55///
56/// Uses a stack-allocated buffer to format the decimal, then writes it directly.
57#[inline]
58pub fn write_csv_decimal<W: Write>(w: &mut W, val: &rust_decimal::Decimal) -> std::io::Result<()> {
59    // rust_decimal's Display impl writes to a formatter; we use a small stack buffer
60    use std::fmt::Write as FmtWrite;
61    let mut buf = DecimalBuffer::new();
62    // This cannot fail since DecimalBuffer always has capacity
63    let _ = write!(buf, "{val}");
64    w.write_all(buf.as_bytes())
65}
66
67/// Write a CSV comma separator.
68#[inline]
69pub fn write_sep<W: Write>(w: &mut W) -> std::io::Result<()> {
70    w.write_all(b",")
71}
72
73/// Write a newline.
74#[inline]
75pub fn write_newline<W: Write>(w: &mut W) -> std::io::Result<()> {
76    w.write_all(b"\n")
77}
78
79/// Write a boolean as "true" or "false".
80#[inline]
81pub fn write_csv_bool<W: Write>(w: &mut W, val: bool) -> std::io::Result<()> {
82    w.write_all(if val { b"true" } else { b"false" })
83}
84
85/// Small stack-allocated buffer for formatting Decimals without heap allocation.
86///
87/// rust_decimal values are at most ~30 characters, so 48 bytes is plenty.
88struct DecimalBuffer {
89    buf: [u8; 48],
90    len: usize,
91}
92
93impl DecimalBuffer {
94    #[inline]
95    fn new() -> Self {
96        Self {
97            buf: [0u8; 48],
98            len: 0,
99        }
100    }
101
102    #[inline]
103    fn as_bytes(&self) -> &[u8] {
104        &self.buf[..self.len]
105    }
106}
107
108impl std::fmt::Write for DecimalBuffer {
109    #[inline]
110    fn write_str(&mut self, s: &str) -> std::fmt::Result {
111        let bytes = s.as_bytes();
112        let remaining = self.buf.len() - self.len;
113        if bytes.len() > remaining {
114            return Err(std::fmt::Error);
115        }
116        self.buf[self.len..self.len + bytes.len()].copy_from_slice(bytes);
117        self.len += bytes.len();
118        Ok(())
119    }
120}
121
122#[cfg(test)]
123#[allow(clippy::unwrap_used)]
124mod tests {
125    use super::*;
126    use rust_decimal_macros::dec;
127
128    #[test]
129    fn test_write_csv_field_simple() {
130        let mut buf = Vec::new();
131        write_csv_field(&mut buf, "hello").unwrap();
132        assert_eq!(std::str::from_utf8(&buf).unwrap(), "hello");
133    }
134
135    #[test]
136    fn test_write_csv_field_with_comma() {
137        let mut buf = Vec::new();
138        write_csv_field(&mut buf, "hello,world").unwrap();
139        assert_eq!(std::str::from_utf8(&buf).unwrap(), "\"hello,world\"");
140    }
141
142    #[test]
143    fn test_write_csv_field_with_quote() {
144        let mut buf = Vec::new();
145        write_csv_field(&mut buf, "say \"hi\"").unwrap();
146        assert_eq!(std::str::from_utf8(&buf).unwrap(), "\"say \"\"hi\"\"\"");
147    }
148
149    #[test]
150    fn test_write_csv_int() {
151        let mut buf = Vec::new();
152        write_csv_int(&mut buf, 42i32).unwrap();
153        assert_eq!(std::str::from_utf8(&buf).unwrap(), "42");
154    }
155
156    #[test]
157    fn test_write_csv_int_negative() {
158        let mut buf = Vec::new();
159        write_csv_int(&mut buf, -123i64).unwrap();
160        assert_eq!(std::str::from_utf8(&buf).unwrap(), "-123");
161    }
162
163    #[test]
164    fn test_write_csv_decimal() {
165        let mut buf = Vec::new();
166        write_csv_decimal(&mut buf, &dec!(1234.56)).unwrap();
167        assert_eq!(std::str::from_utf8(&buf).unwrap(), "1234.56");
168    }
169
170    #[test]
171    fn test_write_csv_decimal_zero() {
172        let mut buf = Vec::new();
173        write_csv_decimal(&mut buf, &dec!(0.00)).unwrap();
174        assert_eq!(std::str::from_utf8(&buf).unwrap(), "0.00");
175    }
176
177    #[test]
178    fn test_write_csv_opt_field_some() {
179        let mut buf = Vec::new();
180        let val = Some("test".to_string());
181        write_csv_opt_field(&mut buf, &val).unwrap();
182        assert_eq!(std::str::from_utf8(&buf).unwrap(), "test");
183    }
184
185    #[test]
186    fn test_write_csv_opt_field_none() {
187        let mut buf = Vec::new();
188        write_csv_opt_field(&mut buf, &None).unwrap();
189        assert_eq!(std::str::from_utf8(&buf).unwrap(), "");
190    }
191
192    #[test]
193    fn test_write_csv_bool() {
194        let mut buf = Vec::new();
195        write_csv_bool(&mut buf, true).unwrap();
196        assert_eq!(std::str::from_utf8(&buf).unwrap(), "true");
197
198        let mut buf = Vec::new();
199        write_csv_bool(&mut buf, false).unwrap();
200        assert_eq!(std::str::from_utf8(&buf).unwrap(), "false");
201    }
202
203    #[test]
204    fn test_combined_row() {
205        let mut buf = Vec::new();
206        write_csv_field(&mut buf, "DOC001").unwrap();
207        write_sep(&mut buf).unwrap();
208        write_csv_int(&mut buf, 2024i32).unwrap();
209        write_sep(&mut buf).unwrap();
210        write_csv_decimal(&mut buf, &dec!(1500.00)).unwrap();
211        write_sep(&mut buf).unwrap();
212        write_csv_bool(&mut buf, false).unwrap();
213        write_newline(&mut buf).unwrap();
214
215        assert_eq!(
216            std::str::from_utf8(&buf).unwrap(),
217            "DOC001,2024,1500.00,false\n"
218        );
219    }
220}