Skip to main content

akar_storage/
csv_reader.rs

1//! CSV reader for the COPY FROM command.
2//!
3//! Parses CSV files and coerces string values to Akar `Value` types
4//! based on a provided schema (column names + types from the catalog).
5//!
6//! Supports: delimiter, header detection, quoting, escaping,
7//! null handling, and type coercion with detailed error messages.
8
9use akar_catalog::CatalogColumn;
10use akar_common::types::{Date, Interval, LogicalTypeID, Timestamp, Value};
11use std::collections::HashMap;
12
13/// Configuration for reading a CSV file.
14#[derive(Debug, Clone)]
15pub struct CsvReaderConfig {
16    /// Field delimiter character (default: `,`).
17    pub delimiter: u8,
18    /// Whether the first row is a header row (default: true).
19    pub has_header: bool,
20    /// Quote character (default: `"`).
21    pub quote: u8,
22    /// Escape character (default: `\\`).
23    pub escape: u8,
24    /// String representation of NULL values (default: `""` — empty string).
25    pub null_str: String,
26}
27
28impl Default for CsvReaderConfig {
29    fn default() -> Self {
30        Self {
31            delimiter: b',',
32            has_header: true,
33            quote: b'"',
34            escape: b'\\',
35            null_str: String::new(),
36        }
37    }
38}
39
40impl CsvReaderConfig {
41    /// Build a config from a `HashMap<String, String>` of COPY options.
42    ///
43    /// Supported keys: `HEADER`, `DELIM` (or `DELIMITER`), `QUOTE`, `ESCAPE`, `NULL`.
44    pub fn from_options(options: &HashMap<String, String>) -> Self {
45        let mut config = Self::default();
46
47        if let Some(d) = options.get("HEADER").or_else(|| options.get("header")) {
48            config.has_header = d.eq_ignore_ascii_case("true");
49        }
50
51        if let Some(d) = options
52            .get("DELIM")
53            .or_else(|| options.get("delim"))
54            .or_else(|| options.get("DELIMITER"))
55            && let Some(c) = d.chars().next()
56        {
57            config.delimiter = c as u8;
58        }
59
60        if let Some(q) = options.get("QUOTE").or_else(|| options.get("quote"))
61            && let Some(c) = q.chars().next()
62        {
63            config.quote = c as u8;
64        }
65
66        if let Some(e) = options.get("ESCAPE").or_else(|| options.get("escape"))
67            && let Some(c) = e.chars().next()
68        {
69            config.escape = c as u8;
70        }
71
72        if let Some(n) = options.get("NULL").or_else(|| options.get("null")) {
73            config.null_str = n.clone();
74        }
75
76        config
77    }
78}
79
80/// Error type for CSV reader operations.
81#[derive(Debug)]
82pub enum CsvReaderError {
83    /// I/O error (file not found, permission denied, etc.).
84    IoError(std::io::Error),
85    /// CSV format error from the parser.
86    CsvError(String),
87    /// Type coercion failure (e.g. "abc" cannot be parsed as Int64).
88    TypeCoercion {
89        line: usize,
90        column: usize,
91        column_name: String,
92        value: String,
93        expected_type: String,
94        message: String,
95    },
96    /// Row has a different number of columns than the schema expects.
97    ColumnCountMismatch {
98        line: usize,
99        expected: usize,
100        actual: usize,
101    },
102}
103
104impl std::fmt::Display for CsvReaderError {
105    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
106        match self {
107            CsvReaderError::IoError(e) => write!(f, "IO error: {e}"),
108            CsvReaderError::CsvError(e) => write!(f, "CSV error: {e}"),
109            CsvReaderError::TypeCoercion {
110                line,
111                column,
112                column_name,
113                value,
114                expected_type,
115                message,
116            } => write!(
117                f,
118                "Type coercion error at line {line}, column {column} ('{column_name}'): \
119                 cannot coerce '{value}' to {expected_type}: {message}"
120            ),
121            CsvReaderError::ColumnCountMismatch { line, expected, actual } => write!(
122                f,
123                "Column count mismatch at line {line}: expected {expected} columns, got {actual}"
124            ),
125        }
126    }
127}
128
129impl std::error::Error for CsvReaderError {}
130
131/// Result alias for CSV reader operations.
132pub type CsvResult<T> = Result<T, CsvReaderError>;
133
134/// Read a CSV file and coerce string values to Akar `Value`s matching the schema.
135///
136/// # Arguments
137///
138/// * `path` - Path to the CSV file.
139/// * `columns` - Column schema (name + type) from the catalog.
140/// * `config` - CSV reader configuration.
141///
142/// # Returns
143///
144/// A vector of rows, where each row is a `Vec<Value>` with length equal to
145/// `columns.len()`.
146///
147/// # Errors
148///
149/// Returns `CsvReaderError` on I/O errors, CSV parse errors, column count
150/// mismatches, or type coercion failures.
151pub fn read_csv(
152    path: &str,
153    vfs: &akar_common::file_system::VirtualFileSystemRegistry,
154    columns: &[CatalogColumn],
155    config: &CsvReaderConfig,
156) -> CsvResult<Vec<Vec<Value>>> {
157    let file = vfs.open_read(path).map_err(CsvReaderError::IoError)?;
158    let mut reader = std::io::BufReader::new(file);
159
160    let mut raw_reader = csv::ReaderBuilder::new()
161        .delimiter(config.delimiter)
162        .has_headers(config.has_header)
163        .quote(config.quote)
164        .escape(Some(config.escape))
165        .flexible(true)
166        .from_reader(&mut reader);
167
168    // Get header / column names
169    let headers: Vec<String> = if config.has_header {
170        raw_reader
171            .headers()
172            .map_err(|e| CsvReaderError::CsvError(e.to_string()))?
173            .iter()
174            .map(|h| h.to_string())
175            .collect()
176    } else {
177        columns.iter().map(|c| c.name.clone()).collect()
178    };
179
180    // Validate column count
181    if headers.len() != columns.len() {
182        return Err(CsvReaderError::ColumnCountMismatch {
183            line: 1,
184            expected: columns.len(),
185            actual: headers.len(),
186        });
187    }
188
189    let mut results = Vec::new();
190    let start_line = if config.has_header { 2 } else { 1 };
191
192    for (line_number, result) in (start_line..).zip(raw_reader.records()) {
193        let record = result.map_err(|e| CsvReaderError::CsvError(format!("Line {line_number}: {e}")))?;
194
195        if record.len() != columns.len() {
196            return Err(CsvReaderError::ColumnCountMismatch {
197                line: line_number,
198                expected: columns.len(),
199                actual: record.len(),
200            });
201        }
202
203        let mut row = Vec::with_capacity(columns.len());
204        for (col_idx, field) in record.iter().enumerate() {
205            let col = &columns[col_idx];
206            let value = coerce_string_to_value(
207                field,
208                col.logical_type,
209                line_number,
210                col_idx,
211                &col.name,
212                &config.null_str,
213            )?;
214            row.push(value);
215        }
216
217        results.push(row);
218    }
219
220    Ok(results)
221}
222
223// ─── Type coercion ──────────────────────────────────────────────────────────────
224
225/// Coerce a raw CSV string field to a `Value` of the target `LogicalTypeID`.
226fn coerce_string_to_value(
227    field: &str,
228    target_type: LogicalTypeID,
229    line: usize,
230    column: usize,
231    column_name: &str,
232    null_str: &str,
233) -> CsvResult<Value> {
234    let trimmed = field.trim();
235
236    // Handle NULL / empty
237    if trimmed == null_str || trimmed.eq_ignore_ascii_case("null") {
238        return Ok(Value::Null);
239    }
240
241    match target_type {
242        LogicalTypeID::Bool => coerce_bool(trimmed, line, column, column_name),
243        LogicalTypeID::Int64 | LogicalTypeID::Serial => {
244            coerce_parse(trimmed, line, column, column_name, "INT64").map(Value::Int64)
245        }
246        LogicalTypeID::Int32 => coerce_parse(trimmed, line, column, column_name, "INT32").map(Value::Int32),
247        LogicalTypeID::Int16 => coerce_parse(trimmed, line, column, column_name, "INT16").map(Value::Int16),
248        LogicalTypeID::Int8 => coerce_parse(trimmed, line, column, column_name, "INT8").map(Value::Int8),
249        LogicalTypeID::UInt64 => coerce_parse(trimmed, line, column, column_name, "UINT64").map(Value::UInt64),
250        LogicalTypeID::UInt32 => coerce_parse(trimmed, line, column, column_name, "UINT32").map(Value::UInt32),
251        LogicalTypeID::UInt16 => coerce_parse(trimmed, line, column, column_name, "UINT16").map(Value::UInt16),
252        LogicalTypeID::UInt8 => coerce_parse(trimmed, line, column, column_name, "UINT8").map(Value::UInt8),
253        LogicalTypeID::Double => coerce_parse::<f64>(trimmed, line, column, column_name, "DOUBLE").map(Value::Double),
254        LogicalTypeID::Float => coerce_parse::<f32>(trimmed, line, column, column_name, "FLOAT").map(Value::Float),
255        LogicalTypeID::String => Ok(Value::String(trimmed.to_string())),
256        LogicalTypeID::Date => coerce_date(trimmed, line, column, column_name),
257        LogicalTypeID::Timestamp | LogicalTypeID::TimestampMs => coerce_timestamp(trimmed, line, column, column_name),
258        LogicalTypeID::TimestampSec => coerce_timestamp_sec(trimmed, line, column, column_name),
259        LogicalTypeID::TimestampNs => coerce_timestamp_ns(trimmed, line, column, column_name),
260        LogicalTypeID::TimestampTz => coerce_timestamp_tz(trimmed, line, column, column_name),
261        LogicalTypeID::Interval => coerce_interval(trimmed, line, column, column_name),
262        LogicalTypeID::Blob => Ok(Value::Blob(parse_blob(trimmed))),
263        LogicalTypeID::List => Ok(Value::List(parse_list(trimmed, null_str))),
264        LogicalTypeID::Map => Ok(Value::Map(parse_map(trimmed, null_str))),
265        LogicalTypeID::Struct | LogicalTypeID::Node | LogicalTypeID::Rel => {
266            Ok(Value::Struct(parse_struct(trimmed, null_str)))
267        }
268        // Fallback: keep as string
269        _ => Ok(Value::String(trimmed.to_string())),
270    }
271}
272
273/// Coerce a string to a boolean.
274fn coerce_bool(s: &str, line: usize, column: usize, column_name: &str) -> CsvResult<Value> {
275    match s.to_lowercase().as_str() {
276        "true" | "1" | "yes" | "t" => Ok(Value::Bool(true)),
277        "false" | "0" | "no" | "f" => Ok(Value::Bool(false)),
278        other => Err(CsvReaderError::TypeCoercion {
279            line,
280            column,
281            column_name: column_name.to_string(),
282            value: other.to_string(),
283            expected_type: "BOOL".into(),
284            message: "expected true/false, 1/0, yes/no, or t/f".into(),
285        }),
286    }
287}
288
289/// Parse a numeric field via `str::parse`, returning a type-coercion error on failure.
290fn coerce_parse<T: std::str::FromStr>(
291    s: &str,
292    line: usize,
293    column: usize,
294    column_name: &str,
295    type_name: &str,
296) -> CsvResult<T> {
297    s.parse::<T>().map_err(|_| CsvReaderError::TypeCoercion {
298        line,
299        column,
300        column_name: column_name.to_string(),
301        value: s.to_string(),
302        expected_type: type_name.to_string(),
303        message: format!("cannot parse '{s}' as {type_name}"),
304    })
305}
306
307/// Parse a date string in `YYYY-MM-DD` format.
308fn coerce_date(s: &str, line: usize, column: usize, column_name: &str) -> CsvResult<Value> {
309    // Accept formats: YYYY-MM-DD or YYYY-M-D
310    let parts: Vec<&str> = s.split('-').collect();
311    if parts.len() != 3 {
312        return Err(coercion_err(
313            s,
314            line,
315            column,
316            column_name,
317            "DATE",
318            "expected YYYY-MM-DD format",
319        ));
320    }
321    let year: i32 = parts[0]
322        .parse()
323        .map_err(|_| coercion_err(s, line, column, column_name, "DATE", "invalid year"))?;
324    let month: u32 = parts[1]
325        .parse()
326        .map_err(|_| coercion_err(s, line, column, column_name, "DATE", "invalid month"))?;
327    let day: u32 = parts[2]
328        .parse()
329        .map_err(|_| coercion_err(s, line, column, column_name, "DATE", "invalid day"))?;
330
331    // Simple days-since-epoch calculation (from 1970-01-01)
332    let days = naive_date_to_epoch_days(year, month, day)
333        .ok_or_else(|| coercion_err(s, line, column, column_name, "DATE", "invalid calendar date"))?;
334
335    Ok(Value::Date(Date::from_days_since_epoch(days)))
336}
337
338/// Parse a timestamp string in `YYYY-MM-DD HH:MM:SS[.fraction]` format.
339fn coerce_timestamp(s: &str, line: usize, column: usize, column_name: &str) -> CsvResult<Value> {
340    let ts = parse_timestamp_micros(s).ok_or_else(|| {
341        coercion_err(
342            s,
343            line,
344            column,
345            column_name,
346            "TIMESTAMP",
347            "expected YYYY-MM-DD HH:MM:SS[.ffffff] format",
348        )
349    })?;
350    Ok(Value::Timestamp(Timestamp::from_micros_since_epoch(ts)))
351}
352
353/// Parse a timestamp in seconds resolution.
354fn coerce_timestamp_sec(s: &str, line: usize, column: usize, column_name: &str) -> CsvResult<Value> {
355    let micros = parse_timestamp_micros(s).ok_or_else(|| {
356        coercion_err(
357            s,
358            line,
359            column,
360            column_name,
361            "TIMESTAMP_SEC",
362            "expected YYYY-MM-DD HH:MM:SS[.ffffff] format",
363        )
364    })?;
365    Ok(Value::TimestampSec(Timestamp(micros / 1_000_000)))
366}
367
368/// Parse a timestamp in nanoseconds resolution.
369fn coerce_timestamp_ns(s: &str, line: usize, column: usize, column_name: &str) -> CsvResult<Value> {
370    let micros = parse_timestamp_micros(s).ok_or_else(|| {
371        coercion_err(
372            s,
373            line,
374            column,
375            column_name,
376            "TIMESTAMP_NS",
377            "expected YYYY-MM-DD HH:MM:SS[.ffffff] format",
378        )
379    })?;
380    // Convert micros to nanos (multiply by 1000)
381    Ok(Value::TimestampNs(Timestamp(micros * 1000)))
382}
383
384/// Parse a timestamp with timezone.
385fn coerce_timestamp_tz(s: &str, line: usize, column: usize, column_name: &str) -> CsvResult<Value> {
386    let micros = parse_timestamp_micros(s).ok_or_else(|| {
387        coercion_err(
388            s,
389            line,
390            column,
391            column_name,
392            "TIMESTAMP_TZ",
393            "expected YYYY-MM-DD HH:MM:SS[.ffffff] format",
394        )
395    })?;
396    Ok(Value::TimestampTz(akar_common::types::TimestampTZ(micros)))
397}
398
399/// Parse an interval string like "1 year 2 months 3 days 4 hours 5 minutes 6 seconds".
400fn coerce_interval(s: &str, line: usize, column: usize, column_name: &str) -> CsvResult<Value> {
401    match parse_interval_str(s) {
402        Some(iv) => Ok(Value::Interval(iv)),
403        None => Err(coercion_err(
404            s,
405            line,
406            column,
407            column_name,
408            "INTERVAL",
409            "expected duration format (e.g. '1 year 2 months 3 days 4 hours 5 minutes 6 seconds')",
410        )),
411    }
412}
413
414// ─── Helper: coercion error builder ─────────────────────────────────────────────
415
416fn coercion_err(
417    value: &str,
418    line: usize,
419    column: usize,
420    column_name: &str,
421    expected_type: &str,
422    message: &str,
423) -> CsvReaderError {
424    CsvReaderError::TypeCoercion {
425        line,
426        column,
427        column_name: column_name.to_string(),
428        value: value.to_string(),
429        expected_type: expected_type.to_string(),
430        message: message.to_string(),
431    }
432}
433
434// ─── Date helper ────────────────────────────────────────────────────────────────
435
436/// Convert a calendar date to days since Unix epoch (1970-01-01).
437/// Returns `None` for invalid dates.
438fn naive_date_to_epoch_days(year: i32, month: u32, day: u32) -> Option<i32> {
439    if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
440        return None;
441    }
442
443    // Days from 1970-01-01 = days from 0000-03-01 to year-month-day - days from 0000-03-01 to 1970-01-01
444    // Using the algorithm from C++ chrono / Howard Hinnant's date library
445    let (y, m) = if month <= 2 {
446        (year as i64 - 1, month as i64 + 12)
447    } else {
448        (year as i64, month as i64)
449    };
450    let era = if y >= 0 { y } else { y - 399 } / 400;
451    let yoe = y - era * 400;
452    let doy = (153 * (m - 3) + 2) / 5 + day as i64;
453    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
454
455    // Days from 0000-03-01 to 1970-01-01 (the Unix epoch in March-based calendar).
456    // Computed as: 4 * 146097 + (369*365 + 369/4 - 369/100 + 307) = 719469
457    const EPOCH_OFFSET: i64 = 719469;
458
459    let days = era * 146097 + doe - EPOCH_OFFSET;
460    Some(days as i32)
461}
462
463// ─── Timestamp helper ───────────────────────────────────────────────────────────
464
465/// Parse `YYYY-MM-DD HH:MM:SS[.fraction]` string to microseconds since epoch.
466fn parse_timestamp_micros(s: &str) -> Option<i64> {
467    let s = s.trim();
468    // Split date and time parts
469    let (date_part, time_part) = if let Some(space_idx) = s.find(' ') {
470        (&s[..space_idx], &s[space_idx + 1..])
471    } else {
472        // Date only — treat as start of day
473        (s, "00:00:00")
474    };
475
476    // Parse date
477    let date_parts: Vec<&str> = date_part.split('-').collect();
478    if date_parts.len() != 3 {
479        return None;
480    }
481    let year: i32 = date_parts[0].parse().ok()?;
482    let month: u32 = date_parts[1].parse().ok()?;
483    let day: u32 = date_parts[2].parse().ok()?;
484    let epoch_days = naive_date_to_epoch_days(year, month, day)?;
485
486    // Parse time
487    let time_parts: Vec<&str> = time_part.split(':').collect();
488    if time_parts.len() < 2 || time_parts.len() > 3 {
489        return None;
490    }
491    let hour: u32 = time_parts[0].parse().ok()?;
492    let minute: u32 = time_parts[1].parse().ok()?;
493
494    let (second, micros) = if time_parts.len() == 3 {
495        let sec_str = time_parts[2];
496        if let Some(dot_idx) = sec_str.find('.') {
497            let sec: u32 = sec_str[..dot_idx].parse().ok()?;
498            let frac_str = &sec_str[dot_idx + 1..];
499            // Pad/truncate to 6 digits (microseconds)
500            let mut frac = [0u8; 6];
501            for (i, ch) in frac_str.chars().enumerate() {
502                if i >= 6 {
503                    break;
504                }
505                if ch.is_ascii_digit() {
506                    frac[i] = ch as u8 - b'0';
507                } else {
508                    return None;
509                }
510            }
511            let micros = frac.iter().fold(0u64, |acc, &d| acc * 10 + d as u64);
512            (sec, micros)
513        } else {
514            (sec_str.parse().ok()?, 0)
515        }
516    } else {
517        (0, 0)
518    };
519
520    let total_micros = epoch_days as i64 * 86_400_000_000i64
521        + hour as i64 * 3_600_000_000i64
522        + minute as i64 * 60_000_000i64
523        + second as i64 * 1_000_000i64
524        + micros as i64;
525
526    Some(total_micros)
527}
528
529// ─── Interval helper ────────────────────────────────────────────────────────────
530
531/// Parse a human-readable interval string.
532///
533/// Supports: `X years`, `X months`, `X days`, `X hours`, `X minutes`,
534/// `X seconds`, `X milliseconds`, `X microseconds`, `X us`.
535/// Components are space-separated (e.g. "1 year 2 months 3 days").
536fn parse_interval_str(s: &str) -> Option<Interval> {
537    let mut months: i32 = 0;
538    let mut days: i32 = 0;
539    let mut micros: i64 = 0;
540
541    let s = s.trim().to_lowercase();
542    // Split on whitespace, process pairs
543    let tokens: Vec<&str> = s.split_whitespace().collect();
544    let mut i = 0;
545    while i + 1 < tokens.len() {
546        let value: i64 = tokens[i].parse().ok()?;
547        let unit = tokens[i + 1];
548        match unit {
549            u if u.starts_with("year") => months += value as i32 * 12,
550            u if u.starts_with("month") => months += value as i32,
551            u if u.starts_with("day") => days += value as i32,
552            u if u.starts_with("hour") => micros += value * 3_600_000_000,
553            u if u.starts_with("minute") => micros += value * 60_000_000,
554            u if u.starts_with("second") && !unit.contains("milli") && !unit.contains("micro") => {
555                micros += value * 1_000_000;
556            }
557            u if u.starts_with("millisecond") => micros += value * 1_000,
558            u if u.starts_with("microsecond") || u == "us" => micros += value,
559            _ => {
560                // Unknown unit — skip
561            }
562        }
563        i += 2;
564    }
565
566    Some(Interval::new(months, days, micros))
567}
568
569// ─── Blob helper ────────────────────────────────────────────────────────────────
570
571/// Parse a blob from hex format.
572///
573/// Akar blob format: `\xHH\xHH...` where HH is a hex byte,
574/// or just a plain ASCII string if no hex escapes are present.
575fn parse_blob(s: &str) -> Vec<u8> {
576    let s = s.trim();
577    if s.is_empty() {
578        return Vec::new();
579    }
580
581    // Check if this is a hex-encoded blob (contains \x)
582    let mut result = Vec::new();
583    let mut chars = s.chars().peekable();
584
585    while let Some(ch) = chars.next() {
586        if ch == '\\' && chars.peek() == Some(&'x') {
587            // Hex escape: \xHH
588            chars.next(); // consume 'x'
589            let hex_str: String = chars.by_ref().take(2).collect();
590            if hex_str.len() == 2 {
591                if let Ok(byte) = u8::from_str_radix(&hex_str, 16) {
592                    result.push(byte);
593                } else {
594                    // Invalid hex — push literal
595                    result.push(b'\\');
596                    result.push(b'x');
597                    result.extend_from_slice(hex_str.as_bytes());
598                }
599            } else {
600                result.push(b'\\');
601                result.push(b'x');
602                result.extend_from_slice(hex_str.as_bytes());
603            }
604        } else {
605            // Plain ASCII character
606            let mut buf = [0u8; 4];
607            let encoded = ch.encode_utf8(&mut buf);
608            result.extend_from_slice(encoded.as_bytes());
609        }
610    }
611
612    result
613}
614
615// ─── List helper ────────────────────────────────────────────────────────────────
616
617/// Parse a list in `[item1, item2, ...]` format.
618///
619/// Items are coerced to `Value::String` for now (no recursive type inference).
620fn parse_list(s: &str, null_str: &str) -> Vec<Value> {
621    let s = s.trim();
622    if s.is_empty() || s == "[]" {
623        return Vec::new();
624    }
625
626    // Strip surrounding brackets
627    let inner = if s.starts_with('[') && s.ends_with(']') {
628        &s[1..s.len() - 1]
629    } else {
630        s
631    };
632
633    if inner.trim().is_empty() {
634        return Vec::new();
635    }
636
637    // Split by comma, respecting quoted strings
638    split_csv_respecting_quotes(inner, ',')
639        .into_iter()
640        .map(|item| {
641            let trimmed = item.trim();
642            if trimmed == null_str || trimmed.eq_ignore_ascii_case("null") {
643                Value::Null
644            } else {
645                Value::String(trimmed.to_string())
646            }
647        })
648        .collect()
649}
650
651// ─── Struct helper ──────────────────────────────────────────────────────────────
652
653/// Parse a struct in `{key1: value1, key2: value2, ...}` format.
654///
655/// Values are coerced to `Value::String` for now.
656fn parse_struct(s: &str, null_str: &str) -> Vec<(String, Value)> {
657    let s = s.trim();
658    if s.is_empty() || s == "{}" {
659        return Vec::new();
660    }
661
662    // Strip surrounding braces
663    let inner = if s.starts_with('{') && s.ends_with('}') {
664        &s[1..s.len() - 1]
665    } else {
666        s
667    };
668
669    if inner.trim().is_empty() {
670        return Vec::new();
671    }
672
673    // Split top-level fields by comma, respecting nested braces/quotes
674    split_top_level(inner, ',')
675        .into_iter()
676        .filter_map(|field| {
677            let trimmed = field.trim();
678            if trimmed.is_empty() {
679                return None;
680            }
681            // Split on first ':'
682            if let Some(colon_idx) = trimmed.find(':') {
683                let key = trimmed[..colon_idx].trim().to_string();
684                let val_str = trimmed[colon_idx + 1..].trim();
685                let value = if val_str == null_str || val_str.eq_ignore_ascii_case("null") {
686                    Value::Null
687                } else {
688                    Value::String(val_str.to_string())
689                };
690                Some((key, value))
691            } else {
692                Some((trimmed.to_string(), Value::Null))
693            }
694        })
695        .collect()
696}
697
698// ─── Map helper ─────────────────────────────────────────────────────────────────
699
700/// Parse a map in `{key1=value1, key2=value2, ...}` format.
701///
702/// Akar uses `=` as key-value separator for maps (vs `:` for structs).
703fn parse_map(s: &str, null_str: &str) -> Vec<(Value, Value)> {
704    let s = s.trim();
705    if s.is_empty() || s == "{}" {
706        return Vec::new();
707    }
708
709    // Strip surrounding braces
710    let inner = if s.starts_with('{') && s.ends_with('}') {
711        &s[1..s.len() - 1]
712    } else {
713        s
714    };
715
716    if inner.trim().is_empty() {
717        return Vec::new();
718    }
719
720    split_top_level(inner, ',')
721        .into_iter()
722        .filter_map(|field| {
723            let trimmed = field.trim();
724            if trimmed.is_empty() {
725                return None;
726            }
727            // Split on first '='
728            if let Some(eq_idx) = trimmed.find('=') {
729                let key_str = trimmed[..eq_idx].trim();
730                let val_str = trimmed[eq_idx + 1..].trim();
731                let key = if key_str == null_str || key_str.eq_ignore_ascii_case("null") {
732                    Value::Null
733                } else {
734                    Value::String(key_str.to_string())
735                };
736                let value = if val_str == null_str || val_str.eq_ignore_ascii_case("null") {
737                    Value::Null
738                } else {
739                    Value::String(val_str.to_string())
740                };
741                Some((key, value))
742            } else {
743                Some((Value::String(trimmed.to_string()), Value::Null))
744            }
745        })
746        .collect()
747}
748
749// ─── Splitting helpers ──────────────────────────────────────────────────────────
750
751/// Split a CSV line by `delimiter`, respecting double-quoted strings.
752fn split_csv_respecting_quotes(s: &str, delimiter: char) -> Vec<String> {
753    let mut parts = Vec::new();
754    let mut current = String::new();
755    let mut in_quotes = false;
756
757    for ch in s.chars() {
758        match ch {
759            '"' => in_quotes = !in_quotes,
760            c if c == delimiter && !in_quotes => {
761                parts.push(current.trim().to_string());
762                current = String::new();
763            }
764            c => current.push(c),
765        }
766    }
767    parts.push(current.trim().to_string());
768    parts
769}
770
771/// Split top-level fields by `delimiter`, respecting balanced braces, brackets,
772/// parentheses, and quotes.
773fn split_top_level(s: &str, delimiter: char) -> Vec<String> {
774    let mut parts = Vec::new();
775    let mut current = String::new();
776    let mut depth_brace = 0i32;
777    let mut depth_bracket = 0i32;
778    let mut depth_paren = 0i32;
779    let mut in_quotes = false;
780
781    for ch in s.chars() {
782        match ch {
783            '"' => {
784                in_quotes = !in_quotes;
785                current.push(ch);
786            }
787            '{' if !in_quotes => {
788                depth_brace += 1;
789                current.push(ch);
790            }
791            '}' if !in_quotes => {
792                depth_brace -= 1;
793                current.push(ch);
794            }
795            '[' if !in_quotes => {
796                depth_bracket += 1;
797                current.push(ch);
798            }
799            ']' if !in_quotes => {
800                depth_bracket -= 1;
801                current.push(ch);
802            }
803            '(' if !in_quotes => {
804                depth_paren += 1;
805                current.push(ch);
806            }
807            ')' if !in_quotes => {
808                depth_paren -= 1;
809                current.push(ch);
810            }
811            c if c == delimiter && !in_quotes && depth_brace == 0 && depth_bracket == 0 && depth_paren == 0 => {
812                parts.push(current.trim().to_string());
813                current = String::new();
814            }
815            c => current.push(c),
816        }
817    }
818    parts.push(current.trim().to_string());
819    parts
820}
821
822// ─── Tests ──────────────────────────────────────────────────────────────────────
823
824#[cfg(test)]
825mod tests {
826    use super::*;
827
828    fn test_schema() -> Vec<CatalogColumn> {
829        vec![
830            CatalogColumn {
831                compression: akar_common::enums::CompressionType::Uncompressed,
832                name: "name".into(),
833                logical_type: LogicalTypeID::String,
834                is_primary_key: true,
835                default_value: None,
836            },
837            CatalogColumn {
838                compression: akar_common::enums::CompressionType::Uncompressed,
839                name: "age".into(),
840                logical_type: LogicalTypeID::Int64,
841                is_primary_key: false,
842                default_value: None,
843            },
844            CatalogColumn {
845                compression: akar_common::enums::CompressionType::Uncompressed,
846                name: "score".into(),
847                logical_type: LogicalTypeID::Double,
848                is_primary_key: false,
849                default_value: None,
850            },
851            CatalogColumn {
852                compression: akar_common::enums::CompressionType::Uncompressed,
853                name: "active".into(),
854                logical_type: LogicalTypeID::Bool,
855                is_primary_key: false,
856                default_value: None,
857            },
858        ]
859    }
860
861    #[test]
862    fn test_read_csv_basic() {
863        let dir = tempfile::tempdir().unwrap();
864        let csv_path = dir.path().join("test.csv");
865        std::fs::write(
866            &csv_path,
867            "name,age,score,active\nAlice,30,95.5,true\nBob,25,87.3,false\n",
868        )
869        .unwrap();
870
871        let config = CsvReaderConfig::default();
872        let rows = read_csv(
873            csv_path.to_str().unwrap(),
874            &akar_common::file_system::VirtualFileSystemRegistry::new(),
875            &test_schema(),
876            &config,
877        )
878        .unwrap();
879        assert_eq!(rows.len(), 2);
880        assert_eq!(rows[0][0], Value::String("Alice".into()));
881        assert_eq!(rows[0][1], Value::Int64(30));
882        assert_eq!(rows[0][2], Value::Double(95.5));
883        assert_eq!(rows[0][3], Value::Bool(true));
884    }
885
886    #[test]
887    fn test_read_csv_no_header() {
888        let dir = tempfile::tempdir().unwrap();
889        let csv_path = dir.path().join("noheader.csv");
890        std::fs::write(&csv_path, "Charlie,40,91.2,true\nDiana,22,88.1,false\n").unwrap();
891
892        let config = CsvReaderConfig {
893            has_header: false,
894            ..Default::default()
895        };
896        let rows = read_csv(
897            csv_path.to_str().unwrap(),
898            &akar_common::file_system::VirtualFileSystemRegistry::new(),
899            &test_schema(),
900            &config,
901        )
902        .unwrap();
903        assert_eq!(rows.len(), 2);
904        assert_eq!(rows[1][0], Value::String("Diana".into()));
905        assert_eq!(rows[1][1], Value::Int64(22));
906    }
907
908    #[test]
909    fn test_read_csv_custom_delimiter() {
910        let dir = tempfile::tempdir().unwrap();
911        let csv_path = dir.path().join("pipes.csv");
912        std::fs::write(&csv_path, "name|age|score|active\nEve|35|77.5|true\n").unwrap();
913
914        let config = CsvReaderConfig {
915            delimiter: b'|',
916            ..Default::default()
917        };
918        let rows = read_csv(
919            csv_path.to_str().unwrap(),
920            &akar_common::file_system::VirtualFileSystemRegistry::new(),
921            &test_schema(),
922            &config,
923        )
924        .unwrap();
925        assert_eq!(rows.len(), 1);
926        assert_eq!(rows[0][0], Value::String("Eve".into()));
927        assert_eq!(rows[0][1], Value::Int64(35));
928    }
929
930    #[test]
931    fn test_read_csv_quoted_fields() {
932        let dir = tempfile::tempdir().unwrap();
933        let csv_path = dir.path().join("quoted.csv");
934        std::fs::write(&csv_path, "name,age,score,active\n\"Frank, Jr.\",28,99.9,true\n").unwrap();
935
936        let config = CsvReaderConfig::default();
937        let rows = read_csv(
938            csv_path.to_str().unwrap(),
939            &akar_common::file_system::VirtualFileSystemRegistry::new(),
940            &test_schema(),
941            &config,
942        )
943        .unwrap();
944        assert_eq!(rows.len(), 1);
945        assert_eq!(rows[0][0], Value::String("Frank, Jr.".into()));
946    }
947
948    #[test]
949    fn test_read_csv_null_values() {
950        let dir = tempfile::tempdir().unwrap();
951        let csv_path = dir.path().join("nulls.csv");
952        std::fs::write(&csv_path, "name,age,score,active\nGrace,,,\n").unwrap();
953
954        let config = CsvReaderConfig::default();
955        let rows = read_csv(
956            csv_path.to_str().unwrap(),
957            &akar_common::file_system::VirtualFileSystemRegistry::new(),
958            &test_schema(),
959            &config,
960        )
961        .unwrap();
962        assert_eq!(rows.len(), 1);
963        assert_eq!(rows[0][0], Value::String("Grace".into()));
964        assert_eq!(rows[0][1], Value::Null);
965        assert_eq!(rows[0][2], Value::Null);
966        assert_eq!(rows[0][3], Value::Null);
967    }
968
969    #[test]
970    fn test_read_csv_custom_null_str() {
971        let dir = tempfile::tempdir().unwrap();
972        let csv_path = dir.path().join("nulls.csv");
973        std::fs::write(&csv_path, "name,age\nNULL,5\n\"\",7\n").unwrap();
974
975        let config = CsvReaderConfig {
976            null_str: "NULL".into(),
977            ..Default::default()
978        };
979
980        let rows = read_csv(
981            csv_path.to_str().unwrap(),
982            &akar_common::file_system::VirtualFileSystemRegistry::new(),
983            &test_schema()[..2],
984            &config,
985        )
986        .unwrap();
987        assert_eq!(rows.len(), 2);
988        assert_eq!(rows[0][0], Value::Null);
989        assert_eq!(rows[0][1], Value::Int64(5));
990        assert_eq!(rows[1][0], Value::String(String::new()));
991        assert_eq!(rows[1][1], Value::Int64(7));
992    }
993
994    #[test]
995    fn test_read_csv_custom_null_str_nested() {
996        let dir = tempfile::tempdir().unwrap();
997        let csv_path = dir.path().join("nested.csv");
998        std::fs::write(&csv_path, "tags,props\n\"[NULL, NA, x]\",\"{a=NA, b=x}\"\n").unwrap();
999
1000        let config = CsvReaderConfig {
1001            null_str: "NA".into(),
1002            ..Default::default()
1003        };
1004
1005        let schema = vec![
1006            CatalogColumn {
1007                compression: akar_common::enums::CompressionType::Uncompressed,
1008                name: "tags".into(),
1009                logical_type: LogicalTypeID::List,
1010                is_primary_key: false,
1011                default_value: None,
1012            },
1013            CatalogColumn {
1014                compression: akar_common::enums::CompressionType::Uncompressed,
1015                name: "props".into(),
1016                logical_type: LogicalTypeID::Map,
1017                is_primary_key: false,
1018                default_value: None,
1019            },
1020        ];
1021
1022        let rows = read_csv(
1023            csv_path.to_str().unwrap(),
1024            &akar_common::file_system::VirtualFileSystemRegistry::new(),
1025            &schema,
1026            &config,
1027        )
1028        .unwrap();
1029        assert_eq!(rows.len(), 1);
1030        assert_eq!(
1031            rows[0][0],
1032            Value::List(vec![Value::Null, Value::Null, Value::String("x".into())])
1033        );
1034        assert_eq!(
1035            rows[0][1],
1036            Value::Map(vec![
1037                (Value::String("a".into()), Value::Null),
1038                (Value::String("b".into()), Value::String("x".into())),
1039            ])
1040        );
1041    }
1042
1043    #[test]
1044    fn test_read_csv_column_count_mismatch() {
1045        let dir = tempfile::tempdir().unwrap();
1046        let csv_path = dir.path().join("bad_cols.csv");
1047        std::fs::write(&csv_path, "name,age,score,active\nAlice,30\n").unwrap();
1048
1049        let config = CsvReaderConfig::default();
1050        let result = read_csv(
1051            csv_path.to_str().unwrap(),
1052            &akar_common::file_system::VirtualFileSystemRegistry::new(),
1053            &test_schema(),
1054            &config,
1055        );
1056        assert!(result.is_err());
1057        match result.unwrap_err() {
1058            CsvReaderError::ColumnCountMismatch { line, expected, actual } => {
1059                assert_eq!(line, 2);
1060                assert_eq!(expected, 4);
1061                assert_eq!(actual, 2);
1062            }
1063            _ => panic!("Expected ColumnCountMismatch"),
1064        }
1065    }
1066
1067    #[test]
1068    fn test_read_csv_type_coercion_error() {
1069        let dir = tempfile::tempdir().unwrap();
1070        let csv_path = dir.path().join("bad_type.csv");
1071        std::fs::write(&csv_path, "name,age,score,active\nAlice,not_a_number,95.5,true\n").unwrap();
1072
1073        let config = CsvReaderConfig::default();
1074        let result = read_csv(
1075            csv_path.to_str().unwrap(),
1076            &akar_common::file_system::VirtualFileSystemRegistry::new(),
1077            &test_schema(),
1078            &config,
1079        );
1080        assert!(result.is_err());
1081        match result.unwrap_err() {
1082            CsvReaderError::TypeCoercion { line, column, .. } => {
1083                assert_eq!(line, 2);
1084                assert_eq!(column, 1);
1085            }
1086            _ => panic!("Expected TypeCoercion"),
1087        }
1088    }
1089
1090    #[test]
1091    fn test_read_csv_file_not_found() {
1092        let config = CsvReaderConfig::default();
1093        let result = read_csv(
1094            "nonexistent.csv",
1095            &akar_common::file_system::VirtualFileSystemRegistry::new(),
1096            &test_schema(),
1097            &config,
1098        );
1099        assert!(result.is_err());
1100        match result.unwrap_err() {
1101            CsvReaderError::IoError(_) => {} // expected
1102            _ => panic!("Expected IoError"),
1103        }
1104    }
1105
1106    #[test]
1107    fn test_read_csv_dates_and_timestamps() {
1108        let dir = tempfile::tempdir().unwrap();
1109        let csv_path = dir.path().join("dates.csv");
1110        std::fs::write(
1111            &csv_path,
1112            "name,birth,updated\nAlice,1990-05-15,2024-01-20 14:30:00.123456\n",
1113        )
1114        .unwrap();
1115
1116        let schema = vec![
1117            CatalogColumn {
1118                compression: akar_common::enums::CompressionType::Uncompressed,
1119                name: "name".into(),
1120                logical_type: LogicalTypeID::String,
1121                is_primary_key: false,
1122                default_value: None,
1123            },
1124            CatalogColumn {
1125                compression: akar_common::enums::CompressionType::Uncompressed,
1126                name: "birth".into(),
1127                logical_type: LogicalTypeID::Date,
1128                is_primary_key: false,
1129                default_value: None,
1130            },
1131            CatalogColumn {
1132                compression: akar_common::enums::CompressionType::Uncompressed,
1133                name: "updated".into(),
1134                logical_type: LogicalTypeID::Timestamp,
1135                is_primary_key: false,
1136                default_value: None,
1137            },
1138        ];
1139
1140        let config = CsvReaderConfig::default();
1141        let rows = read_csv(
1142            csv_path.to_str().unwrap(),
1143            &akar_common::file_system::VirtualFileSystemRegistry::new(),
1144            &schema,
1145            &config,
1146        )
1147        .unwrap();
1148        assert_eq!(rows.len(), 1);
1149        assert_eq!(rows[0][0], Value::String("Alice".into()));
1150        // Date: 1990-05-15 → compute days since epoch
1151        if let Value::Date(d) = &rows[0][1] {
1152            assert_eq!(d.days_since_epoch(), 7439); // 1990-05-15
1153        } else {
1154            panic!("Expected Date");
1155        }
1156        if let Value::Timestamp(ts) = &rows[0][2] {
1157            // 2024-01-20 14:30:00.123456 → compute micros
1158            assert!(ts.micros_since_epoch() > 0);
1159        } else {
1160            panic!("Expected Timestamp");
1161        }
1162    }
1163
1164    #[test]
1165    fn test_read_csv_interval() {
1166        let dir = tempfile::tempdir().unwrap();
1167        let csv_path = dir.path().join("intervals.csv");
1168        std::fs::write(
1169            &csv_path,
1170            "name,duration\nTask1,1 year 2 months 3 days 4 hours 5 minutes 6 seconds\n",
1171        )
1172        .unwrap();
1173
1174        let schema = vec![
1175            CatalogColumn {
1176                compression: akar_common::enums::CompressionType::Uncompressed,
1177                name: "name".into(),
1178                logical_type: LogicalTypeID::String,
1179                is_primary_key: false,
1180                default_value: None,
1181            },
1182            CatalogColumn {
1183                compression: akar_common::enums::CompressionType::Uncompressed,
1184                name: "duration".into(),
1185                logical_type: LogicalTypeID::Interval,
1186                is_primary_key: false,
1187                default_value: None,
1188            },
1189        ];
1190
1191        let config = CsvReaderConfig::default();
1192        let rows = read_csv(
1193            csv_path.to_str().unwrap(),
1194            &akar_common::file_system::VirtualFileSystemRegistry::new(),
1195            &schema,
1196            &config,
1197        )
1198        .unwrap();
1199        assert_eq!(rows.len(), 1);
1200        if let Value::Interval(iv) = &rows[0][1] {
1201            // 1 year = 12 months, + 2 months = 14 months
1202            assert_eq!(iv.months, 14);
1203            assert_eq!(iv.days, 3);
1204            // 4h 5m 6s = 4*3600 + 5*60 + 6 = 14706 seconds = 14706000000 micros
1205            assert_eq!(iv.micros, 14_706_000_000);
1206        } else {
1207            panic!("Expected Interval");
1208        }
1209    }
1210
1211    #[test]
1212    fn test_read_csv_list_and_struct() {
1213        let dir = tempfile::tempdir().unwrap();
1214        let csv_path = dir.path().join("complex.csv");
1215        std::fs::write(
1216            &csv_path,
1217            "name,tags,metadata\nItem1,\"[a,b,c]\",\"{key1: val1, key2: val2}\"\n",
1218        )
1219        .unwrap();
1220
1221        let schema = vec![
1222            CatalogColumn {
1223                compression: akar_common::enums::CompressionType::Uncompressed,
1224                name: "name".into(),
1225                logical_type: LogicalTypeID::String,
1226                is_primary_key: false,
1227                default_value: None,
1228            },
1229            CatalogColumn {
1230                compression: akar_common::enums::CompressionType::Uncompressed,
1231                name: "tags".into(),
1232                logical_type: LogicalTypeID::List,
1233                is_primary_key: false,
1234                default_value: None,
1235            },
1236            CatalogColumn {
1237                compression: akar_common::enums::CompressionType::Uncompressed,
1238                name: "metadata".into(),
1239                logical_type: LogicalTypeID::Struct,
1240                is_primary_key: false,
1241                default_value: None,
1242            },
1243        ];
1244
1245        let config = CsvReaderConfig::default();
1246        let rows = read_csv(
1247            csv_path.to_str().unwrap(),
1248            &akar_common::file_system::VirtualFileSystemRegistry::new(),
1249            &schema,
1250            &config,
1251        )
1252        .unwrap();
1253        assert_eq!(rows.len(), 1);
1254        assert_eq!(rows[0][0], Value::String("Item1".into()));
1255        if let Value::List(items) = &rows[0][1] {
1256            assert_eq!(items.len(), 3);
1257        } else {
1258            panic!("Expected List");
1259        }
1260        if let Value::Struct(fields) = &rows[0][2] {
1261            assert_eq!(fields.len(), 2);
1262            assert_eq!(fields[0].0, "key1");
1263        } else {
1264            panic!("Expected Struct");
1265        }
1266    }
1267
1268    #[test]
1269    fn test_read_csv_blob() {
1270        let dir = tempfile::tempdir().unwrap();
1271        let csv_path = dir.path().join("blobs.csv");
1272        std::fs::write(&csv_path, "name,data\nBlob1,\\xAA\\xBB\\xCC\\xDD\n").unwrap();
1273
1274        let schema = vec![
1275            CatalogColumn {
1276                compression: akar_common::enums::CompressionType::Uncompressed,
1277                name: "name".into(),
1278                logical_type: LogicalTypeID::String,
1279                is_primary_key: false,
1280                default_value: None,
1281            },
1282            CatalogColumn {
1283                compression: akar_common::enums::CompressionType::Uncompressed,
1284                name: "data".into(),
1285                logical_type: LogicalTypeID::Blob,
1286                is_primary_key: false,
1287                default_value: None,
1288            },
1289        ];
1290
1291        let config = CsvReaderConfig::default();
1292        let rows = read_csv(
1293            csv_path.to_str().unwrap(),
1294            &akar_common::file_system::VirtualFileSystemRegistry::new(),
1295            &schema,
1296            &config,
1297        )
1298        .unwrap();
1299        assert_eq!(rows.len(), 1);
1300        if let Value::Blob(bytes) = &rows[0][1] {
1301            assert_eq!(bytes, &[0xAA, 0xBB, 0xCC, 0xDD]);
1302        } else {
1303            panic!("Expected Blob");
1304        }
1305    }
1306
1307    #[test]
1308    fn test_read_csv_uint_types() {
1309        let dir = tempfile::tempdir().unwrap();
1310        let csv_path = dir.path().join("uints.csv");
1311        std::fs::write(&csv_path, "small,medium,large\n100,1000,100000\n").unwrap();
1312
1313        let schema = vec![
1314            CatalogColumn {
1315                compression: akar_common::enums::CompressionType::Uncompressed,
1316                name: "small".into(),
1317                logical_type: LogicalTypeID::UInt8,
1318                is_primary_key: false,
1319                default_value: None,
1320            },
1321            CatalogColumn {
1322                compression: akar_common::enums::CompressionType::Uncompressed,
1323                name: "medium".into(),
1324                logical_type: LogicalTypeID::UInt32,
1325                is_primary_key: false,
1326                default_value: None,
1327            },
1328            CatalogColumn {
1329                compression: akar_common::enums::CompressionType::Uncompressed,
1330                name: "large".into(),
1331                logical_type: LogicalTypeID::UInt64,
1332                is_primary_key: false,
1333                default_value: None,
1334            },
1335        ];
1336
1337        let config = CsvReaderConfig::default();
1338        let rows = read_csv(
1339            csv_path.to_str().unwrap(),
1340            &akar_common::file_system::VirtualFileSystemRegistry::new(),
1341            &schema,
1342            &config,
1343        )
1344        .unwrap();
1345        assert_eq!(rows[0][0], Value::UInt8(100));
1346        assert_eq!(rows[0][1], Value::UInt32(1000));
1347        assert_eq!(rows[0][2], Value::UInt64(100000));
1348    }
1349
1350    #[test]
1351    fn test_config_from_options() {
1352        let mut opts = HashMap::new();
1353        opts.insert("HEADER".into(), "false".into());
1354        opts.insert("DELIM".into(), "|".into());
1355        opts.insert("QUOTE".into(), "'".into());
1356        opts.insert("ESCAPE".into(), "`".into());
1357        opts.insert("NULL".into(), "NA".into());
1358
1359        let config = CsvReaderConfig::from_options(&opts);
1360        assert!(!config.has_header);
1361        assert_eq!(config.delimiter, b'|');
1362        assert_eq!(config.quote, b'\'');
1363        assert_eq!(config.escape, b'`');
1364        assert_eq!(config.null_str, "NA");
1365    }
1366
1367    #[test]
1368    fn test_parse_blob_mixed() {
1369        let blob = parse_blob("Hello\\x20World");
1370        assert_eq!(blob, b"Hello World");
1371    }
1372
1373    #[test]
1374    fn test_split_top_level_nested() {
1375        let result = split_top_level("{a: 1, b: {c: 2}}, {d: 3}", ',');
1376        assert_eq!(result.len(), 2);
1377        assert_eq!(result[0], "{a: 1, b: {c: 2}}");
1378        assert_eq!(result[1], "{d: 3}");
1379    }
1380
1381    #[test]
1382    fn test_naive_date_to_epoch_days_known() {
1383        // 1970-01-01 = 0
1384        assert_eq!(naive_date_to_epoch_days(1970, 1, 1), Some(0));
1385        // 2024-01-01 = 19723 days after epoch
1386        assert_eq!(naive_date_to_epoch_days(2024, 1, 1), Some(19723));
1387        // 1990-05-15
1388        assert_eq!(naive_date_to_epoch_days(1990, 5, 15), Some(7439));
1389    }
1390}