Skip to main content

sheetkit_core/
cell.rs

1//! Cell value representation.
2//!
3//! Provides the [`CellValue`] enum which represents the typed value of a
4//! single cell in a worksheet. This is the high-level counterpart to the
5//! raw XML `Cell` element from `sheetkit-xml`.
6
7use std::fmt;
8
9use chrono::{NaiveDate, NaiveDateTime, NaiveTime, Timelike};
10
11/// Represents the value of a cell.
12#[derive(Debug, Clone, PartialEq, Default)]
13pub enum CellValue {
14    /// No value (empty cell).
15    #[default]
16    Empty,
17    /// Boolean value.
18    Bool(bool),
19    /// Numeric value (integers are stored as f64 in Excel).
20    Number(f64),
21    /// String value.
22    String(String),
23    /// Formula with optional cached result.
24    Formula {
25        expr: String,
26        result: Option<Box<CellValue>>,
27    },
28    /// A date/time value stored as an Excel serial number.
29    /// Integer part = days since 1899-12-30 (Excel epoch).
30    /// Fractional part = time of day (0.5 = noon).
31    Date(f64),
32    /// Error value (e.g. #DIV/0!, #N/A, #VALUE!).
33    Error(String),
34    /// Rich text: multiple formatted runs within a single cell.
35    RichString(Vec<crate::rich_text::RichTextRun>),
36}
37
38impl fmt::Display for CellValue {
39    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
40        match self {
41            CellValue::Empty => write!(f, ""),
42            CellValue::Bool(b) => write!(f, "{}", if *b { "TRUE" } else { "FALSE" }),
43            CellValue::Number(n) => {
44                // Display integers without decimal point
45                if n.fract() == 0.0 && n.is_finite() {
46                    write!(f, "{}", *n as i64)
47                } else {
48                    write!(f, "{n}")
49                }
50            }
51            CellValue::Date(serial) => {
52                if let Some(dt) = serial_to_datetime(*serial) {
53                    if serial.fract() == 0.0 {
54                        // Date only, no time component.
55                        write!(f, "{}", dt.format("%Y-%m-%d"))
56                    } else {
57                        write!(f, "{}", dt.format("%Y-%m-%d %H:%M:%S"))
58                    }
59                } else {
60                    write!(f, "{serial}")
61                }
62            }
63            CellValue::String(s) => write!(f, "{s}"),
64            CellValue::Formula { result, expr, .. } => {
65                if let Some(result) = result {
66                    write!(f, "{result}")
67                } else {
68                    write!(f, "={expr}")
69                }
70            }
71            CellValue::Error(e) => write!(f, "{e}"),
72            CellValue::RichString(runs) => {
73                write!(f, "{}", crate::rich_text::rich_text_to_plain(runs))
74            }
75        }
76    }
77}
78
79impl From<&str> for CellValue {
80    fn from(s: &str) -> Self {
81        CellValue::String(s.to_string())
82    }
83}
84
85impl From<String> for CellValue {
86    fn from(s: String) -> Self {
87        CellValue::String(s)
88    }
89}
90
91impl From<f64> for CellValue {
92    fn from(n: f64) -> Self {
93        CellValue::Number(n)
94    }
95}
96
97impl From<i32> for CellValue {
98    fn from(n: i32) -> Self {
99        CellValue::Number(f64::from(n))
100    }
101}
102
103impl From<i64> for CellValue {
104    fn from(n: i64) -> Self {
105        CellValue::Number(n as f64)
106    }
107}
108
109impl From<bool> for CellValue {
110    fn from(b: bool) -> Self {
111        CellValue::Bool(b)
112    }
113}
114
115impl From<NaiveDate> for CellValue {
116    fn from(date: NaiveDate) -> Self {
117        CellValue::Date(date_to_serial(date))
118    }
119}
120
121impl From<NaiveDateTime> for CellValue {
122    fn from(datetime: NaiveDateTime) -> Self {
123        CellValue::Date(datetime_to_serial(datetime))
124    }
125}
126
127/// Number of seconds in a day.
128const SECONDS_PER_DAY: f64 = 86_400.0;
129
130/// Convert a `NaiveDate` to an Excel serial number.
131///
132/// Serial number 1 = January 1, 1900. Accounts for the Excel 1900 leap year
133/// bug (serial 60 = the non-existent February 29, 1900).
134pub fn date_to_serial(date: NaiveDate) -> f64 {
135    let epoch = NaiveDate::from_ymd_opt(1899, 12, 31).expect("valid epoch date 1899-12-31");
136    let days = (date - epoch).num_days() as f64;
137    // Excel incorrectly considers 1900 a leap year. All dates from
138    // March 1, 1900 onward have serial numbers one higher than the
139    // actual day count from the epoch.
140    if days >= 60.0 {
141        days + 1.0
142    } else {
143        days
144    }
145}
146
147/// Convert a `NaiveDateTime` to an Excel serial number with fractional time.
148///
149/// The integer part represents the date (see [`date_to_serial`]) and the
150/// fractional part represents the time of day (0.5 = noon).
151pub fn datetime_to_serial(datetime: NaiveDateTime) -> f64 {
152    let date_part = date_to_serial(datetime.date());
153    let time = datetime.time();
154    let seconds_since_midnight =
155        time.hour() as f64 * 3600.0 + time.minute() as f64 * 60.0 + time.second() as f64;
156    date_part + seconds_since_midnight / SECONDS_PER_DAY
157}
158
159/// Convert an Excel serial number to a `NaiveDate`.
160///
161/// Returns `None` for invalid serial numbers (< 1).
162pub fn serial_to_date(serial: f64) -> Option<NaiveDate> {
163    let serial_int = serial.floor() as i64;
164    if serial_int < 1 {
165        return None;
166    }
167    let epoch = NaiveDate::from_ymd_opt(1899, 12, 31).expect("valid epoch date 1899-12-31");
168    // Serial 60 = February 29, 1900 (Excel bug -- this date does not exist).
169    // Map it to February 28, 1900 for practical purposes.
170    if serial_int == 60 {
171        return NaiveDate::from_ymd_opt(1900, 2, 28);
172    }
173    // For serial >= 61, subtract 1 to compensate for the phantom leap day.
174    let adjusted = if serial_int >= 61 {
175        serial_int - 1
176    } else {
177        serial_int
178    };
179    epoch.checked_add_signed(chrono::Duration::days(adjusted))
180}
181
182/// Convert an Excel serial number to a `NaiveDateTime`.
183///
184/// Returns `None` for invalid serial numbers (< 1).
185pub fn serial_to_datetime(serial: f64) -> Option<NaiveDateTime> {
186    let date = serial_to_date(serial)?;
187    let frac = serial.fract().abs();
188    let total_seconds = (frac * SECONDS_PER_DAY).round() as u32;
189    let hours = total_seconds / 3600;
190    let minutes = (total_seconds % 3600) / 60;
191    let seconds = total_seconds % 60;
192    let time = NaiveTime::from_hms_opt(hours, minutes, seconds)?;
193    Some(NaiveDateTime::new(date, time))
194}
195
196/// Returns `true` if the given number format ID is a built-in date or time format.
197///
198/// Built-in date format IDs: 14-22 and 45-47.
199pub fn is_date_num_fmt(num_fmt_id: u32) -> bool {
200    matches!(num_fmt_id, 14..=22 | 45..=47)
201}
202
203/// Returns `true` if a custom number format string looks like a date/time format.
204///
205/// Checks for common date/time tokens such as y, m, d, h, s in the format code.
206/// Ignores text in quoted strings and escaped characters.
207pub fn is_date_format_code(code: &str) -> bool {
208    let mut in_quotes = false;
209    let mut prev_backslash = false;
210    for ch in code.chars() {
211        if prev_backslash {
212            prev_backslash = false;
213            continue;
214        }
215        if ch == '\\' {
216            prev_backslash = true;
217            continue;
218        }
219        if ch == '"' {
220            in_quotes = !in_quotes;
221            continue;
222        }
223        if in_quotes {
224            continue;
225        }
226        let lower = ch.to_ascii_lowercase();
227        if matches!(lower, 'y' | 'd' | 'h' | 's') {
228            return true;
229        }
230        // 'm' is ambiguous (month vs minute); consider it a date token
231        // when not preceded by 'h' or followed by 's'. For simplicity,
232        // treat any bare 'm' as a date indicator.
233        if lower == 'm' {
234            return true;
235        }
236    }
237    false
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    #[test]
245    fn test_cell_value_default_is_empty() {
246        let v = CellValue::default();
247        assert_eq!(v, CellValue::Empty);
248    }
249
250    #[test]
251    fn test_cell_value_from_str() {
252        let v: CellValue = "hello".into();
253        assert_eq!(v, CellValue::String("hello".to_string()));
254    }
255
256    #[test]
257    fn test_cell_value_from_string() {
258        let v: CellValue = String::from("world").into();
259        assert_eq!(v, CellValue::String("world".to_string()));
260    }
261
262    #[test]
263    fn test_cell_value_from_f64() {
264        let v: CellValue = 3.14.into();
265        assert_eq!(v, CellValue::Number(3.14));
266    }
267
268    #[test]
269    fn test_cell_value_from_i32() {
270        let v: CellValue = 42i32.into();
271        assert_eq!(v, CellValue::Number(42.0));
272    }
273
274    #[test]
275    fn test_cell_value_from_i64() {
276        let v: CellValue = 100i64.into();
277        assert_eq!(v, CellValue::Number(100.0));
278    }
279
280    #[test]
281    fn test_cell_value_from_bool() {
282        let v: CellValue = true.into();
283        assert_eq!(v, CellValue::Bool(true));
284
285        let v2: CellValue = false.into();
286        assert_eq!(v2, CellValue::Bool(false));
287    }
288
289    #[test]
290    fn test_cell_value_display() {
291        assert_eq!(CellValue::Empty.to_string(), "");
292        assert_eq!(CellValue::Bool(true).to_string(), "TRUE");
293        assert_eq!(CellValue::Bool(false).to_string(), "FALSE");
294        assert_eq!(CellValue::Number(42.0).to_string(), "42");
295        assert_eq!(CellValue::Number(3.14).to_string(), "3.14");
296        assert_eq!(CellValue::String("hello".to_string()).to_string(), "hello");
297        assert_eq!(
298            CellValue::Error("#DIV/0!".to_string()).to_string(),
299            "#DIV/0!"
300        );
301        assert_eq!(
302            CellValue::Formula {
303                expr: "A1+B1".to_string(),
304                result: Some(Box::new(CellValue::Number(42.0))),
305            }
306            .to_string(),
307            "42"
308        );
309        assert_eq!(
310            CellValue::Formula {
311                expr: "A1+B1".to_string(),
312                result: None,
313            }
314            .to_string(),
315            "=A1+B1"
316        );
317    }
318
319    // -- Date conversion tests --
320
321    #[test]
322    fn test_date_to_serial_jan_1_1900() {
323        let date = NaiveDate::from_ymd_opt(1900, 1, 1).unwrap();
324        assert_eq!(date_to_serial(date), 1.0);
325    }
326
327    #[test]
328    fn test_date_to_serial_feb_28_1900() {
329        let date = NaiveDate::from_ymd_opt(1900, 2, 28).unwrap();
330        assert_eq!(date_to_serial(date), 59.0);
331    }
332
333    #[test]
334    fn test_date_to_serial_mar_1_1900_accounts_for_leap_year_bug() {
335        // March 1, 1900 should be serial 61 (skipping the phantom Feb 29).
336        let date = NaiveDate::from_ymd_opt(1900, 3, 1).unwrap();
337        assert_eq!(date_to_serial(date), 61.0);
338    }
339
340    #[test]
341    fn test_date_to_serial_jan_1_2000() {
342        let date = NaiveDate::from_ymd_opt(2000, 1, 1).unwrap();
343        // Known value: January 1, 2000 = serial 36526 in Excel.
344        assert_eq!(date_to_serial(date), 36526.0);
345    }
346
347    #[test]
348    fn test_date_to_serial_jan_1_1970() {
349        let date = NaiveDate::from_ymd_opt(1970, 1, 1).unwrap();
350        assert_eq!(date_to_serial(date), 25569.0);
351    }
352
353    #[test]
354    fn test_serial_to_date_jan_1_1900() {
355        let date = serial_to_date(1.0).unwrap();
356        assert_eq!(date, NaiveDate::from_ymd_opt(1900, 1, 1).unwrap());
357    }
358
359    #[test]
360    fn test_serial_to_date_feb_28_1900() {
361        let date = serial_to_date(59.0).unwrap();
362        assert_eq!(date, NaiveDate::from_ymd_opt(1900, 2, 28).unwrap());
363    }
364
365    #[test]
366    fn test_serial_to_date_60_phantom_leap_day() {
367        // Serial 60 is the phantom Feb 29 1900. We map it to Feb 28.
368        let date = serial_to_date(60.0).unwrap();
369        assert_eq!(date, NaiveDate::from_ymd_opt(1900, 2, 28).unwrap());
370    }
371
372    #[test]
373    fn test_serial_to_date_mar_1_1900() {
374        let date = serial_to_date(61.0).unwrap();
375        assert_eq!(date, NaiveDate::from_ymd_opt(1900, 3, 1).unwrap());
376    }
377
378    #[test]
379    fn test_serial_to_date_jan_1_2000() {
380        let date = serial_to_date(36526.0).unwrap();
381        assert_eq!(date, NaiveDate::from_ymd_opt(2000, 1, 1).unwrap());
382    }
383
384    #[test]
385    fn test_serial_to_date_invalid() {
386        assert!(serial_to_date(0.0).is_none());
387        assert!(serial_to_date(-1.0).is_none());
388    }
389
390    #[test]
391    fn test_date_roundtrip() {
392        // Test that date_to_serial -> serial_to_date is a roundtrip for dates
393        // after March 1, 1900.
394        let dates = vec![
395            NaiveDate::from_ymd_opt(1900, 3, 1).unwrap(),
396            NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
397            NaiveDate::from_ymd_opt(1999, 12, 31).unwrap(),
398            NaiveDate::from_ymd_opt(2100, 1, 1).unwrap(),
399        ];
400        for date in dates {
401            let serial = date_to_serial(date);
402            let roundtripped = serial_to_date(serial).unwrap();
403            assert_eq!(roundtripped, date, "roundtrip failed for {date}");
404        }
405    }
406
407    #[test]
408    fn test_datetime_to_serial_noon() {
409        let dt = NaiveDate::from_ymd_opt(2000, 1, 1)
410            .unwrap()
411            .and_hms_opt(12, 0, 0)
412            .unwrap();
413        let serial = datetime_to_serial(dt);
414        // 36526 + 0.5 = 36526.5
415        assert!((serial - 36526.5).abs() < 1e-9);
416    }
417
418    #[test]
419    fn test_datetime_to_serial_with_time() {
420        let dt = NaiveDate::from_ymd_opt(2000, 1, 1)
421            .unwrap()
422            .and_hms_opt(6, 0, 0)
423            .unwrap();
424        let serial = datetime_to_serial(dt);
425        // 6 AM = 0.25 of a day
426        assert!((serial - 36526.25).abs() < 1e-9);
427    }
428
429    #[test]
430    fn test_serial_to_datetime_noon() {
431        let dt = serial_to_datetime(36526.5).unwrap();
432        assert_eq!(dt.date(), NaiveDate::from_ymd_opt(2000, 1, 1).unwrap());
433        assert_eq!(dt.time(), NaiveTime::from_hms_opt(12, 0, 0).unwrap());
434    }
435
436    #[test]
437    fn test_datetime_roundtrip() {
438        let dt = NaiveDate::from_ymd_opt(2024, 3, 15)
439            .unwrap()
440            .and_hms_opt(14, 30, 45)
441            .unwrap();
442        let serial = datetime_to_serial(dt);
443        let roundtripped = serial_to_datetime(serial).unwrap();
444        assert_eq!(roundtripped, dt);
445    }
446
447    #[test]
448    fn test_cell_value_date_display_date_only() {
449        let serial = date_to_serial(NaiveDate::from_ymd_opt(2024, 6, 15).unwrap());
450        let cv = CellValue::Date(serial);
451        assert_eq!(cv.to_string(), "2024-06-15");
452    }
453
454    #[test]
455    fn test_cell_value_date_display_with_time() {
456        let dt = NaiveDate::from_ymd_opt(2024, 6, 15)
457            .unwrap()
458            .and_hms_opt(14, 30, 0)
459            .unwrap();
460        let serial = datetime_to_serial(dt);
461        let cv = CellValue::Date(serial);
462        assert_eq!(cv.to_string(), "2024-06-15 14:30:00");
463    }
464
465    #[test]
466    fn test_cell_value_from_naive_date() {
467        let date = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
468        let cv: CellValue = date.into();
469        match cv {
470            CellValue::Date(s) => assert_eq!(s, date_to_serial(date)),
471            _ => panic!("expected Date variant"),
472        }
473    }
474
475    #[test]
476    fn test_cell_value_from_naive_datetime() {
477        let dt = NaiveDate::from_ymd_opt(2024, 1, 1)
478            .unwrap()
479            .and_hms_opt(12, 0, 0)
480            .unwrap();
481        let cv: CellValue = dt.into();
482        match cv {
483            CellValue::Date(s) => assert_eq!(s, datetime_to_serial(dt)),
484            _ => panic!("expected Date variant"),
485        }
486    }
487
488    #[test]
489    fn test_is_date_num_fmt() {
490        // Date formats: 14-22
491        for id in 14..=22 {
492            assert!(is_date_num_fmt(id), "expected {id} to be a date format");
493        }
494        // Time formats: 45-47
495        for id in 45..=47 {
496            assert!(is_date_num_fmt(id), "expected {id} to be a date format");
497        }
498        // Not date formats
499        assert!(!is_date_num_fmt(0));
500        assert!(!is_date_num_fmt(1));
501        assert!(!is_date_num_fmt(13));
502        assert!(!is_date_num_fmt(23));
503        assert!(!is_date_num_fmt(49));
504    }
505
506    #[test]
507    fn test_is_date_format_code() {
508        assert!(is_date_format_code("yyyy-mm-dd"));
509        assert!(is_date_format_code("m/d/yyyy"));
510        assert!(is_date_format_code("h:mm:ss"));
511        assert!(is_date_format_code("dd/mm/yyyy hh:mm"));
512        // Not date formats
513        assert!(!is_date_format_code("0.00"));
514        assert!(!is_date_format_code("#,##0"));
515        assert!(!is_date_format_code("0%"));
516        // Quoted text should be ignored
517        assert!(!is_date_format_code("\"date\"0.00"));
518        // Escaped characters should be ignored
519        assert!(!is_date_format_code("\\d0.00"));
520    }
521
522    #[test]
523    fn test_date_early_dates_before_leap_bug() {
524        // January 2, 1900 = serial 2
525        let date = NaiveDate::from_ymd_opt(1900, 1, 2).unwrap();
526        assert_eq!(date_to_serial(date), 2.0);
527        assert_eq!(serial_to_date(2.0).unwrap(), date);
528    }
529}