1#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub struct Cell<S> {
24 pub text: S,
26 pub row_span: u32,
28 pub col_span: u32,
30}
31
32impl<S> Cell<S> {
33 pub fn new(text: S, row_span: u32, col_span: u32) -> Self {
35 Self {
36 text,
37 row_span,
38 col_span,
39 }
40 }
41
42 pub fn single(text: S) -> Self {
44 Self::new(text, 1, 1)
45 }
46}
47
48pub fn to_csv<R, C, S>(rows: R) -> String
61where
62 R: IntoIterator<Item = C>,
63 C: IntoIterator<Item = Cell<S>>,
64 S: AsRef<str>,
65{
66 to_delimited(rows, ',')
67}
68
69pub fn to_delimited<R, C, S>(rows: R, delimiter: char) -> String
96where
97 R: IntoIterator<Item = C>,
98 C: IntoIterator<Item = Cell<S>>,
99 S: AsRef<str>,
100{
101 let mut out = String::new();
102 for record in grid(rows) {
103 for (i, field) in record.iter().enumerate() {
104 if i > 0 {
105 out.push(delimiter);
106 }
107 push_field(&mut out, field, delimiter);
108 }
109 out.push_str("\r\n");
110 }
111 out
112}
113
114fn push_field(out: &mut String, field: &str, delimiter: char) {
115 let quote = field.contains(delimiter)
116 || field.contains('"')
117 || field.contains('\n')
118 || field.contains('\r');
119 if quote {
120 out.push('"');
121 out.push_str(&field.replace('"', "\"\""));
122 out.push('"');
123 } else {
124 out.push_str(field);
125 }
126}
127
128fn grid<R, C, S>(rows: R) -> Vec<Vec<String>>
131where
132 R: IntoIterator<Item = C>,
133 C: IntoIterator<Item = Cell<S>>,
134 S: AsRef<str>,
135{
136 let mut covered: Vec<usize> = Vec::new();
138 let mut grid: Vec<Vec<String>> = Vec::new();
139 for row in rows {
140 let mut record: Vec<String> = Vec::new();
141 let mut col = 0;
142 for cell in row {
143 skip_covered(&mut covered, &mut record, &mut col);
144 record.push(cell.text.as_ref().to_string());
145 let span = cell.col_span.max(1) as usize;
146 record.extend(std::iter::repeat_n(String::new(), span - 1));
147 if covered.len() < col + span {
148 covered.resize(col + span, 0);
149 }
150 for c in &mut covered[col..col + span] {
151 *c = cell.row_span.max(1) as usize - 1;
152 }
153 col += span;
154 }
155 while col < covered.len() {
157 if covered[col] > 0 {
158 covered[col] -= 1;
159 }
160 record.push(String::new());
161 col += 1;
162 }
163 grid.push(record);
164 }
165 let width = grid.iter().map(Vec::len).max().unwrap_or(0);
166 for record in &mut grid {
167 record.resize(width, String::new());
168 }
169 grid
170}
171
172fn skip_covered(covered: &mut [usize], record: &mut Vec<String>, col: &mut usize) {
175 while covered.get(*col).is_some_and(|&n| n > 0) {
176 covered[*col] -= 1;
177 record.push(String::new());
178 *col += 1;
179 }
180}
181
182#[cfg(test)]
183mod tests {
184 use super::*;
185
186 fn csv(rows: &[&[Cell<&str>]]) -> String {
187 to_csv(rows.iter().map(|row| row.iter().copied()))
188 }
189
190 #[test]
191 fn plain_fields_are_written_as_they_are() {
192 assert_eq!(
193 csv(&[&[Cell::single("a"), Cell::single("b c")]]),
194 "a,b c\r\n"
195 );
196 }
197
198 #[test]
199 fn a_field_with_a_delimiter_quote_or_line_break_is_quoted() {
200 assert_eq!(
201 csv(&[&[
202 Cell::single("1,5"),
203 Cell::single("say \"hi\""),
204 Cell::single("two\nlines"),
205 Cell::single("cr\rhere"),
206 ]]),
207 "\"1,5\",\"say \"\"hi\"\"\",\"two\nlines\",\"cr\rhere\"\r\n"
208 );
209 }
210
211 #[test]
212 fn the_delimiter_decides_what_needs_quoting() {
213 let row = [Cell::single("a,b"), Cell::single("c\td")];
214 assert_eq!(to_delimited([row], '\t'), "a,b\t\"c\td\"\r\n");
215 }
216
217 #[test]
218 fn a_horizontal_span_leaves_the_positions_it_covers_empty() {
219 assert_eq!(
220 csv(&[
221 &[Cell::new("wide", 1, 3)],
222 &[Cell::single("a"), Cell::single("b"), Cell::single("c")],
223 ]),
224 "wide,,\r\na,b,c\r\n"
225 );
226 }
227
228 #[test]
229 fn a_vertical_span_pushes_the_rows_below_rightward() {
230 assert_eq!(
232 csv(&[
233 &[Cell::new("tall", 3, 1), Cell::single("a")],
234 &[Cell::single("b")],
235 &[Cell::single("c")],
236 &[Cell::single("d"), Cell::single("e")],
237 ]),
238 "tall,a\r\n,b\r\n,c\r\nd,e\r\n"
239 );
240 }
241
242 #[test]
243 fn a_cell_merged_down_at_the_row_end_leaves_the_next_row_aligned() {
244 assert_eq!(
245 csv(&[
246 &[Cell::single("a"), Cell::new("tall", 2, 1)],
247 &[Cell::single("b")],
248 &[Cell::single("c"), Cell::single("d")],
249 ]),
250 "a,tall\r\nb,\r\nc,d\r\n"
251 );
252 }
253
254 #[test]
255 fn a_block_merge_covers_rows_and_columns() {
256 assert_eq!(
257 csv(&[
258 &[Cell::new("block", 2, 2), Cell::single("x")],
259 &[Cell::single("y")],
260 &[Cell::single("p"), Cell::single("q"), Cell::single("r")],
261 ]),
262 "block,,x\r\n,,y\r\np,q,r\r\n"
263 );
264 }
265
266 #[test]
267 fn short_rows_are_padded_to_the_widest() {
268 assert_eq!(
269 csv(&[
270 &[Cell::single("a")],
271 &[Cell::single("b"), Cell::single("c")]
272 ]),
273 "a,\r\nb,c\r\n"
274 );
275 }
276
277 #[test]
278 fn a_zero_span_is_read_as_one() {
279 assert_eq!(
280 csv(&[
281 &[Cell::new("z", 0, 0), Cell::single("a")],
282 &[Cell::single("b"), Cell::single("c")]
283 ]),
284 "z,a\r\nb,c\r\n"
285 );
286 }
287
288 #[test]
289 fn an_empty_table_is_empty_text() {
290 assert_eq!(csv(&[]), "");
291 }
292
293 #[test]
294 fn owned_text_works_as_well_as_borrowed() {
295 let rows = vec![vec![Cell::single(String::from("own"))]];
296 assert_eq!(to_csv(rows), "own\r\n");
297 }
298}