959 Csv Writer
Tutorial
The Problem
Implement a CSV writer that correctly escapes fields: any field containing a comma, double-quote, newline, or carriage return must be wrapped in double quotes, with internal double-quotes doubled (" → ""). Implement field escaping, single-row serialization, and multi-row document serialization.
🎯 Learning Outcomes
needs_quoting(s: &str) -> bool to detect when a field requires quotingescape_field: wrap in quotes and double internal " charactersfields.iter().map(escape_field).collect::<Vec<_>>().join(",") for row serialization&str and String input variants,, ", \n, or \rCode Example
#![allow(clippy::all)]
// 959: CSV Writer
// Escape quotes, handle commas/newlines in fields, produce valid CSV output
// Approach 1: Escape a single field
pub fn needs_quoting(s: &str) -> bool {
s.contains(',') || s.contains('"') || s.contains('\n') || s.contains('\r')
}
pub fn escape_field(s: &str) -> String {
if needs_quoting(s) {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for c in s.chars() {
if c == '"' {
out.push('"');
out.push('"');
} else {
out.push(c);
}
}
out.push('"');
out
} else {
s.to_string()
}
}
// Approach 2: Write a single row
pub fn write_row(fields: &[&str]) -> String {
fields
.iter()
.map(|f| escape_field(f))
.collect::<Vec<_>>()
.join(",")
}
pub fn write_row_owned(fields: &[String]) -> String {
fields
.iter()
.map(|f| escape_field(f))
.collect::<Vec<_>>()
.join(",")
}
// Approach 3: Write complete CSV (rows of string slices)
pub fn write_csv(rows: &[Vec<&str>]) -> String {
rows.iter()
.map(|row| write_row(row))
.collect::<Vec<_>>()
.join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_no_quoting_needed() {
assert_eq!(escape_field("hello"), "hello");
assert_eq!(escape_field("42"), "42");
assert_eq!(escape_field(""), "");
}
#[test]
fn test_comma_quoting() {
assert_eq!(escape_field("one, two"), "\"one, two\"");
}
#[test]
fn test_quote_escaping() {
assert_eq!(escape_field("say \"hi\""), "\"say \"\"hi\"\"\"");
}
#[test]
fn test_newline_quoting() {
assert_eq!(escape_field("line1\nline2"), "\"line1\nline2\"");
}
#[test]
fn test_write_row_plain() {
assert_eq!(write_row(&["name", "age", "city"]), "name,age,city");
}
#[test]
fn test_write_row_with_special() {
assert_eq!(
write_row(&["Alice, Smith", "30", "Amsterdam"]),
"\"Alice, Smith\",30,Amsterdam"
);
}
#[test]
fn test_write_csv() {
let rows = vec![
vec!["name", "age", "city"],
vec!["Alice, Smith", "30", "Amsterdam"],
vec!["Bob", "25", "say \"hi\""],
];
let csv = write_csv(&rows);
let lines: Vec<&str> = csv.lines().collect();
assert_eq!(lines.len(), 3);
assert_eq!(lines[0], "name,age,city");
assert_eq!(lines[1], "\"Alice, Smith\",30,Amsterdam");
assert_eq!(lines[2], "Bob,25,\"say \"\"hi\"\"\"");
}
}Key Differences
| Aspect | Rust | OCaml |
|---|---|---|
| Quote detection | .contains('"') etc. | String.contains s '"' etc. |
| String builder | String::with_capacity + push | Buffer + add_char |
| Row joining | .join(",") | String.concat "," |
| Multi-row | .join("\n") | String.concat "\n" |
| Slice vs list | &[&str] — zero-copy borrowed slice | string list — linked list |
The writer and parser (958) form a pair that should round-trip: any Vec<Vec<String>> serialized with write_csv and parsed with parse_csv_line row-by-row should recover the original data exactly.
OCaml Approach
let needs_quoting s =
String.contains s ',' || String.contains s '"' ||
String.contains s '\n' || String.contains s '\r'
let escape_field s =
if not (needs_quoting s) then s
else begin
let buf = Buffer.create (String.length s + 2) in
Buffer.add_char buf '"';
String.iter (fun c ->
if c = '"' then Buffer.add_char buf '"';
Buffer.add_char buf c
) s;
Buffer.add_char buf '"';
Buffer.contents buf
end
let write_row fields =
String.concat "," (List.map escape_field fields)
let write_csv rows =
String.concat "\n" (List.map write_row rows)
OCaml's String.contains provides a built-in single-character search, slightly cleaner than Rust's .contains(char). The Buffer-based approach avoids intermediate string allocations during escape processing.
Full Source
#![allow(clippy::all)]
// 959: CSV Writer
// Escape quotes, handle commas/newlines in fields, produce valid CSV output
// Approach 1: Escape a single field
pub fn needs_quoting(s: &str) -> bool {
s.contains(',') || s.contains('"') || s.contains('\n') || s.contains('\r')
}
pub fn escape_field(s: &str) -> String {
if needs_quoting(s) {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for c in s.chars() {
if c == '"' {
out.push('"');
out.push('"');
} else {
out.push(c);
}
}
out.push('"');
out
} else {
s.to_string()
}
}
// Approach 2: Write a single row
pub fn write_row(fields: &[&str]) -> String {
fields
.iter()
.map(|f| escape_field(f))
.collect::<Vec<_>>()
.join(",")
}
pub fn write_row_owned(fields: &[String]) -> String {
fields
.iter()
.map(|f| escape_field(f))
.collect::<Vec<_>>()
.join(",")
}
// Approach 3: Write complete CSV (rows of string slices)
pub fn write_csv(rows: &[Vec<&str>]) -> String {
rows.iter()
.map(|row| write_row(row))
.collect::<Vec<_>>()
.join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_no_quoting_needed() {
assert_eq!(escape_field("hello"), "hello");
assert_eq!(escape_field("42"), "42");
assert_eq!(escape_field(""), "");
}
#[test]
fn test_comma_quoting() {
assert_eq!(escape_field("one, two"), "\"one, two\"");
}
#[test]
fn test_quote_escaping() {
assert_eq!(escape_field("say \"hi\""), "\"say \"\"hi\"\"\"");
}
#[test]
fn test_newline_quoting() {
assert_eq!(escape_field("line1\nline2"), "\"line1\nline2\"");
}
#[test]
fn test_write_row_plain() {
assert_eq!(write_row(&["name", "age", "city"]), "name,age,city");
}
#[test]
fn test_write_row_with_special() {
assert_eq!(
write_row(&["Alice, Smith", "30", "Amsterdam"]),
"\"Alice, Smith\",30,Amsterdam"
);
}
#[test]
fn test_write_csv() {
let rows = vec![
vec!["name", "age", "city"],
vec!["Alice, Smith", "30", "Amsterdam"],
vec!["Bob", "25", "say \"hi\""],
];
let csv = write_csv(&rows);
let lines: Vec<&str> = csv.lines().collect();
assert_eq!(lines.len(), 3);
assert_eq!(lines[0], "name,age,city");
assert_eq!(lines[1], "\"Alice, Smith\",30,Amsterdam");
assert_eq!(lines[2], "Bob,25,\"say \"\"hi\"\"\"");
}
}#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_no_quoting_needed() {
assert_eq!(escape_field("hello"), "hello");
assert_eq!(escape_field("42"), "42");
assert_eq!(escape_field(""), "");
}
#[test]
fn test_comma_quoting() {
assert_eq!(escape_field("one, two"), "\"one, two\"");
}
#[test]
fn test_quote_escaping() {
assert_eq!(escape_field("say \"hi\""), "\"say \"\"hi\"\"\"");
}
#[test]
fn test_newline_quoting() {
assert_eq!(escape_field("line1\nline2"), "\"line1\nline2\"");
}
#[test]
fn test_write_row_plain() {
assert_eq!(write_row(&["name", "age", "city"]), "name,age,city");
}
#[test]
fn test_write_row_with_special() {
assert_eq!(
write_row(&["Alice, Smith", "30", "Amsterdam"]),
"\"Alice, Smith\",30,Amsterdam"
);
}
#[test]
fn test_write_csv() {
let rows = vec![
vec!["name", "age", "city"],
vec!["Alice, Smith", "30", "Amsterdam"],
vec!["Bob", "25", "say \"hi\""],
];
let csv = write_csv(&rows);
let lines: Vec<&str> = csv.lines().collect();
assert_eq!(lines.len(), 3);
assert_eq!(lines[0], "name,age,city");
assert_eq!(lines[1], "\"Alice, Smith\",30,Amsterdam");
assert_eq!(lines[2], "Bob,25,\"say \"\"hi\"\"\"");
}
}
Deep Comparison
CSV Writer — Comparison
Core Insight
CSV writing is the inverse of parsing: turn structured data back into escaped text. Both languages use the same algorithm (check if quoting is needed, double embedded quotes, wrap in outer quotes). OCaml's Buffer is the mutable accumulator equivalent to Rust's String::with_capacity.
OCaml Approach
String.contains s ',' — check if quoting is neededBuffer.create + Buffer.add_char + Buffer.contents for efficient buildingString.iter to iterate charactersString.concat "," (List.map escape_field fields) — functional pipeline for rowsString.concat "\n" (List.map write_row rows) — functional pipeline for CSVRust Approach
s.contains(',') — idiomatic contains checkString::with_capacity pre-allocates for efficiencyfor c in s.chars() iterates characters (Unicode-aware).map(|f| escape_field(f)).collect::<Vec<_>>().join(",") — functional pipelinerows.iter().map(write_row).collect::<Vec<_>>().join("\n")Comparison Table
| Aspect | OCaml | Rust |
|---|---|---|
| Contains check | String.contains s ',' | s.contains(',') |
| String building | Buffer.create + add_char | String::with_capacity + push |
| Char iteration | String.iter | s.chars() |
| Row join | String.concat "," list | vec.join(",") |
| Map + join pattern | String.concat sep (List.map f list) | list.iter().map(f).collect::<Vec<_>>().join(sep) |
| Empty field | "" → no quoting | "" → no quoting (same) |
Exercises
parse(write(data)) == data.append_row variant that writes to a Write trait implementation (e.g., std::io::stdout()) instead of returning a String.write_with_bom variant.write_tsv (tab-separated values) by parameterizing the delimiter.needs_quoting to also quote fields that start or end with whitespace.