Skip to main content

cell_sheet_core/io/
cell_format.rs

1use crate::model::{col_index_to_label, col_label_to_index, CellValue, Sheet};
2use std::io::{BufRead, BufReader, Read, Write};
3
4fn escape_cell_string(s: &str) -> String {
5    let mut out = String::with_capacity(s.len());
6    for c in s.chars() {
7        match c {
8            '\\' => out.push_str("\\\\"),
9            '"' => out.push_str("\\\""),
10            _ => out.push(c),
11        }
12    }
13    out
14}
15
16fn unescape_cell_string(s: &str) -> String {
17    let mut out = String::with_capacity(s.len());
18    let mut chars = s.chars();
19    while let Some(c) = chars.next() {
20        if c == '\\' {
21            if let Some(next) = chars.next() {
22                out.push(next);
23            }
24        } else {
25            out.push(c);
26        }
27    }
28    out
29}
30
31fn parse_address(addr: &str) -> Option<(usize, usize)> {
32    let mut col_end = 0;
33    for (i, c) in addr.chars().enumerate() {
34        if c.is_ascii_uppercase() {
35            col_end = i + 1;
36        } else {
37            break;
38        }
39    }
40    if col_end == 0 || col_end >= addr.len() {
41        return None;
42    }
43    let col_label = &addr[..col_end];
44    let row_str = &addr[col_end..];
45    let col = col_label_to_index(col_label)?;
46    let row: usize = row_str.parse().ok()?;
47    Some((row, col))
48}
49
50fn format_address(row: usize, col: usize) -> String {
51    format!("{}{}", col_index_to_label(col), row)
52}
53
54pub fn write_cell_format<W: Write>(
55    sheet: &Sheet,
56    mut writer: W,
57) -> Result<(), Box<dyn std::error::Error>> {
58    writeln!(writer, "# cell v1")?;
59    writeln!(writer)?;
60    writeln!(writer, "size {} {}", sheet.row_count, sheet.col_count)?;
61    writeln!(writer)?;
62
63    for (i, &width) in sheet.col_widths.iter().enumerate() {
64        writeln!(writer, "col-width {} {}", i, width)?;
65    }
66    if !sheet.col_widths.is_empty() {
67        writeln!(writer)?;
68    }
69
70    let mut positions: Vec<_> = sheet.cells.keys().cloned().collect();
71    positions.sort();
72
73    for pos in positions {
74        let cell = &sheet.cells[&pos];
75        let addr = format_address(pos.0, pos.1);
76
77        if cell.raw.starts_with('=') {
78            writeln!(writer, "formula {} = {}", addr, cell.raw)?;
79        } else {
80            match &cell.value {
81                CellValue::Number(_) => {
82                    writeln!(writer, "let {} = {}", addr, cell.raw)?;
83                }
84                CellValue::Text(s) => {
85                    writeln!(writer, "label {} = \"{}\"", addr, escape_cell_string(s))?;
86                }
87                CellValue::Bool(b) => {
88                    writeln!(
89                        writer,
90                        "let {} = {}",
91                        addr,
92                        if *b { "TRUE" } else { "FALSE" }
93                    )?;
94                }
95                CellValue::Empty => {}
96                CellValue::Error(_) => {
97                    writeln!(
98                        writer,
99                        "label {} = \"{}\"",
100                        addr,
101                        escape_cell_string(&cell.raw)
102                    )?;
103                }
104            }
105        }
106    }
107
108    Ok(())
109}
110
111pub fn read_cell_format<R: Read>(reader: R) -> Result<Sheet, Box<dyn std::error::Error>> {
112    let mut sheet = Sheet::new();
113    let buf = BufReader::new(reader);
114
115    for line in buf.lines() {
116        let line = line?;
117        let line = line.trim();
118
119        if line.is_empty() || line.starts_with('#') {
120            continue;
121        }
122
123        if let Some(rest) = line.strip_prefix("size ") {
124            let parts: Vec<&str> = rest.split_whitespace().collect();
125            if parts.len() != 2 {
126                return Err(format!(
127                    "corrupted .cell file: `size` directive expects 2 fields, got {}: {:?}",
128                    parts.len(),
129                    rest
130                )
131                .into());
132            }
133            // Returning the parser error instead of silently
134            // collapsing to a 0×0 sheet (issue #76 — the previous
135            // `unwrap_or(0)` made a corrupted file look like an
136            // empty document).
137            sheet.row_count = parts[0].parse().map_err(|e| {
138                format!(
139                    "corrupted .cell file: `size` row_count is not numeric ({:?}): {e}",
140                    parts[0]
141                )
142            })?;
143            sheet.col_count = parts[1].parse().map_err(|e| {
144                format!(
145                    "corrupted .cell file: `size` col_count is not numeric ({:?}): {e}",
146                    parts[1]
147                )
148            })?;
149        } else if let Some(rest) = line.strip_prefix("col-width ") {
150            let parts: Vec<&str> = rest.split_whitespace().collect();
151            if parts.len() != 2 {
152                return Err(format!(
153                    "corrupted .cell file: `col-width` expects 2 fields, got {}: {:?}",
154                    parts.len(),
155                    rest
156                )
157                .into());
158            }
159            let idx: usize = parts[0].parse().map_err(|e| {
160                format!(
161                    "corrupted .cell file: `col-width` index is not numeric ({:?}): {e}",
162                    parts[0]
163                )
164            })?;
165            let width: u16 = parts[1].parse().map_err(|e| {
166                format!(
167                    "corrupted .cell file: `col-width` width is not numeric ({:?}): {e}",
168                    parts[1]
169                )
170            })?;
171            if idx >= sheet.col_widths.len() {
172                sheet.col_widths.resize(idx + 1, 10);
173            }
174            sheet.col_widths[idx] = width;
175        } else if let Some(rest) = line.strip_prefix("let ") {
176            if let Some((addr_str, value_str)) = rest.split_once(" = ") {
177                if let Some(pos) = parse_address(addr_str.trim()) {
178                    sheet.set_cell(pos, value_str.trim());
179                }
180            }
181        } else if let Some(rest) = line.strip_prefix("label ") {
182            if let Some((addr_str, value_str)) = rest.split_once(" = ") {
183                if let Some(pos) = parse_address(addr_str.trim()) {
184                    let s = value_str.trim();
185                    let s = if s.starts_with('"') && s.ends_with('"') && s.len() >= 2 {
186                        unescape_cell_string(&s[1..s.len() - 1])
187                    } else {
188                        s.to_string()
189                    };
190                    sheet.set_cell(pos, &s);
191                }
192            }
193        } else if let Some(rest) = line.strip_prefix("formula ") {
194            if let Some((addr_str, formula_str)) = rest.split_once(" = ") {
195                if let Some(pos) = parse_address(addr_str.trim()) {
196                    sheet.set_cell(pos, formula_str.trim());
197                }
198            }
199        }
200    }
201
202    Ok(sheet)
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208    use crate::model::CellValue;
209
210    #[test]
211    fn write_and_read_roundtrip() {
212        let mut sheet = Sheet::new();
213        sheet.set_cell((0, 0), "42");
214        sheet.set_cell((0, 1), "hello");
215        sheet.set_cell((1, 0), "=A1+1");
216        sheet.cells.get_mut(&(1, 0)).unwrap().value = CellValue::Number(43.0);
217        sheet.col_widths = vec![12, 8];
218
219        let mut buf = Vec::new();
220        write_cell_format(&sheet, &mut buf).unwrap();
221        let output = String::from_utf8(buf.clone()).unwrap();
222
223        assert!(output.contains("size 2 2"));
224        assert!(output.contains("let A0 = 42"));
225        assert!(output.contains("label B0 = \"hello\""));
226        assert!(output.contains("formula A1 = =A1+1"));
227        assert!(output.contains("col-width 0 12"));
228
229        let sheet2 = read_cell_format(buf.as_slice()).unwrap();
230        assert_eq!(sheet2.row_count, 2);
231        assert_eq!(sheet2.col_count, 2);
232        assert_eq!(
233            sheet2.get_cell((0, 0)).unwrap().value,
234            CellValue::Number(42.0)
235        );
236        assert_eq!(
237            sheet2.get_cell((0, 1)).unwrap().value,
238            CellValue::Text("hello".into())
239        );
240        assert_eq!(sheet2.get_cell((1, 0)).unwrap().raw, "=A1+1");
241        assert_eq!(sheet2.col_widths, vec![12, 8]);
242    }
243
244    #[test]
245    fn read_comments_and_blanks() {
246        let data = "# comment\n\nsize 1 1\nlet A0 = 5\n";
247        let sheet = read_cell_format(data.as_bytes()).unwrap();
248        assert_eq!(
249            sheet.get_cell((0, 0)).unwrap().value,
250            CellValue::Number(5.0)
251        );
252    }
253
254    #[test]
255    fn read_label_with_spaces() {
256        let data = "size 1 1\nlabel A0 = \"hello world\"\n";
257        let sheet = read_cell_format(data.as_bytes()).unwrap();
258        assert_eq!(
259            sheet.get_cell((0, 0)).unwrap().value,
260            CellValue::Text("hello world".into())
261        );
262    }
263
264    #[test]
265    fn write_empty_sheet() {
266        let sheet = Sheet::new();
267        let mut buf = Vec::new();
268        write_cell_format(&sheet, &mut buf).unwrap();
269        let output = String::from_utf8(buf).unwrap();
270        assert!(output.contains("size 0 0"));
271    }
272
273    #[test]
274    fn read_float_value() {
275        let data = "size 1 1\nlet A0 = 3.15\n";
276        let sheet = read_cell_format(data.as_bytes()).unwrap();
277        assert_eq!(
278            sheet.get_cell((0, 0)).unwrap().value,
279            CellValue::Number(3.15)
280        );
281    }
282
283    // Regression for https://github.com/garritfra/cell/issues/76. A
284    // corrupted `.cell` file with a non-numeric `size` header used to
285    // load as a 0×0 sheet — looked like an empty document. Now the
286    // parser surfaces a clear "corrupted .cell file" error so the TUI
287    // can show it in the status bar instead of silently truncating
288    // the user's data.
289    #[test]
290    fn read_size_header_with_non_numeric_row_count_returns_error() {
291        let data = "size NaN 5\n";
292        let err = read_cell_format(data.as_bytes())
293            .expect_err("non-numeric size header must produce an error, not a 0x0 sheet");
294        let msg = format!("{err}");
295        assert!(msg.contains("corrupted .cell file"), "got: {msg}");
296        assert!(msg.contains("row_count"), "got: {msg}");
297    }
298
299    #[test]
300    fn read_size_header_with_non_numeric_col_count_returns_error() {
301        let data = "size 5 NaN\n";
302        let err = read_cell_format(data.as_bytes())
303            .expect_err("non-numeric col_count must produce an error");
304        let msg = format!("{err}");
305        assert!(msg.contains("corrupted .cell file"), "got: {msg}");
306        assert!(msg.contains("col_count"), "got: {msg}");
307    }
308
309    #[test]
310    fn read_size_header_with_wrong_arity_returns_error() {
311        let data = "size 5\n";
312        let err = read_cell_format(data.as_bytes())
313            .expect_err("size header with one field must produce an error");
314        assert!(format!("{err}").contains("expects 2 fields"));
315    }
316
317    #[test]
318    fn read_col_width_with_non_numeric_index_returns_error() {
319        let data = "size 1 1\ncol-width foo 12\n";
320        let err =
321            read_cell_format(data.as_bytes()).expect_err("non-numeric col-width index must error");
322        assert!(format!("{err}").contains("col-width"));
323    }
324
325    #[test]
326    fn roundtrip_label_with_embedded_quote() {
327        let mut sheet = Sheet::new();
328        sheet.set_cell((0, 0), "hello\"world");
329        let mut buf = Vec::new();
330        write_cell_format(&sheet, &mut buf).unwrap();
331        let sheet2 = read_cell_format(buf.as_slice()).unwrap();
332        assert_eq!(
333            sheet2.get_cell((0, 0)).unwrap().value,
334            CellValue::Text("hello\"world".into())
335        );
336    }
337
338    #[test]
339    fn roundtrip_label_with_embedded_backslash() {
340        let mut sheet = Sheet::new();
341        sheet.set_cell((0, 0), "hello\\world");
342        let mut buf = Vec::new();
343        write_cell_format(&sheet, &mut buf).unwrap();
344        let sheet2 = read_cell_format(buf.as_slice()).unwrap();
345        assert_eq!(
346            sheet2.get_cell((0, 0)).unwrap().value,
347            CellValue::Text("hello\\world".into())
348        );
349    }
350
351    #[test]
352    fn roundtrip_label_with_quote_and_backslash() {
353        let mut sheet = Sheet::new();
354        sheet.set_cell((0, 0), "say \"hi\\\" to me");
355        let mut buf = Vec::new();
356        write_cell_format(&sheet, &mut buf).unwrap();
357        let sheet2 = read_cell_format(buf.as_slice()).unwrap();
358        assert_eq!(
359            sheet2.get_cell((0, 0)).unwrap().value,
360            CellValue::Text("say \"hi\\\" to me".into())
361        );
362    }
363}