Skip to main content

formualizer_workbook/backends/
csv.rs

1use crate::error::IoError;
2use crate::traits::{
3    AccessGranularity, BackendCaps, CellData, SaveDestination, SheetData, SpreadsheetReader,
4    SpreadsheetWriter,
5};
6use formualizer_common::LiteralValue;
7use std::collections::BTreeMap;
8use std::fs::File;
9use std::io::{BufReader, Read, Write};
10use std::path::{Path, PathBuf};
11
12#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
13pub enum CsvEncoding {
14    /// CSV v1 supports UTF-8 only.
15    #[default]
16    Utf8,
17}
18
19#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
20pub enum CsvTrim {
21    #[default]
22    None,
23    All,
24}
25
26#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
27pub enum CsvTypeInference {
28    /// Do not infer: treat all non-empty fields as text.
29    Off,
30    /// Infer booleans + numbers when unambiguous.
31    #[default]
32    Basic,
33    /// Like `Basic`, plus conservative date/date-time parsing.
34    BasicWithDates,
35}
36
37#[derive(Clone, Debug)]
38pub struct CsvReadOptions {
39    /// Field delimiter as a single byte. Use `b'\t'` for TSV.
40    pub delimiter: u8,
41    /// When true, the first record is treated as a header row.
42    ///
43    /// v1 behavior: headers are still loaded into row 1, but type inference is disabled
44    /// for that row to avoid surprising coercions.
45    pub has_headers: bool,
46    pub trim: CsvTrim,
47    pub encoding: CsvEncoding,
48    pub type_inference: CsvTypeInference,
49}
50
51impl Default for CsvReadOptions {
52    fn default() -> Self {
53        Self {
54            delimiter: b',',
55            has_headers: false,
56            trim: CsvTrim::None,
57            encoding: CsvEncoding::Utf8,
58            type_inference: CsvTypeInference::Basic,
59        }
60    }
61}
62
63#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
64pub enum CsvNewline {
65    #[default]
66    Lf,
67    Crlf,
68}
69
70#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
71pub enum CsvQuoteStyle {
72    #[default]
73    Necessary,
74    Always,
75    Never,
76    NonNumeric,
77}
78
79#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
80pub enum CsvArrayPolicy {
81    /// Reject exporting arrays to CSV.
82    #[default]
83    Error,
84    /// Export only the top-left element (`[0][0]`).
85    TopLeft,
86    /// Export an empty string.
87    Blank,
88}
89
90#[derive(Clone, Debug)]
91pub struct CsvWriteOptions {
92    /// Field delimiter as a single byte. Use `b'\t'` for TSV.
93    pub delimiter: u8,
94    pub newline: CsvNewline,
95    pub quote_style: CsvQuoteStyle,
96
97    /// Policy for exporting `LiteralValue::Array` into a single CSV field.
98    pub array_policy: CsvArrayPolicy,
99}
100
101impl Default for CsvWriteOptions {
102    fn default() -> Self {
103        Self {
104            delimiter: b',',
105            newline: CsvNewline::Lf,
106            quote_style: CsvQuoteStyle::Necessary,
107            array_policy: CsvArrayPolicy::Error,
108        }
109    }
110}
111
112#[derive(Clone, Debug, Default)]
113struct CsvSheet {
114    /// Only non-empty cells are stored.
115    cells: BTreeMap<(u32, u32), LiteralValue>,
116    /// Maximum row index seen (1-based).
117    max_row: u32,
118    /// Maximum column index seen (1-based).
119    max_col: u32,
120}
121
122impl CsvSheet {
123    fn bounds(&self) -> Option<(u32, u32)> {
124        if self.max_row == 0 || self.max_col == 0 {
125            None
126        } else {
127            Some((self.max_row, self.max_col))
128        }
129    }
130
131    fn set_bounds(&mut self, rows: u32, cols: u32) {
132        self.max_row = self.max_row.max(rows);
133        self.max_col = self.max_col.max(cols);
134    }
135}
136
137/// CSV backend adapter.
138///
139/// Semantics:
140/// - A CSV file is treated as a single-sheet workbook (default sheet name: `Sheet1`).
141/// - UTF-8 only.
142/// - Formulas/styles/tables/named ranges are not supported.
143pub struct CsvAdapter {
144    sheet_name: String,
145    sheet: CsvSheet,
146    path: Option<PathBuf>,
147    read_options: CsvReadOptions,
148    write_options: CsvWriteOptions,
149    caps: BackendCaps,
150}
151
152impl Default for CsvAdapter {
153    fn default() -> Self {
154        Self::new()
155    }
156}
157
158impl CsvAdapter {
159    pub fn new() -> Self {
160        Self::new_with_options(CsvReadOptions::default(), CsvWriteOptions::default())
161    }
162
163    pub fn new_with_options(read_options: CsvReadOptions, write_options: CsvWriteOptions) -> Self {
164        Self {
165            sheet_name: "Sheet1".to_string(),
166            sheet: CsvSheet::default(),
167            path: None,
168            read_options,
169            write_options,
170            caps: BackendCaps {
171                read: true,
172                write: true,
173                streaming: false,
174                tables: false,
175                named_ranges: false,
176                formulas: false,
177                styles: false,
178                lazy_loading: false,
179                random_access: true,
180                bytes_input: true,
181                date_system_1904: false,
182                merged_cells: false,
183                rich_text: false,
184                hyperlinks: false,
185                data_validations: false,
186                shared_formulas: false,
187            },
188        }
189    }
190
191    pub fn read_options(&self) -> &CsvReadOptions {
192        &self.read_options
193    }
194
195    pub fn write_options(&self) -> &CsvWriteOptions {
196        &self.write_options
197    }
198
199    pub fn set_write_options(&mut self, opts: CsvWriteOptions) {
200        self.write_options = opts;
201    }
202
203    pub fn open_path_with_options<P: AsRef<Path>>(
204        path: P,
205        read_options: CsvReadOptions,
206    ) -> Result<Self, IoError> {
207        let mut adapter = Self::new_with_options(read_options, CsvWriteOptions::default());
208        adapter.open_from_path(path.as_ref())?;
209        adapter.path = Some(path.as_ref().to_path_buf());
210        Ok(adapter)
211    }
212
213    pub fn open_reader_with_options(
214        reader: Box<dyn Read + Send + Sync>,
215        read_options: CsvReadOptions,
216    ) -> Result<Self, IoError> {
217        let mut adapter = Self::new_with_options(read_options, CsvWriteOptions::default());
218        adapter.open_from_reader(reader)?;
219        Ok(adapter)
220    }
221
222    pub fn open_bytes_with_options(
223        bytes: Vec<u8>,
224        read_options: CsvReadOptions,
225    ) -> Result<Self, IoError> {
226        let mut adapter = Self::new_with_options(read_options, CsvWriteOptions::default());
227        adapter.open_from_reader(Box::new(std::io::Cursor::new(bytes)))?;
228        Ok(adapter)
229    }
230
231    fn open_from_path(&mut self, path: &Path) -> Result<(), IoError> {
232        let file = File::open(path)?;
233        let reader = BufReader::new(file);
234        self.open_from_reader(Box::new(reader))
235    }
236
237    fn open_from_reader(&mut self, reader: Box<dyn Read + Send + Sync>) -> Result<(), IoError> {
238        if self.read_options.encoding != CsvEncoding::Utf8 {
239            return Err(IoError::Unsupported {
240                feature: "encoding".to_string(),
241                context: "csv: only UTF-8 is supported".to_string(),
242            });
243        }
244
245        let mut rb = csv::ReaderBuilder::new();
246        rb.delimiter(self.read_options.delimiter)
247            .has_headers(self.read_options.has_headers)
248            // Allow ragged rows; we pad missing cells as empty on export.
249            .flexible(true);
250
251        match self.read_options.trim {
252            CsvTrim::None => rb.trim(csv::Trim::None),
253            CsvTrim::All => rb.trim(csv::Trim::All),
254        };
255
256        let mut rdr = rb.from_reader(reader);
257        self.sheet = CsvSheet::default();
258
259        let mut row: u32 = 1;
260
261        if self.read_options.has_headers {
262            let headers = rdr.headers().map_err(|e| IoError::from_backend("csv", e))?;
263            let cols = headers.len() as u32;
264            self.sheet.set_bounds(1, cols);
265            for (ci, field) in headers.iter().enumerate() {
266                let col = (ci as u32) + 1;
267                if let Some(v) = infer_field(field, CsvTypeInference::Off, true) {
268                    self.sheet.cells.insert((row, col), v);
269                }
270            }
271            row += 1;
272        }
273
274        for rec in rdr.records() {
275            let rec = rec.map_err(|e| IoError::from_backend("csv", e))?;
276            let cols = rec.len() as u32;
277            self.sheet.set_bounds(row, cols);
278
279            for (ci, field) in rec.iter().enumerate() {
280                let col = (ci as u32) + 1;
281                if let Some(v) = infer_field(field, self.read_options.type_inference, false) {
282                    self.sheet.cells.insert((row, col), v);
283                }
284            }
285            row += 1;
286        }
287
288        Ok(())
289    }
290
291    pub fn write_sheet_to<'a>(
292        &self,
293        sheet: &str,
294        dest: SaveDestination<'a>,
295        opts: CsvWriteOptions,
296    ) -> Result<Option<Vec<u8>>, IoError> {
297        let Some((rows, cols)) = self.sheet_bounds(sheet) else {
298            return match dest {
299                SaveDestination::InPlace => {
300                    let Some(path) = self.path.as_ref() else {
301                        return Err(IoError::Backend {
302                            backend: "csv".to_string(),
303                            message: "no known path for in-place save".to_string(),
304                        });
305                    };
306                    let _ = File::create(path)?;
307                    Ok(None)
308                }
309                SaveDestination::Path(path) => {
310                    let _ = File::create(path)?;
311                    Ok(None)
312                }
313                SaveDestination::Writer(_writer) => Ok(None),
314                SaveDestination::Bytes => Ok(Some(Vec::new())),
315            };
316        };
317        self.write_range_to(sheet, (1, 1), (rows, cols), dest, opts)
318    }
319
320    pub fn write_range_to<'a>(
321        &self,
322        sheet: &str,
323        start: (u32, u32),
324        end: (u32, u32),
325        dest: SaveDestination<'a>,
326        opts: CsvWriteOptions,
327    ) -> Result<Option<Vec<u8>>, IoError> {
328        if sheet != self.sheet_name {
329            return Err(IoError::Backend {
330                backend: "csv".to_string(),
331                message: format!("sheet not found: {sheet}"),
332            });
333        }
334        let (sr, sc) = start;
335        let (er, ec) = end;
336        if sr == 0 || sc == 0 || er == 0 || ec == 0 {
337            return Err(IoError::Backend {
338                backend: "csv".to_string(),
339                message: "range coordinates are 1-based".to_string(),
340            });
341        }
342        if er < sr || ec < sc {
343            return Err(IoError::Backend {
344                backend: "csv".to_string(),
345                message: "invalid range (end before start)".to_string(),
346            });
347        }
348
349        match dest {
350            SaveDestination::InPlace => {
351                let Some(path) = self.path.as_ref() else {
352                    return Err(IoError::Backend {
353                        backend: "csv".to_string(),
354                        message: "no known path for in-place save".to_string(),
355                    });
356                };
357                let mut file = File::create(path)?;
358                write_rect_csv(&mut file, opts, (sr, sc), (er, ec), |r, c| {
359                    self.sheet.cells.get(&(r, c)).cloned()
360                })?;
361                Ok(None)
362            }
363            SaveDestination::Path(path) => {
364                let mut file = File::create(path)?;
365                write_rect_csv(&mut file, opts, (sr, sc), (er, ec), |r, c| {
366                    self.sheet.cells.get(&(r, c)).cloned()
367                })?;
368                Ok(None)
369            }
370            SaveDestination::Writer(writer) => {
371                write_rect_csv(writer, opts, (sr, sc), (er, ec), |r, c| {
372                    self.sheet.cells.get(&(r, c)).cloned()
373                })?;
374                Ok(None)
375            }
376            SaveDestination::Bytes => {
377                let mut buf: Vec<u8> = Vec::new();
378                write_rect_csv(&mut buf, opts, (sr, sc), (er, ec), |r, c| {
379                    self.sheet.cells.get(&(r, c)).cloned()
380                })?;
381                Ok(Some(buf))
382            }
383        }
384    }
385}
386
387impl SpreadsheetReader for CsvAdapter {
388    type Error = IoError;
389
390    fn access_granularity(&self) -> AccessGranularity {
391        AccessGranularity::Workbook
392    }
393
394    fn capabilities(&self) -> BackendCaps {
395        self.caps.clone()
396    }
397
398    fn sheet_names(&self) -> Result<Vec<String>, Self::Error> {
399        Ok(vec![self.sheet_name.clone()])
400    }
401
402    fn open_path<P: AsRef<Path>>(path: P) -> Result<Self, Self::Error>
403    where
404        Self: Sized,
405    {
406        Self::open_path_with_options(path, CsvReadOptions::default())
407    }
408
409    fn open_reader(reader: Box<dyn Read + Send + Sync>) -> Result<Self, Self::Error>
410    where
411        Self: Sized,
412    {
413        Self::open_reader_with_options(reader, CsvReadOptions::default())
414    }
415
416    fn open_bytes(data: Vec<u8>) -> Result<Self, Self::Error>
417    where
418        Self: Sized,
419    {
420        Self::open_bytes_with_options(data, CsvReadOptions::default())
421    }
422
423    fn read_range(
424        &mut self,
425        sheet: &str,
426        start: (u32, u32),
427        end: (u32, u32),
428    ) -> Result<BTreeMap<(u32, u32), CellData>, Self::Error> {
429        if sheet != self.sheet_name {
430            return Ok(BTreeMap::new());
431        }
432        let mut out: BTreeMap<(u32, u32), CellData> = BTreeMap::new();
433        let (sr, sc) = start;
434        let (er, ec) = end;
435        for ((r, c), v) in self.sheet.cells.iter() {
436            if *r >= sr && *r <= er && *c >= sc && *c <= ec {
437                out.insert(
438                    (*r, *c),
439                    CellData {
440                        value: Some(v.clone()),
441                        formula: None,
442                        style: None,
443                    },
444                );
445            }
446        }
447        Ok(out)
448    }
449
450    fn read_sheet(&mut self, sheet: &str) -> Result<SheetData, Self::Error> {
451        if sheet != self.sheet_name {
452            return Ok(SheetData {
453                cells: BTreeMap::new(),
454                dimensions: None,
455                tables: vec![],
456                named_ranges: vec![],
457                date_system_1904: false,
458                merged_cells: vec![],
459                hidden: false,
460            });
461        }
462
463        let mut cells: BTreeMap<(u32, u32), CellData> = BTreeMap::new();
464        for (k, v) in self.sheet.cells.iter() {
465            cells.insert(
466                *k,
467                CellData {
468                    value: Some(v.clone()),
469                    formula: None,
470                    style: None,
471                },
472            );
473        }
474
475        Ok(SheetData {
476            cells,
477            dimensions: self.sheet.bounds(),
478            tables: vec![],
479            named_ranges: vec![],
480            date_system_1904: false,
481            merged_cells: vec![],
482            hidden: false,
483        })
484    }
485
486    fn sheet_bounds(&self, sheet: &str) -> Option<(u32, u32)> {
487        if sheet == self.sheet_name {
488            self.sheet.bounds()
489        } else {
490            None
491        }
492    }
493
494    fn is_loaded(&self, _sheet: &str, _row: Option<u32>, _col: Option<u32>) -> bool {
495        true
496    }
497}
498
499impl SpreadsheetWriter for CsvAdapter {
500    type Error = IoError;
501
502    fn write_cell(
503        &mut self,
504        sheet: &str,
505        row: u32,
506        col: u32,
507        data: CellData,
508    ) -> Result<(), Self::Error> {
509        if sheet != self.sheet_name {
510            return Err(IoError::Backend {
511                backend: "csv".to_string(),
512                message: format!("sheet not found: {sheet}"),
513            });
514        }
515        if data.formula.is_some() {
516            return Err(IoError::Unsupported {
517                feature: "formulas".to_string(),
518                context: "csv".to_string(),
519            });
520        }
521        if data.style.is_some() {
522            return Err(IoError::Unsupported {
523                feature: "styles".to_string(),
524                context: "csv".to_string(),
525            });
526        }
527
528        self.sheet.set_bounds(row, col);
529        match data.value {
530            None => {
531                self.sheet.cells.remove(&(row, col));
532            }
533            Some(LiteralValue::Empty) => {
534                self.sheet.cells.remove(&(row, col));
535            }
536            Some(v) => {
537                self.sheet.cells.insert((row, col), v);
538            }
539        }
540        Ok(())
541    }
542
543    fn write_range(
544        &mut self,
545        sheet: &str,
546        cells: BTreeMap<(u32, u32), CellData>,
547    ) -> Result<(), Self::Error> {
548        for ((r, c), d) in cells {
549            self.write_cell(sheet, r, c, d)?;
550        }
551        Ok(())
552    }
553
554    fn clear_range(
555        &mut self,
556        sheet: &str,
557        start: (u32, u32),
558        end: (u32, u32),
559    ) -> Result<(), Self::Error> {
560        if sheet != self.sheet_name {
561            return Ok(());
562        }
563        let (sr, sc) = start;
564        let (er, ec) = end;
565        let keys: Vec<(u32, u32)> = self
566            .sheet
567            .cells
568            .keys()
569            .copied()
570            .filter(|(r, c)| *r >= sr && *r <= er && *c >= sc && *c <= ec)
571            .collect();
572        for k in keys {
573            self.sheet.cells.remove(&k);
574        }
575        Ok(())
576    }
577
578    fn create_sheet(&mut self, name: &str) -> Result<(), Self::Error> {
579        if name == self.sheet_name {
580            return Ok(());
581        }
582        Err(IoError::Unsupported {
583            feature: "multiple sheets".to_string(),
584            context: "csv".to_string(),
585        })
586    }
587
588    fn delete_sheet(&mut self, name: &str) -> Result<(), Self::Error> {
589        if name == self.sheet_name {
590            self.sheet = CsvSheet::default();
591        }
592        Ok(())
593    }
594
595    fn rename_sheet(&mut self, old: &str, new: &str) -> Result<(), Self::Error> {
596        if old == self.sheet_name {
597            self.sheet_name = new.to_string();
598        }
599        Ok(())
600    }
601
602    fn flush(&mut self) -> Result<(), Self::Error> {
603        Ok(())
604    }
605
606    fn save_to<'a>(&mut self, dest: SaveDestination<'a>) -> Result<Option<Vec<u8>>, Self::Error> {
607        let sheet = self.sheet_name.clone();
608        let opts = self.write_options.clone();
609        self.write_sheet_to(&sheet, dest, opts)
610    }
611}
612
613// Stream CSV contents into the evaluation engine (values only).
614impl<R> formualizer_eval::engine::ingest::EngineLoadStream<R> for CsvAdapter
615where
616    R: formualizer_eval::traits::EvaluationContext,
617{
618    type Error = IoError;
619
620    fn stream_into_engine(
621        &mut self,
622        engine: &mut formualizer_eval::engine::Engine<R>,
623    ) -> Result<(), Self::Error> {
624        // CSV is values only; keep engine date system default.
625        let sheet_name = self.sheet_name.clone();
626        engine
627            .add_sheet(&sheet_name)
628            .map_err(|e| IoError::from_backend("csv", e))?;
629
630        let Some((rows_u32, cols_u32)) = self.sheet.bounds() else {
631            return Ok(());
632        };
633        let rows = rows_u32 as usize;
634        let cols = cols_u32 as usize;
635
636        let chunk_rows: usize = 32 * 1024;
637        let mut aib = formualizer_eval::arrow_store::IngestBuilder::new(
638            &sheet_name,
639            cols,
640            chunk_rows,
641            engine.config.date_system,
642        );
643
644        for r0 in 0..rows {
645            let r = (r0 as u32) + 1;
646            let mut row_vals: Vec<LiteralValue> = vec![LiteralValue::Empty; cols];
647            for (c0, val) in row_vals.iter_mut().enumerate().take(cols) {
648                let c = (c0 as u32) + 1;
649                if let Some(v) = self.sheet.cells.get(&(r, c)) {
650                    *val = v.clone();
651                }
652            }
653            aib.append_row(&row_vals)
654                .map_err(|e| IoError::from_backend("csv", e))?;
655        }
656
657        let asheet = aib.finish();
658        let store = engine.sheet_store_mut();
659        if let Some(pos) = store
660            .sheets
661            .iter()
662            .position(|s| s.name.as_ref() == sheet_name)
663        {
664            store.sheets[pos] = asheet;
665        } else {
666            store.sheets.push(asheet);
667        }
668        engine.finalize_sheet_index(&sheet_name);
669        engine.set_first_load_assume_new(false);
670        engine.reset_ensure_touched();
671        Ok(())
672    }
673}
674
675/// Export a workbook sheet as CSV.
676///
677/// Notes:
678/// - Uses the workbook's current stored values (after evaluation/overlays).
679/// - The exported rectangle is `1..=rows` x `1..=cols` based on `Workbook::sheet_dimensions`.
680pub fn write_workbook_sheet_to_path(
681    wb: &crate::Workbook,
682    sheet: &str,
683    path: impl AsRef<Path>,
684    opts: CsvWriteOptions,
685) -> Result<(), IoError> {
686    let Some((rows, cols)) = wb.sheet_dimensions(sheet) else {
687        let _ = File::create(path.as_ref())?;
688        return Ok(());
689    };
690    let addr = crate::RangeAddress::new(sheet.to_string(), 1, 1, rows, cols).map_err(|e| {
691        IoError::Backend {
692            backend: "csv".to_string(),
693            message: e.to_string(),
694        }
695    })?;
696    write_workbook_range_to_path(wb, &addr, path, opts)
697}
698
699/// Export a workbook range as CSV.
700pub fn write_workbook_range_to_path(
701    wb: &crate::Workbook,
702    addr: &crate::RangeAddress,
703    path: impl AsRef<Path>,
704    opts: CsvWriteOptions,
705) -> Result<(), IoError> {
706    let values = wb.read_range(addr);
707    let mut file = File::create(path.as_ref())?;
708    write_values_csv(&mut file, opts, &values)
709}
710
711/// Export a workbook range as CSV bytes.
712pub fn write_workbook_range_to_bytes(
713    wb: &crate::Workbook,
714    addr: &crate::RangeAddress,
715    opts: CsvWriteOptions,
716) -> Result<Vec<u8>, IoError> {
717    let values = wb.read_range(addr);
718    let mut buf: Vec<u8> = Vec::new();
719    write_values_csv(&mut buf, opts, &values)?;
720    Ok(buf)
721}
722
723fn infer_field(field: &str, mode: CsvTypeInference, force_text: bool) -> Option<LiteralValue> {
724    if field.is_empty() {
725        return None;
726    }
727    if force_text || mode == CsvTypeInference::Off {
728        return Some(LiteralValue::Text(field.to_string()));
729    }
730
731    if let Some(b) = parse_bool(field) {
732        return Some(LiteralValue::Boolean(b));
733    }
734    if let Some(i) = parse_unambiguous_i64(field) {
735        return Some(LiteralValue::Int(i));
736    }
737    if let Some(n) = parse_unambiguous_f64(field) {
738        return Some(LiteralValue::Number(n));
739    }
740    if mode == CsvTypeInference::BasicWithDates {
741        if let Some(d) = parse_date(field) {
742            return Some(LiteralValue::Date(d));
743        }
744        if let Some(dt) = parse_datetime(field) {
745            return Some(LiteralValue::DateTime(dt));
746        }
747    }
748    Some(LiteralValue::Text(field.to_string()))
749}
750
751fn parse_bool(s: &str) -> Option<bool> {
752    if s.eq_ignore_ascii_case("true") {
753        Some(true)
754    } else if s.eq_ignore_ascii_case("false") {
755        Some(false)
756    } else {
757        None
758    }
759}
760
761fn parse_unambiguous_i64(s: &str) -> Option<i64> {
762    // Conservative: reject leading zeros (except exactly "0" or "-0").
763    let bytes = s.as_bytes();
764    if bytes.is_empty() {
765        return None;
766    }
767    let (sign, digits) = match bytes[0] {
768        b'+' => (1i64, &s[1..]),
769        b'-' => (-1i64, &s[1..]),
770        _ => (1i64, s),
771    };
772    if digits.is_empty() {
773        return None;
774    }
775    if digits.len() > 1 && digits.starts_with('0') {
776        return None;
777    }
778    if !digits.as_bytes().iter().all(|b| b.is_ascii_digit()) {
779        return None;
780    }
781    let parsed: i64 = digits.parse().ok()?;
782    Some(sign * parsed)
783}
784
785fn parse_unambiguous_f64(s: &str) -> Option<f64> {
786    // Only consider float if it actually looks like one (contains '.' or exponent).
787    if !(s.contains('.') || s.contains('e') || s.contains('E')) {
788        return None;
789    }
790    // Reject leading zeros like "01.2" (conservative).
791    let s2 = s.strip_prefix('+').unwrap_or(s);
792    let s2 = s2.strip_prefix('-').unwrap_or(s2);
793    if s2.len() > 1 && s2.starts_with('0') && !s2.starts_with("0.") {
794        return None;
795    }
796    let n: f64 = s.parse().ok()?;
797    if !n.is_finite() {
798        return None;
799    }
800    Some(n)
801}
802
803fn parse_date(s: &str) -> Option<chrono::NaiveDate> {
804    chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d").ok()
805}
806
807fn parse_datetime(s: &str) -> Option<chrono::NaiveDateTime> {
808    chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S")
809        .or_else(|_| chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S"))
810        .ok()
811}
812
813fn csv_terminator(nl: CsvNewline) -> csv::Terminator {
814    match nl {
815        CsvNewline::Lf => csv::Terminator::Any(b'\n'),
816        CsvNewline::Crlf => csv::Terminator::CRLF,
817    }
818}
819
820fn csv_quote_style(q: CsvQuoteStyle) -> csv::QuoteStyle {
821    match q {
822        CsvQuoteStyle::Necessary => csv::QuoteStyle::Necessary,
823        CsvQuoteStyle::Always => csv::QuoteStyle::Always,
824        CsvQuoteStyle::Never => csv::QuoteStyle::Never,
825        CsvQuoteStyle::NonNumeric => csv::QuoteStyle::NonNumeric,
826    }
827}
828
829fn write_rect_csv<W: Write + ?Sized>(
830    writer: &mut W,
831    opts: CsvWriteOptions,
832    start: (u32, u32),
833    end: (u32, u32),
834    mut get: impl FnMut(u32, u32) -> Option<LiteralValue>,
835) -> Result<(), IoError> {
836    let mut wb = csv::WriterBuilder::new();
837    wb.delimiter(opts.delimiter)
838        .terminator(csv_terminator(opts.newline))
839        .quote_style(csv_quote_style(opts.quote_style));
840    let mut wtr = wb.from_writer(writer);
841
842    let (sr, sc) = start;
843    let (er, ec) = end;
844    for r in sr..=er {
845        let mut record: Vec<String> = Vec::with_capacity((ec - sc + 1) as usize);
846        for c in sc..=ec {
847            let s = match get(r, c) {
848                Some(v) => literal_to_csv_field(&v, &opts)?,
849                None => String::new(),
850            };
851            record.push(s);
852        }
853        wtr.write_record(record)
854            .map_err(|e| IoError::from_backend("csv", e))?;
855    }
856    wtr.flush().map_err(|e| IoError::from_backend("csv", e))?;
857    Ok(())
858}
859
860fn write_values_csv<W: Write>(
861    writer: &mut W,
862    opts: CsvWriteOptions,
863    values: &[Vec<LiteralValue>],
864) -> Result<(), IoError> {
865    let mut wb = csv::WriterBuilder::new();
866    wb.delimiter(opts.delimiter)
867        .terminator(csv_terminator(opts.newline))
868        .quote_style(csv_quote_style(opts.quote_style));
869    let mut wtr = wb.from_writer(writer);
870    for row in values {
871        let record: Vec<String> = row
872            .iter()
873            .map(|v| literal_to_csv_field(v, &opts))
874            .collect::<Result<Vec<_>, IoError>>()?;
875        wtr.write_record(record)
876            .map_err(|e| IoError::from_backend("csv", e))?;
877    }
878    wtr.flush().map_err(|e| IoError::from_backend("csv", e))?;
879    Ok(())
880}
881
882fn literal_to_csv_field(v: &LiteralValue, opts: &CsvWriteOptions) -> Result<String, IoError> {
883    literal_to_csv_field_inner(v, opts, 0)
884}
885
886fn literal_to_csv_field_inner(
887    v: &LiteralValue,
888    opts: &CsvWriteOptions,
889    depth: u8,
890) -> Result<String, IoError> {
891    if depth > 4 {
892        return Err(IoError::Backend {
893            backend: "csv".to_string(),
894            message: "Array nesting too deep for CSV export".to_string(),
895        });
896    }
897
898    Ok(match v {
899        LiteralValue::Empty => String::new(),
900        LiteralValue::Text(s) => s.clone(),
901        LiteralValue::Int(i) => i.to_string(),
902        LiteralValue::Number(n) => n.to_string(),
903        LiteralValue::Boolean(b) => {
904            if *b {
905                "TRUE".to_string()
906            } else {
907                "FALSE".to_string()
908            }
909        }
910        LiteralValue::Date(d) => d.format("%Y-%m-%d").to_string(),
911        LiteralValue::DateTime(dt) => dt.format("%Y-%m-%d %H:%M:%S").to_string(),
912        LiteralValue::Time(t) => t.format("%H:%M:%S").to_string(),
913        LiteralValue::Duration(d) => d.num_seconds().to_string(),
914        LiteralValue::Error(e) => e.kind.to_string(),
915        LiteralValue::Pending => "Pending".to_string(),
916        LiteralValue::Array(a) => match opts.array_policy {
917            CsvArrayPolicy::Error => {
918                return Err(IoError::Backend {
919                    backend: "csv".to_string(),
920                    message: "Cannot export array value to CSV (array_policy=Error)".to_string(),
921                });
922            }
923            CsvArrayPolicy::Blank => String::new(),
924            CsvArrayPolicy::TopLeft => {
925                if let Some(row0) = a.first()
926                    && let Some(v0) = row0.first()
927                {
928                    return literal_to_csv_field_inner(v0, opts, depth + 1);
929                }
930                String::new()
931            }
932        },
933    })
934}