Skip to main content

sheetkit_core/
numfmt.rs

1//! Number format renderer: converts a (value, format_code) pair into display text.
2//!
3//! Supports Excel built-in format IDs 0-49, custom numeric patterns
4//! (`0`, `#`, `,`, `.`, `%`, `E+`), date/time patterns (`y`, `m`, `d`,
5//! `h`, `s`, `AM/PM`), multi-section formats (up to 4 sections separated
6//! by `;`), color codes (`[Red]`, `[Blue]`, etc.), conditional sections
7//! (`[>100]`), text format (`@`), and fraction formats (`# ?/?`).
8
9use crate::cell::serial_to_date;
10
11/// Map a built-in number format ID (0-49) to its format code string.
12pub fn builtin_format_code(id: u32) -> Option<&'static str> {
13    match id {
14        0 => Some("General"),
15        1 => Some("0"),
16        2 => Some("0.00"),
17        3 => Some("#,##0"),
18        4 => Some("#,##0.00"),
19        5 => Some("#,##0_);(#,##0)"),
20        6 => Some("#,##0_);[Red](#,##0)"),
21        7 => Some("#,##0.00_);(#,##0.00)"),
22        8 => Some("#,##0.00_);[Red](#,##0.00)"),
23        9 => Some("0%"),
24        10 => Some("0.00%"),
25        11 => Some("0.00E+00"),
26        12 => Some("# ?/?"),
27        13 => Some("# ??/??"),
28        14 => Some("m/d/yyyy"),
29        15 => Some("d-mmm-yy"),
30        16 => Some("d-mmm"),
31        17 => Some("mmm-yy"),
32        18 => Some("h:mm AM/PM"),
33        19 => Some("h:mm:ss AM/PM"),
34        20 => Some("h:mm"),
35        21 => Some("h:mm:ss"),
36        22 => Some("m/d/yyyy h:mm"),
37        37 => Some("#,##0_);(#,##0)"),
38        38 => Some("#,##0_);[Red](#,##0)"),
39        39 => Some("#,##0.00_);(#,##0.00)"),
40        40 => Some("#,##0.00_);[Red](#,##0.00)"),
41        41 => Some(r#"_(* #,##0_);_(* \(#,##0\);_(* "-"_);_(@_)"#),
42        42 => Some(r#"_("$"* #,##0_);_("$"* \(#,##0\);_("$"* "-"_);_(@_)"#),
43        43 => Some(r#"_(* #,##0.00_);_(* \(#,##0.00\);_(* "-"??_);_(@_)"#),
44        44 => Some(r#"_("$"* #,##0.00_);_("$"* \(#,##0.00\);_("$"* "-"??_);_(@_)"#),
45        45 => Some("mm:ss"),
46        46 => Some("[h]:mm:ss"),
47        47 => Some("mm:ss.0"),
48        48 => Some("##0.0E+0"),
49        49 => Some("@"),
50        _ => None,
51    }
52}
53
54/// Format a numeric value using the given format code string.
55///
56/// Returns the formatted display text. For format codes that contain date/time
57/// tokens, the value is interpreted as an Excel serial number.
58pub fn format_number(value: f64, format_code: &str) -> String {
59    if format_code.is_empty() || format_code.eq_ignore_ascii_case("General") {
60        return format_general(value);
61    }
62
63    let sections = parse_sections(format_code);
64
65    let has_any_condition = sections.iter().any(|s| extract_condition(s).is_some());
66    let section = pick_section(&sections, value);
67
68    let (cleaned, _color) = strip_color_and_condition(section);
69
70    // When multiple sections handle sign presentation, use absolute value:
71    // - Standard sign-based sections (>= 2 sections): the negative section
72    //   format includes its own sign (parentheses, literal minus, etc.)
73    // - Conditional sections: the format encodes its own sign presentation,
74    //   so always pass absolute value to avoid double signs
75    let use_abs = if has_any_condition {
76        sections.len() >= 2
77    } else {
78        sections.len() >= 2 && value < 0.0
79    };
80    let effective_value = if use_abs { value.abs() } else { value };
81
82    if cleaned == "@" {
83        return format_general(effective_value);
84    }
85
86    if is_date_time_format(&cleaned) {
87        return format_date_time(effective_value, &cleaned);
88    }
89
90    if cleaned.contains('?') && cleaned.contains('/') {
91        return format_fraction(effective_value, &cleaned);
92    }
93
94    if format_has_unquoted_char(&cleaned, 'E') || format_has_unquoted_char(&cleaned, 'e') {
95        return format_scientific(effective_value, &cleaned);
96    }
97
98    format_numeric(effective_value, &cleaned)
99}
100
101/// Format a numeric value using a built-in format ID.
102/// Returns `None` if the ID is not a recognized built-in format.
103pub fn format_with_builtin(value: f64, id: u32) -> Option<String> {
104    let code = builtin_format_code(id)?;
105    Some(format_number(value, code))
106}
107
108fn format_general(value: f64) -> String {
109    if value == 0.0 {
110        return "0".to_string();
111    }
112    if value.fract() == 0.0 && value.is_finite() && value.abs() < 1e15 {
113        return format!("{}", value as i64);
114    }
115    // Excel displays up to ~11 significant digits in General format.
116    let abs = value.abs();
117    if (1e-4..1e15).contains(&abs) {
118        let s = format!("{:.10}", value);
119        trim_trailing_zeros(&s)
120    } else if abs < 1e-4 && abs > 0.0 {
121        format!("{:.6E}", value)
122    } else {
123        format!("{}", value)
124    }
125}
126
127fn trim_trailing_zeros(s: &str) -> String {
128    if let Some(dot) = s.find('.') {
129        let trimmed = s.trim_end_matches('0');
130        if trimmed.len() == dot + 1 {
131            trimmed[..dot].to_string()
132        } else {
133            trimmed.to_string()
134        }
135    } else {
136        s.to_string()
137    }
138}
139
140fn parse_sections(format_code: &str) -> Vec<&str> {
141    let mut sections = Vec::new();
142    let mut start = 0;
143    let mut in_quotes = false;
144    let mut prev_backslash = false;
145
146    for (i, ch) in format_code.char_indices() {
147        if prev_backslash {
148            prev_backslash = false;
149            continue;
150        }
151        if ch == '\\' {
152            prev_backslash = true;
153            continue;
154        }
155        if ch == '"' {
156            in_quotes = !in_quotes;
157            continue;
158        }
159        if !in_quotes && ch == ';' {
160            sections.push(&format_code[start..i]);
161            start = i + 1;
162        }
163    }
164    sections.push(&format_code[start..]);
165    sections
166}
167
168/// Comparison operator for conditional format sections.
169#[derive(Debug, Clone, Copy, PartialEq)]
170enum ConditionOp {
171    Gt,
172    Ge,
173    Lt,
174    Le,
175    Eq,
176    Ne,
177}
178
179/// A parsed conditional predicate from a format section (e.g., `[>100]`).
180#[derive(Debug, Clone, PartialEq)]
181struct Condition {
182    op: ConditionOp,
183    threshold: f64,
184}
185
186impl Condition {
187    fn matches(&self, value: f64) -> bool {
188        match self.op {
189            ConditionOp::Gt => value > self.threshold,
190            ConditionOp::Ge => value >= self.threshold,
191            ConditionOp::Lt => value < self.threshold,
192            ConditionOp::Le => value <= self.threshold,
193            ConditionOp::Eq => (value - self.threshold).abs() < 1e-12,
194            ConditionOp::Ne => (value - self.threshold).abs() >= 1e-12,
195        }
196    }
197}
198
199/// Parse a bracket's inner content as a conditional predicate.
200/// Returns `Some(Condition)` for strings like `>100`, `<=0`, `<>5`, `=0`.
201fn parse_condition(content: &str) -> Option<Condition> {
202    let s = content.trim();
203    if s.is_empty() {
204        return None;
205    }
206
207    // Try two-character operators first, then single-character
208    let (op, rest) = if let Some(r) = s.strip_prefix(">=") {
209        (ConditionOp::Ge, r)
210    } else if let Some(r) = s.strip_prefix("<=") {
211        (ConditionOp::Le, r)
212    } else if let Some(r) = s.strip_prefix("<>").or_else(|| s.strip_prefix("!=")) {
213        (ConditionOp::Ne, r)
214    } else if let Some(r) = s.strip_prefix('>') {
215        (ConditionOp::Gt, r)
216    } else if let Some(r) = s.strip_prefix('<') {
217        (ConditionOp::Lt, r)
218    } else if let Some(r) = s.strip_prefix('=') {
219        (ConditionOp::Eq, r)
220    } else {
221        return None;
222    };
223
224    let threshold: f64 = rest.trim().parse().ok()?;
225    Some(Condition { op, threshold })
226}
227
228/// Extract the condition (if any) from a format section's bracket content.
229fn extract_condition(section: &str) -> Option<Condition> {
230    let mut chars = section.chars().peekable();
231    while let Some(&ch) = chars.peek() {
232        if ch == '[' {
233            chars.next();
234            let mut bracket_content = String::new();
235            while let Some(&c) = chars.peek() {
236                if c == ']' {
237                    chars.next();
238                    break;
239                }
240                bracket_content.push(c);
241                chars.next();
242            }
243            let lower = bracket_content.to_ascii_lowercase();
244            let is_known_non_condition = is_color_code(&lower)
245                || lower.starts_with("dbnum")
246                || lower.starts_with('$')
247                || lower.starts_with("natnum")
248                || (lower.starts_with('h') && lower.contains(':'))
249                || lower.starts_with("mm")
250                || lower.starts_with("ss");
251            if !is_known_non_condition {
252                if let Some(cond) = parse_condition(&bracket_content) {
253                    return Some(cond);
254                }
255            }
256        } else {
257            chars.next();
258        }
259    }
260    None
261}
262
263/// Pick the format section to apply for a given value.
264///
265/// When sections contain explicit conditional predicates (e.g., `[>100]`),
266/// those conditions are evaluated against the value. Otherwise, the standard
267/// Excel sign-based selection (positive / negative / zero / text) is used.
268fn pick_section<'a>(sections: &[&'a str], value: f64) -> &'a str {
269    // Gather conditions from each section
270    let conditions: Vec<Option<Condition>> =
271        sections.iter().map(|s| extract_condition(s)).collect();
272
273    let has_any_condition = conditions.iter().any(|c| c.is_some());
274
275    if has_any_condition {
276        // Find the first section whose condition matches
277        for (i, cond) in conditions.iter().enumerate() {
278            if let Some(c) = cond {
279                if c.matches(value) {
280                    return sections[i];
281                }
282            }
283        }
284        // No conditional section matched: use the first section without a condition
285        // as the fallback (this is how Excel handles it)
286        for (i, cond) in conditions.iter().enumerate() {
287            if cond.is_none() {
288                return sections[i];
289            }
290        }
291        // All sections have conditions and none matched: use the last section
292        return sections.last().unwrap_or(&"General");
293    }
294
295    // Standard sign-based selection (no conditional predicates)
296    match sections.len() {
297        1 => sections[0],
298        2 => {
299            if value >= 0.0 {
300                sections[0]
301            } else {
302                sections[1]
303            }
304        }
305        3 | 4.. => {
306            if value > 0.0 {
307                sections[0]
308            } else if value < 0.0 {
309                sections[1]
310            } else {
311                sections[2]
312            }
313        }
314        _ => "General",
315    }
316}
317
318/// Strip color codes and conditional predicates from a format section,
319/// returning the cleaned format string and the color name (if any).
320fn strip_color_and_condition(section: &str) -> (String, Option<String>) {
321    let mut result = String::with_capacity(section.len());
322    let mut color = None;
323    let mut chars = section.chars().peekable();
324
325    while let Some(&ch) = chars.peek() {
326        if ch == '[' {
327            let mut bracket_content = String::new();
328            chars.next(); // consume '['
329            while let Some(&c) = chars.peek() {
330                if c == ']' {
331                    chars.next(); // consume ']'
332                    break;
333                }
334                bracket_content.push(c);
335                chars.next();
336            }
337            let lower = bracket_content.to_ascii_lowercase();
338            if is_color_code(&lower) {
339                color = Some(bracket_content);
340            } else if lower.starts_with('h') && lower.contains(':') {
341                // Elapsed time bracket like [h] or [hh]
342                result.push('[');
343                result.push_str(&bracket_content);
344                result.push(']');
345            } else if lower.starts_with("mm") || lower.starts_with("ss") {
346                result.push('[');
347                result.push_str(&bracket_content);
348                result.push(']');
349            } else if parse_condition(&bracket_content).is_some() {
350                // Conditional predicate -- strip from format string
351                // (condition is evaluated during section selection)
352            } else if lower.starts_with("dbnum")
353                || lower.starts_with("$")
354                || lower.starts_with("natnum")
355            {
356                // Locale or special -- skip
357            } else {
358                // Unknown bracket, preserve
359                result.push('[');
360                result.push_str(&bracket_content);
361                result.push(']');
362            }
363        } else {
364            result.push(ch);
365            chars.next();
366        }
367    }
368
369    (result, color)
370}
371
372fn is_color_code(lower: &str) -> bool {
373    matches!(
374        lower,
375        "red"
376            | "blue"
377            | "green"
378            | "yellow"
379            | "cyan"
380            | "magenta"
381            | "white"
382            | "black"
383            | "color1"
384            | "color2"
385            | "color3"
386            | "color4"
387            | "color5"
388            | "color6"
389            | "color7"
390            | "color8"
391            | "color9"
392            | "color10"
393    )
394}
395
396fn is_date_time_format(format: &str) -> bool {
397    let mut in_quotes = false;
398    let mut prev_backslash = false;
399    for ch in format.chars() {
400        if prev_backslash {
401            prev_backslash = false;
402            continue;
403        }
404        if ch == '\\' {
405            prev_backslash = true;
406            continue;
407        }
408        if ch == '"' {
409            in_quotes = !in_quotes;
410            continue;
411        }
412        if in_quotes {
413            continue;
414        }
415        let lower = ch.to_ascii_lowercase();
416        if matches!(lower, 'y' | 'd' | 'h' | 's') {
417            return true;
418        }
419        if lower == 'm' {
420            return true;
421        }
422    }
423    false
424}
425
426fn format_date_time(value: f64, format: &str) -> String {
427    let int_part = value.floor() as i64;
428    let frac = value.fract().abs();
429    let total_seconds = (frac * 86_400.0).round() as u64;
430    let mut hours = (total_seconds / 3600) as u32;
431    let minutes = ((total_seconds % 3600) / 60) as u32;
432    let seconds = (total_seconds % 60) as u32;
433    let subsec_frac = (frac * 86_400.0) - (total_seconds as f64);
434
435    let date_opt = serial_to_date(value);
436    let (year, month, day) = if let Some(date) = date_opt {
437        (date.year() as u32, date.month(), date.day())
438    } else {
439        (1900, 1, 1)
440    };
441
442    let has_ampm = {
443        let lower = format.to_ascii_lowercase();
444        lower.contains("am/pm") || lower.contains("a/p")
445    };
446
447    let mut ampm_str = "";
448    if has_ampm {
449        if hours == 0 {
450            hours = 12;
451            ampm_str = "AM";
452        } else if hours < 12 {
453            ampm_str = "AM";
454        } else if hours == 12 {
455            ampm_str = "PM";
456        } else {
457            hours -= 12;
458            ampm_str = "PM";
459        }
460    }
461
462    let month_names_short = [
463        "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
464    ];
465    let month_names_long = [
466        "January",
467        "February",
468        "March",
469        "April",
470        "May",
471        "June",
472        "July",
473        "August",
474        "September",
475        "October",
476        "November",
477        "December",
478    ];
479
480    let mut result = String::with_capacity(format.len() * 2);
481    let chars: Vec<char> = format.chars().collect();
482    let len = chars.len();
483    let mut i = 0;
484    let mut in_quotes = false;
485
486    while i < len {
487        let ch = chars[i];
488
489        if ch == '"' {
490            in_quotes = !in_quotes;
491            i += 1;
492            continue;
493        }
494        if in_quotes {
495            result.push(ch);
496            i += 1;
497            continue;
498        }
499        if ch == '\\' && i + 1 < len {
500            result.push(chars[i + 1]);
501            i += 2;
502            continue;
503        }
504
505        // Skip padding/spacing characters: _ followed by a character
506        if ch == '_' && i + 1 < len {
507            result.push(' ');
508            i += 2;
509            continue;
510        }
511        // Skip * repetition fill
512        if ch == '*' && i + 1 < len {
513            i += 2;
514            continue;
515        }
516
517        let lower = ch.to_ascii_lowercase();
518
519        if lower == 'y' {
520            let count = count_char(&chars, i, 'y');
521            if count <= 2 {
522                result.push_str(&format!("{:02}", year % 100));
523            } else {
524                result.push_str(&format!("{:04}", year));
525            }
526            i += count;
527            continue;
528        }
529
530        if lower == 'm' {
531            let count = count_char(&chars, i, 'm');
532            if is_m_minute_context(&chars, i) {
533                // Minutes
534                if count == 1 {
535                    result.push_str(&format!("{}", minutes));
536                } else {
537                    result.push_str(&format!("{:02}", minutes));
538                }
539            } else {
540                // Month
541                match count {
542                    1 => result.push_str(&format!("{}", month)),
543                    2 => result.push_str(&format!("{:02}", month)),
544                    3 => {
545                        if (1..=12).contains(&month) {
546                            result.push_str(month_names_short[(month - 1) as usize]);
547                        }
548                    }
549                    4 => {
550                        if (1..=12).contains(&month) {
551                            result.push_str(month_names_long[(month - 1) as usize]);
552                        }
553                    }
554                    _ => {
555                        result.push_str(&format!("{:02}", month));
556                    }
557                }
558            }
559            i += count;
560            continue;
561        }
562
563        if lower == 'd' {
564            let count = count_char(&chars, i, 'd');
565            match count {
566                1 => result.push_str(&format!("{}", day)),
567                2 => result.push_str(&format!("{:02}", day)),
568                3 => {
569                    if let Some(date) = date_opt {
570                        let day_names = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
571                        let wd = date.weekday().num_days_from_monday() as usize;
572                        result.push_str(day_names[wd]);
573                    }
574                }
575                _ => {
576                    if let Some(date) = date_opt {
577                        let day_names = [
578                            "Monday",
579                            "Tuesday",
580                            "Wednesday",
581                            "Thursday",
582                            "Friday",
583                            "Saturday",
584                            "Sunday",
585                        ];
586                        let wd = date.weekday().num_days_from_monday() as usize;
587                        result.push_str(day_names[wd]);
588                    }
589                }
590            }
591            i += count;
592            continue;
593        }
594
595        if lower == 'h' {
596            let count = count_char(&chars, i, 'h');
597            // Check for elapsed hours [h]
598            if i > 0 && chars[i - 1] == '[' {
599                // Elapsed hours: total hours from the serial number
600                let serial_days = value.floor() as i64;
601                let elapsed_h = (serial_days as u64) * 24 + total_seconds / 3600;
602                // Find the closing bracket after the 'h' tokens
603                let mut end = i + count;
604                if end < len && chars[end] == ']' {
605                    end += 1; // skip the ']'
606                }
607                result.push_str(&format!("{}", elapsed_h));
608                i = end;
609                continue;
610            }
611            if count == 1 {
612                result.push_str(&format!("{}", hours));
613            } else {
614                result.push_str(&format!("{:02}", hours));
615            }
616            i += count;
617            continue;
618        }
619
620        if lower == 's' {
621            let count = count_char(&chars, i, 's');
622            if count == 1 {
623                result.push_str(&format!("{}", seconds));
624            } else {
625                result.push_str(&format!("{:02}", seconds));
626            }
627            i += count;
628            continue;
629        }
630
631        // AM/PM or A/P
632        if lower == 'a' {
633            if i + 4 < len {
634                let slice: String = chars[i..i + 5].iter().collect();
635                if slice.eq_ignore_ascii_case("AM/PM") {
636                    result.push_str(ampm_str);
637                    i += 5;
638                    continue;
639                }
640            }
641            if i + 2 < len {
642                let slice: String = chars[i..i + 3].iter().collect();
643                if slice.eq_ignore_ascii_case("A/P") {
644                    if ampm_str == "AM" {
645                        result.push('A');
646                    } else {
647                        result.push('P');
648                    }
649                    i += 3;
650                    continue;
651                }
652            }
653            result.push(ch);
654            i += 1;
655            continue;
656        }
657
658        // Elapsed time brackets [h], [mm], [ss]
659        if ch == '[' {
660            // Look ahead to see if this is [h], [hh], [mm], [ss]
661            if i + 2 < len && chars[i + 1].eq_ignore_ascii_case(&'h') {
662                // Let the 'h' handler deal with it -- pass the '['
663                result.push(ch);
664                i += 1;
665                continue;
666            }
667            if i + 2 < len && chars[i + 1].eq_ignore_ascii_case(&'m') {
668                let count = count_char(&chars, i + 1, 'm');
669                let end = i + 1 + count;
670                if end < len && chars[end] == ']' {
671                    let elapsed_m = (int_part as u64) * 24 * 60 + total_seconds / 60;
672                    result.push_str(&format!("{}", elapsed_m));
673                    i = end + 1;
674                    continue;
675                }
676            }
677            if i + 2 < len && chars[i + 1].eq_ignore_ascii_case(&'s') {
678                let count = count_char(&chars, i + 1, 's');
679                let end = i + 1 + count;
680                if end < len && chars[end] == ']' {
681                    let elapsed_s = (int_part as u64) * 24 * 3600 + total_seconds;
682                    result.push_str(&format!("{}", elapsed_s));
683                    i = end + 1;
684                    continue;
685                }
686            }
687            result.push(ch);
688            i += 1;
689            continue;
690        }
691
692        if ch == '.' && i + 1 < len && chars[i + 1] == '0' {
693            // Fractional seconds
694            result.push('.');
695            let count = count_char(&chars, i + 1, '0');
696            let sub = subsec_frac.abs();
697            let digits = format!("{:.*}", count, sub);
698            // digits is like "0.xxx", take the part after '.'
699            if let Some(dot_pos) = digits.find('.') {
700                result.push_str(&digits[dot_pos + 1..]);
701            }
702            i += 1 + count;
703            continue;
704        }
705
706        // Pass through separators and other literal characters
707        result.push(ch);
708        i += 1;
709    }
710
711    result
712}
713
714/// Determine whether an 'm' token at position `pos` in the format chars
715/// should be interpreted as minutes (true) or months (false).
716/// Minutes if there is a preceding 'h' or a following 's' (skipping separators).
717fn is_m_minute_context(chars: &[char], pos: usize) -> bool {
718    // Look backwards for 'h', skipping ':', ' ', digits, brackets
719    let mut j = pos;
720    while j > 0 {
721        j -= 1;
722        let c = chars[j].to_ascii_lowercase();
723        if c == 'h' {
724            return true;
725        }
726        if c == ':' || c == ' ' || c == ']' || c == '[' {
727            continue;
728        }
729        break;
730    }
731    // Look forwards past the 'm' run for 's', skipping ':', ' ', digits, '.'
732    let m_count = count_char(chars, pos, 'm');
733    let mut k = pos + m_count;
734    while k < chars.len() {
735        let c = chars[k].to_ascii_lowercase();
736        if c == 's' {
737            return true;
738        }
739        if c == ':' || c == ' ' || c == '0' || c == '.' {
740            k += 1;
741            continue;
742        }
743        break;
744    }
745    false
746}
747
748fn count_char(chars: &[char], start: usize, target: char) -> usize {
749    let lower_target = target.to_ascii_lowercase();
750    let mut count = 0;
751    let mut i = start;
752    while i < chars.len() && chars[i].to_ascii_lowercase() == lower_target {
753        count += 1;
754        i += 1;
755    }
756    count
757}
758
759fn format_numeric(value: f64, format: &str) -> String {
760    let is_negative = value < 0.0;
761    let abs_val = value.abs();
762
763    // Parse the format to understand: digit placeholders, decimal places,
764    // comma grouping, percent, underscore/star padding, and literal text.
765    let has_percent = format_has_unquoted_char(format, '%');
766    let display_val = if has_percent {
767        abs_val * 100.0
768    } else {
769        abs_val
770    };
771
772    // Count decimal places from the format
773    let decimal_places = count_decimal_places(format);
774
775    // Check for thousands separator (comma grouping)
776    let has_comma_grouping = has_thousands_separator(format);
777
778    // Check for trailing commas (divide by 1000 per comma)
779    let trailing_comma_count = count_trailing_commas(format);
780    let display_val = display_val / 1000f64.powi(trailing_comma_count as i32);
781
782    // Round to the number of decimal places
783    let rounded = if decimal_places > 0 {
784        let factor = 10f64.powi(decimal_places as i32);
785        (display_val * factor).round() / factor
786    } else {
787        display_val.round()
788    };
789
790    let int_part = rounded.trunc() as u64;
791    let frac_part =
792        ((rounded - rounded.trunc()).abs() * 10f64.powi(decimal_places as i32)).round() as u64;
793
794    // Format integer part
795    let int_str = format!("{}", int_part);
796    let int_display = if has_comma_grouping {
797        add_thousands_separators(&int_str)
798    } else {
799        int_str.clone()
800    };
801
802    // Count required integer digits (from '0' placeholders in integer part)
803    let min_int_digits = count_integer_zeros(format);
804    let padded_int = if int_display.len() < min_int_digits && int_part == 0 {
805        let needed = min_int_digits - int_display.len();
806        let mut s = "0".repeat(needed);
807        s.push_str(&int_display);
808        if has_comma_grouping {
809            add_thousands_separators(&s)
810        } else {
811            s
812        }
813    } else {
814        int_display
815    };
816
817    // Build the output by walking through the format pattern
818    // For simplicity, output the formatted number with appropriate decorations
819    let mut output = String::with_capacity(format.len() + 10);
820    let chars: Vec<char> = format.chars().collect();
821    let len = chars.len();
822    let mut i = 0;
823    let mut in_quotes = false;
824    let mut number_placed = false;
825
826    while i < len {
827        let ch = chars[i];
828
829        if ch == '"' {
830            in_quotes = !in_quotes;
831            i += 1;
832            continue;
833        }
834        if in_quotes {
835            output.push(ch);
836            i += 1;
837            continue;
838        }
839        if ch == '\\' && i + 1 < len {
840            output.push(chars[i + 1]);
841            i += 2;
842            continue;
843        }
844        if ch == '_' && i + 1 < len {
845            output.push(' ');
846            i += 2;
847            continue;
848        }
849        if ch == '*' && i + 1 < len {
850            i += 2;
851            continue;
852        }
853
854        if (ch == '0' || ch == '#' || ch == ',') && !number_placed {
855            // Find the end of the numeric pattern
856            let num_end = find_numeric_end(&chars, i);
857            // Place the formatted number
858            let num_str = if decimal_places > 0 {
859                let frac_str = format!("{:0>width$}", frac_part, width = decimal_places);
860                format!("{}.{}", padded_int, frac_str)
861            } else {
862                padded_int.clone()
863            };
864
865            if is_negative {
866                output.push('-');
867            }
868            output.push_str(&num_str);
869            number_placed = true;
870            i = num_end;
871            continue;
872        }
873
874        if ch == '.' && !number_placed {
875            // This dot is part of the numeric pattern, handle together
876            continue;
877        }
878
879        if ch == '%' {
880            output.push('%');
881            i += 1;
882            continue;
883        }
884
885        if ch == '(' || ch == ')' || ch == '-' || ch == '+' || ch == ' ' || ch == ':' || ch == '/' {
886            output.push(ch);
887            i += 1;
888            continue;
889        }
890
891        if ch == '0' || ch == '#' || ch == ',' || ch == '.' {
892            i += 1;
893            continue;
894        }
895
896        output.push(ch);
897        i += 1;
898    }
899
900    // If no number was placed, check whether the format actually has digit placeholders.
901    // Formats like `"-"` (pure literal text) should not emit a number at all.
902    if !number_placed {
903        let has_digit_placeholder = format.chars().any(|c| c == '0' || c == '#');
904        if has_digit_placeholder {
905            if is_negative {
906                output.push('-');
907            }
908            if decimal_places > 0 {
909                let frac_str = format!("{:0>width$}", frac_part, width = decimal_places);
910                output.push_str(&format!("{}.{}", padded_int, frac_str));
911            } else {
912                output.push_str(&padded_int);
913            }
914        }
915    }
916
917    output
918}
919
920fn format_has_unquoted_char(format: &str, target: char) -> bool {
921    let mut in_quotes = false;
922    let mut prev_backslash = false;
923    for ch in format.chars() {
924        if prev_backslash {
925            prev_backslash = false;
926            continue;
927        }
928        if ch == '\\' {
929            prev_backslash = true;
930            continue;
931        }
932        if ch == '"' {
933            in_quotes = !in_quotes;
934            continue;
935        }
936        if !in_quotes && ch == target {
937            return true;
938        }
939    }
940    false
941}
942
943fn count_decimal_places(format: &str) -> usize {
944    let mut in_quotes = false;
945    let mut prev_backslash = false;
946    let mut found_dot = false;
947    let mut count = 0;
948
949    for ch in format.chars() {
950        if prev_backslash {
951            prev_backslash = false;
952            continue;
953        }
954        if ch == '\\' {
955            prev_backslash = true;
956            continue;
957        }
958        if ch == '"' {
959            in_quotes = !in_quotes;
960            continue;
961        }
962        if in_quotes {
963            continue;
964        }
965        if ch == '.' && !found_dot {
966            found_dot = true;
967            continue;
968        }
969        if found_dot && (ch == '0' || ch == '#') {
970            count += 1;
971        } else if found_dot && ch != '0' && ch != '#' {
972            break;
973        }
974    }
975    count
976}
977
978fn has_thousands_separator(format: &str) -> bool {
979    let mut in_quotes = false;
980    let mut prev_backslash = false;
981    let chars: Vec<char> = format.chars().collect();
982
983    for (i, &ch) in chars.iter().enumerate() {
984        if prev_backslash {
985            prev_backslash = false;
986            continue;
987        }
988        if ch == '\\' {
989            prev_backslash = true;
990            continue;
991        }
992        if ch == '"' {
993            in_quotes = !in_quotes;
994            continue;
995        }
996        if in_quotes {
997            continue;
998        }
999        // A comma is a thousands separator if it appears between digit placeholders
1000        if ch == ',' {
1001            let has_digit_before = chars[..i].iter().rev().any(|&c| c == '0' || c == '#');
1002            let has_digit_after = chars[i + 1..].iter().any(|&c| c == '0' || c == '#');
1003            if has_digit_before && has_digit_after {
1004                return true;
1005            }
1006        }
1007    }
1008    false
1009}
1010
1011fn count_trailing_commas(format: &str) -> usize {
1012    let mut in_quotes = false;
1013    let mut prev_backslash = false;
1014    let chars: Vec<char> = format.chars().collect();
1015    let mut count = 0;
1016
1017    // Find the last digit placeholder, then count commas after it
1018    let mut last_digit_pos = None;
1019    for (i, &ch) in chars.iter().enumerate() {
1020        if prev_backslash {
1021            prev_backslash = false;
1022            continue;
1023        }
1024        if ch == '\\' {
1025            prev_backslash = true;
1026            continue;
1027        }
1028        if ch == '"' {
1029            in_quotes = !in_quotes;
1030            continue;
1031        }
1032        if in_quotes {
1033            continue;
1034        }
1035        if ch == '0' || ch == '#' {
1036            last_digit_pos = Some(i);
1037        }
1038    }
1039
1040    if let Some(pos) = last_digit_pos {
1041        for &ch in &chars[pos + 1..] {
1042            if ch == ',' {
1043                count += 1;
1044            } else {
1045                break;
1046            }
1047        }
1048    }
1049    count
1050}
1051
1052fn count_integer_zeros(format: &str) -> usize {
1053    let mut in_quotes = false;
1054    let mut prev_backslash = false;
1055    let mut count = 0;
1056    let mut found_dot = false;
1057
1058    for ch in format.chars() {
1059        if prev_backslash {
1060            prev_backslash = false;
1061            continue;
1062        }
1063        if ch == '\\' {
1064            prev_backslash = true;
1065            continue;
1066        }
1067        if ch == '"' {
1068            in_quotes = !in_quotes;
1069            continue;
1070        }
1071        if in_quotes {
1072            continue;
1073        }
1074        if ch == '.' {
1075            found_dot = true;
1076            continue;
1077        }
1078        if !found_dot && ch == '0' {
1079            count += 1;
1080        }
1081    }
1082    count
1083}
1084
1085fn add_thousands_separators(s: &str) -> String {
1086    let bytes = s.as_bytes();
1087    let len = bytes.len();
1088    if len <= 3 {
1089        return s.to_string();
1090    }
1091    let mut result = String::with_capacity(len + len / 3);
1092    let remainder = len % 3;
1093    if remainder > 0 {
1094        result.push_str(&s[..remainder]);
1095        if len > remainder {
1096            result.push(',');
1097        }
1098    }
1099    for (i, chunk) in s.as_bytes()[remainder..].chunks(3).enumerate() {
1100        if i > 0 {
1101            result.push(',');
1102        }
1103        result.push_str(std::str::from_utf8(chunk).unwrap_or(""));
1104    }
1105    result
1106}
1107
1108fn find_numeric_end(chars: &[char], start: usize) -> usize {
1109    let mut i = start;
1110    let mut in_quotes = false;
1111    while i < chars.len() {
1112        let ch = chars[i];
1113        if ch == '"' {
1114            in_quotes = !in_quotes;
1115            i += 1;
1116            continue;
1117        }
1118        if in_quotes {
1119            i += 1;
1120            continue;
1121        }
1122        if ch == '0' || ch == '#' || ch == ',' || ch == '.' {
1123            i += 1;
1124        } else {
1125            break;
1126        }
1127    }
1128    i
1129}
1130
1131fn format_scientific(value: f64, format: &str) -> String {
1132    let decimal_places = count_decimal_places(format);
1133    let formatted = format!("{:.*E}", decimal_places, value.abs());
1134
1135    // Split into mantissa and exponent
1136    let parts: Vec<&str> = formatted.split('E').collect();
1137    if parts.len() != 2 {
1138        return formatted;
1139    }
1140
1141    let mantissa = parts[0];
1142    let exp_str = parts[1];
1143    let exp: i32 = exp_str.parse().unwrap_or(0);
1144
1145    // Count the '0' digits after E+/E- in the format to determine exponent width
1146    let exp_width = count_exponent_zeros(format).max(2);
1147
1148    // Determine sign character for exponent
1149    let has_plus = format.contains("E+") || format.contains("e+");
1150    let exp_sign = if exp >= 0 {
1151        if has_plus {
1152            "+"
1153        } else {
1154            ""
1155        }
1156    } else {
1157        "-"
1158    };
1159
1160    let exp_display = format!(
1161        "{}{:0>width$}",
1162        exp_sign,
1163        exp.unsigned_abs(),
1164        width = exp_width
1165    );
1166
1167    let sign = if value < 0.0 { "-" } else { "" };
1168
1169    // Determine E vs e
1170    let e_char = if format.contains('e') { 'e' } else { 'E' };
1171
1172    format!("{}{}{}{}", sign, mantissa, e_char, exp_display)
1173}
1174
1175fn count_exponent_zeros(format: &str) -> usize {
1176    let upper = format.to_uppercase();
1177    if let Some(pos) = upper.find("E+").or_else(|| upper.find("E-")) {
1178        let after = &format[pos + 2..];
1179        after.chars().take_while(|&c| c == '0').count()
1180    } else {
1181        2
1182    }
1183}
1184
1185fn format_fraction(value: f64, format: &str) -> String {
1186    let abs = value.abs();
1187    let whole = abs.floor() as i64;
1188    let frac = abs - whole as f64;
1189
1190    let sign = if value < 0.0 { "-" } else { "" };
1191
1192    // Determine the maximum denominator from the denominator width (digits after '/')
1193    let denom_q_count = format
1194        .split('/')
1195        .nth(1)
1196        .map(|s| s.chars().filter(|&c| c == '?').count())
1197        .unwrap_or(1);
1198    let max_denom = if denom_q_count >= 4 {
1199        9999
1200    } else if denom_q_count >= 3 {
1201        999
1202    } else if denom_q_count >= 2 {
1203        99
1204    } else {
1205        9
1206    };
1207
1208    if frac < 1e-10 {
1209        if format.contains('#') {
1210            return format!("{}{}", sign, whole);
1211        }
1212        return format!("{}{}    ", sign, whole);
1213    }
1214
1215    // Find the best rational approximation
1216    let (num, den) = best_fraction(frac, max_denom);
1217
1218    let has_whole = format.contains('#');
1219
1220    if has_whole {
1221        if whole > 0 {
1222            format!("{}{} {}/{}", sign, whole, num, den)
1223        } else {
1224            format!("{}{}/{}", sign, num, den)
1225        }
1226    } else {
1227        let total_num = whole as u64 * den + num;
1228        format!("{}{}/{}", sign, total_num, den)
1229    }
1230}
1231
1232fn best_fraction(value: f64, max_denom: u64) -> (u64, u64) {
1233    if value <= 0.0 {
1234        return (0, 1);
1235    }
1236    let mut best_num = 0u64;
1237    let mut best_den = 1u64;
1238    let mut best_err = value.abs();
1239
1240    for den in 1..=max_denom {
1241        let num = (value * den as f64).round() as u64;
1242        if num == 0 {
1243            continue;
1244        }
1245        let err = (value - num as f64 / den as f64).abs();
1246        if err < best_err {
1247            best_err = err;
1248            best_num = num;
1249            best_den = den;
1250        }
1251        if best_err < 1e-10 {
1252            break;
1253        }
1254    }
1255    (best_num, best_den)
1256}
1257
1258use chrono::Datelike;
1259
1260#[cfg(test)]
1261mod tests {
1262    use super::*;
1263
1264    #[test]
1265    fn test_builtin_format_code_general() {
1266        assert_eq!(builtin_format_code(0), Some("General"));
1267    }
1268
1269    #[test]
1270    fn test_builtin_format_code_integer() {
1271        assert_eq!(builtin_format_code(1), Some("0"));
1272    }
1273
1274    #[test]
1275    fn test_builtin_format_code_decimal() {
1276        assert_eq!(builtin_format_code(2), Some("0.00"));
1277    }
1278
1279    #[test]
1280    fn test_builtin_format_code_thousands() {
1281        assert_eq!(builtin_format_code(3), Some("#,##0"));
1282    }
1283
1284    #[test]
1285    fn test_builtin_format_code_date() {
1286        assert_eq!(builtin_format_code(14), Some("m/d/yyyy"));
1287    }
1288
1289    #[test]
1290    fn test_builtin_format_code_text() {
1291        assert_eq!(builtin_format_code(49), Some("@"));
1292    }
1293
1294    #[test]
1295    fn test_builtin_format_code_unknown() {
1296        assert_eq!(builtin_format_code(100), None);
1297        assert_eq!(builtin_format_code(50), None);
1298    }
1299
1300    #[test]
1301    fn test_format_general_zero() {
1302        assert_eq!(format_number(0.0, "General"), "0");
1303    }
1304
1305    #[test]
1306    fn test_format_general_integer() {
1307        assert_eq!(format_number(42.0, "General"), "42");
1308        assert_eq!(format_number(-100.0, "General"), "-100");
1309    }
1310
1311    #[test]
1312    fn test_format_general_decimal() {
1313        assert_eq!(format_number(3.14, "General"), "3.14");
1314    }
1315
1316    #[test]
1317    fn test_format_general_large_number() {
1318        assert_eq!(format_number(1000000.0, "General"), "1000000");
1319    }
1320
1321    #[test]
1322    fn test_format_integer() {
1323        assert_eq!(format_number(42.0, "0"), "42");
1324        assert_eq!(format_number(42.7, "0"), "43");
1325        assert_eq!(format_number(0.0, "0"), "0");
1326    }
1327
1328    #[test]
1329    fn test_format_decimal_2() {
1330        assert_eq!(format_number(3.14159, "0.00"), "3.14");
1331        assert_eq!(format_number(3.0, "0.00"), "3.00");
1332        assert_eq!(format_number(0.5, "0.00"), "0.50");
1333    }
1334
1335    #[test]
1336    fn test_format_thousands() {
1337        assert_eq!(format_number(1234.0, "#,##0"), "1,234");
1338        assert_eq!(format_number(1234567.0, "#,##0"), "1,234,567");
1339        assert_eq!(format_number(999.0, "#,##0"), "999");
1340        assert_eq!(format_number(0.0, "#,##0"), "0");
1341    }
1342
1343    #[test]
1344    fn test_format_thousands_decimal() {
1345        assert_eq!(format_number(1234.56, "#,##0.00"), "1,234.56");
1346        assert_eq!(format_number(0.0, "#,##0.00"), "0.00");
1347    }
1348
1349    #[test]
1350    fn test_format_percent() {
1351        assert_eq!(format_number(0.75, "0%"), "75%");
1352        assert_eq!(format_number(0.5, "0%"), "50%");
1353        assert_eq!(format_number(1.0, "0%"), "100%");
1354    }
1355
1356    #[test]
1357    fn test_format_percent_decimal() {
1358        assert_eq!(format_number(0.7534, "0.00%"), "75.34%");
1359        assert_eq!(format_number(0.5, "0.00%"), "50.00%");
1360    }
1361
1362    #[test]
1363    fn test_format_scientific() {
1364        let result = format_number(1234.5, "0.00E+00");
1365        assert_eq!(result, "1.23E+03");
1366    }
1367
1368    #[test]
1369    fn test_format_scientific_small() {
1370        let result = format_number(0.001, "0.00E+00");
1371        assert_eq!(result, "1.00E-03");
1372    }
1373
1374    #[test]
1375    fn test_format_date_mdy() {
1376        // 2024-01-15 = serial 45306
1377        let serial =
1378            crate::cell::date_to_serial(chrono::NaiveDate::from_ymd_opt(2024, 1, 15).unwrap());
1379        assert_eq!(format_number(serial, "m/d/yyyy"), "1/15/2024");
1380    }
1381
1382    #[test]
1383    fn test_format_date_dmy() {
1384        let serial =
1385            crate::cell::date_to_serial(chrono::NaiveDate::from_ymd_opt(2024, 3, 5).unwrap());
1386        assert_eq!(format_number(serial, "d-mmm-yy"), "5-Mar-24");
1387    }
1388
1389    #[test]
1390    fn test_format_date_dm() {
1391        let serial =
1392            crate::cell::date_to_serial(chrono::NaiveDate::from_ymd_opt(2024, 6, 15).unwrap());
1393        assert_eq!(format_number(serial, "d-mmm"), "15-Jun");
1394    }
1395
1396    #[test]
1397    fn test_format_date_my() {
1398        let serial =
1399            crate::cell::date_to_serial(chrono::NaiveDate::from_ymd_opt(2024, 12, 1).unwrap());
1400        assert_eq!(format_number(serial, "mmm-yy"), "Dec-24");
1401    }
1402
1403    #[test]
1404    fn test_format_time_hm_ampm() {
1405        let serial = crate::cell::datetime_to_serial(
1406            chrono::NaiveDate::from_ymd_opt(2024, 1, 1)
1407                .unwrap()
1408                .and_hms_opt(14, 30, 0)
1409                .unwrap(),
1410        );
1411        assert_eq!(format_number(serial, "h:mm AM/PM"), "2:30 PM");
1412    }
1413
1414    #[test]
1415    fn test_format_time_hm_ampm_morning() {
1416        let serial = crate::cell::datetime_to_serial(
1417            chrono::NaiveDate::from_ymd_opt(2024, 1, 1)
1418                .unwrap()
1419                .and_hms_opt(9, 5, 0)
1420                .unwrap(),
1421        );
1422        assert_eq!(format_number(serial, "h:mm AM/PM"), "9:05 AM");
1423    }
1424
1425    #[test]
1426    fn test_format_time_hms() {
1427        let serial = crate::cell::datetime_to_serial(
1428            chrono::NaiveDate::from_ymd_opt(2024, 1, 1)
1429                .unwrap()
1430                .and_hms_opt(14, 30, 45)
1431                .unwrap(),
1432        );
1433        assert_eq!(format_number(serial, "h:mm:ss"), "14:30:45");
1434    }
1435
1436    #[test]
1437    fn test_format_time_hm_24h() {
1438        let serial = crate::cell::datetime_to_serial(
1439            chrono::NaiveDate::from_ymd_opt(2024, 1, 1)
1440                .unwrap()
1441                .and_hms_opt(14, 30, 0)
1442                .unwrap(),
1443        );
1444        assert_eq!(format_number(serial, "h:mm"), "14:30");
1445    }
1446
1447    #[test]
1448    fn test_format_datetime_combined() {
1449        let serial = crate::cell::datetime_to_serial(
1450            chrono::NaiveDate::from_ymd_opt(2024, 1, 15)
1451                .unwrap()
1452                .and_hms_opt(14, 30, 0)
1453                .unwrap(),
1454        );
1455        assert_eq!(format_number(serial, "m/d/yyyy h:mm"), "1/15/2024 14:30");
1456    }
1457
1458    #[test]
1459    fn test_format_date_yyyy_mm_dd() {
1460        let serial =
1461            crate::cell::date_to_serial(chrono::NaiveDate::from_ymd_opt(2024, 6, 15).unwrap());
1462        assert_eq!(format_number(serial, "yyyy-mm-dd"), "2024-06-15");
1463    }
1464
1465    #[test]
1466    fn test_format_date_dd_mm_yyyy() {
1467        let serial =
1468            crate::cell::date_to_serial(chrono::NaiveDate::from_ymd_opt(2024, 6, 15).unwrap());
1469        assert_eq!(format_number(serial, "dd/mm/yyyy"), "15/06/2024");
1470    }
1471
1472    #[test]
1473    fn test_format_text_passthrough() {
1474        assert_eq!(format_number(42.0, "@"), "42");
1475    }
1476
1477    #[test]
1478    fn test_format_multi_section_positive_negative() {
1479        assert_eq!(format_number(42.0, "#,##0;-#,##0"), "42");
1480        assert_eq!(format_number(-42.0, "#,##0;-#,##0"), "-42");
1481    }
1482
1483    #[test]
1484    fn test_format_multi_section_three_parts() {
1485        assert_eq!(format_number(42.0, "#,##0;-#,##0;\"zero\""), "42");
1486        assert_eq!(format_number(-42.0, "#,##0;-#,##0;\"zero\""), "-42");
1487    }
1488
1489    #[test]
1490    fn test_format_color_stripped() {
1491        assert_eq!(format_number(42.0, "[Red]0"), "42");
1492        assert_eq!(format_number(42.0, "[Blue]0.00"), "42.00");
1493    }
1494
1495    #[test]
1496    fn test_format_with_builtin_general() {
1497        assert_eq!(format_with_builtin(42.0, 0), Some("42".to_string()));
1498    }
1499
1500    #[test]
1501    fn test_format_with_builtin_percent() {
1502        assert_eq!(format_with_builtin(0.5, 9), Some("50%".to_string()));
1503    }
1504
1505    #[test]
1506    fn test_format_with_builtin_unknown() {
1507        assert_eq!(format_with_builtin(42.0, 100), None);
1508    }
1509
1510    #[test]
1511    fn test_format_fraction_simple() {
1512        let result = format_number(1.5, "# ?/?");
1513        assert_eq!(result, "1 1/2");
1514    }
1515
1516    #[test]
1517    fn test_format_fraction_two_digit() {
1518        let result = format_number(0.333, "# ??/??");
1519        // Should approximate to something close to 1/3
1520        assert!(result.contains("/"), "result was: {}", result);
1521    }
1522
1523    #[test]
1524    fn test_format_negative_in_parens() {
1525        let result = format_number(-1234.0, "#,##0_);(#,##0)");
1526        assert!(result.contains("1,234"), "result was: {}", result);
1527        assert!(result.contains("("), "result was: {}", result);
1528    }
1529
1530    #[test]
1531    fn test_format_empty_format_uses_general() {
1532        assert_eq!(format_number(42.0, ""), "42");
1533    }
1534
1535    #[test]
1536    fn test_format_date_long_month() {
1537        let serial =
1538            crate::cell::date_to_serial(chrono::NaiveDate::from_ymd_opt(2024, 1, 15).unwrap());
1539        assert_eq!(format_number(serial, "d mmmm yyyy"), "15 January 2024");
1540    }
1541
1542    #[test]
1543    fn test_format_time_ampm_midnight() {
1544        let serial =
1545            crate::cell::date_to_serial(chrono::NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
1546        // Midnight = no fractional part
1547        assert_eq!(format_number(serial, "h:mm AM/PM"), "12:00 AM");
1548    }
1549
1550    #[test]
1551    fn test_format_time_ampm_noon() {
1552        let serial = crate::cell::datetime_to_serial(
1553            chrono::NaiveDate::from_ymd_opt(2024, 1, 1)
1554                .unwrap()
1555                .and_hms_opt(12, 0, 0)
1556                .unwrap(),
1557        );
1558        assert_eq!(format_number(serial, "h:mm AM/PM"), "12:00 PM");
1559    }
1560
1561    #[test]
1562    fn test_format_builtin_mmss() {
1563        let serial = crate::cell::datetime_to_serial(
1564            chrono::NaiveDate::from_ymd_opt(2024, 1, 1)
1565                .unwrap()
1566                .and_hms_opt(0, 5, 30)
1567                .unwrap(),
1568        );
1569        assert_eq!(format_number(serial, "mm:ss"), "05:30");
1570    }
1571
1572    #[test]
1573    fn test_format_general_negative_decimal() {
1574        let result = format_number(-3.14, "General");
1575        assert_eq!(result, "-3.14");
1576    }
1577
1578    #[test]
1579    fn test_format_date_two_digit_year() {
1580        let serial =
1581            crate::cell::date_to_serial(chrono::NaiveDate::from_ymd_opt(2024, 6, 15).unwrap());
1582        assert_eq!(format_number(serial, "yy"), "24");
1583    }
1584
1585    #[test]
1586    fn test_format_thousands_separator_with_zero() {
1587        assert_eq!(add_thousands_separators("0"), "0");
1588        assert_eq!(add_thousands_separators("100"), "100");
1589        assert_eq!(add_thousands_separators("1000"), "1,000");
1590        assert_eq!(add_thousands_separators("1000000"), "1,000,000");
1591    }
1592
1593    #[test]
1594    fn test_parse_sections_single() {
1595        let sections = parse_sections("0.00");
1596        assert_eq!(sections, vec!["0.00"]);
1597    }
1598
1599    #[test]
1600    fn test_parse_sections_multi() {
1601        let sections = parse_sections("0.00;-0.00;\"zero\"");
1602        assert_eq!(sections, vec!["0.00", "-0.00", "\"zero\""]);
1603    }
1604
1605    #[test]
1606    fn test_parse_sections_quoted_semicolon() {
1607        let sections = parse_sections("\"a;b\"");
1608        assert_eq!(sections, vec!["\"a;b\""]);
1609    }
1610
1611    #[test]
1612    fn test_strip_color() {
1613        let (cleaned, color) = strip_color_and_condition("[Red]0.00");
1614        assert_eq!(cleaned, "0.00");
1615        assert_eq!(color, Some("Red".to_string()));
1616    }
1617
1618    #[test]
1619    fn test_strip_condition() {
1620        let (cleaned, _) = strip_color_and_condition("[>100]0.00");
1621        assert_eq!(cleaned, "0.00");
1622    }
1623
1624    #[test]
1625    fn test_is_date_time_format_checks() {
1626        assert!(is_date_time_format("yyyy-mm-dd"));
1627        assert!(is_date_time_format("h:mm:ss"));
1628        assert!(is_date_time_format("m/d/yyyy"));
1629        assert!(!is_date_time_format("0.00"));
1630        assert!(!is_date_time_format("#,##0"));
1631        assert!(!is_date_time_format("\"yyyy\"0"));
1632    }
1633
1634    #[test]
1635    fn test_count_decimal_places_none() {
1636        assert_eq!(count_decimal_places("0"), 0);
1637        assert_eq!(count_decimal_places("#,##0"), 0);
1638    }
1639
1640    #[test]
1641    fn test_count_decimal_places_two() {
1642        assert_eq!(count_decimal_places("0.00"), 2);
1643        assert_eq!(count_decimal_places("#,##0.00"), 2);
1644    }
1645
1646    #[test]
1647    fn test_count_decimal_places_three() {
1648        assert_eq!(count_decimal_places("0.000"), 3);
1649    }
1650
1651    #[test]
1652    fn test_parse_condition_operators() {
1653        let c = parse_condition(">100").unwrap();
1654        assert_eq!(c.op, ConditionOp::Gt);
1655        assert_eq!(c.threshold, 100.0);
1656
1657        let c = parse_condition(">=50").unwrap();
1658        assert_eq!(c.op, ConditionOp::Ge);
1659        assert_eq!(c.threshold, 50.0);
1660
1661        let c = parse_condition("<1000").unwrap();
1662        assert_eq!(c.op, ConditionOp::Lt);
1663        assert_eq!(c.threshold, 1000.0);
1664
1665        let c = parse_condition("<=0").unwrap();
1666        assert_eq!(c.op, ConditionOp::Le);
1667        assert_eq!(c.threshold, 0.0);
1668
1669        let c = parse_condition("=0").unwrap();
1670        assert_eq!(c.op, ConditionOp::Eq);
1671        assert_eq!(c.threshold, 0.0);
1672
1673        let c = parse_condition("<>5").unwrap();
1674        assert_eq!(c.op, ConditionOp::Ne);
1675        assert_eq!(c.threshold, 5.0);
1676
1677        let c = parse_condition("!=5").unwrap();
1678        assert_eq!(c.op, ConditionOp::Ne);
1679        assert_eq!(c.threshold, 5.0);
1680
1681        assert!(parse_condition("Red").is_none());
1682        assert!(parse_condition("").is_none());
1683    }
1684
1685    #[test]
1686    fn test_condition_matches() {
1687        let c = Condition {
1688            op: ConditionOp::Gt,
1689            threshold: 100.0,
1690        };
1691        assert!(c.matches(150.0));
1692        assert!(!c.matches(100.0));
1693        assert!(!c.matches(50.0));
1694    }
1695
1696    #[test]
1697    fn test_conditional_two_sections_color_and_condition() {
1698        // [Red][>100]0;[Blue][<=100]0
1699        let fmt = "[Red][>100]0;[Blue][<=100]0";
1700        assert_eq!(format_number(150.0, fmt), "150");
1701        assert_eq!(format_number(50.0, fmt), "50");
1702        assert_eq!(format_number(100.0, fmt), "100");
1703    }
1704
1705    #[test]
1706    fn test_conditional_three_sections_cascading() {
1707        // [>1000]#,##0;[>100]0.0;0.00
1708        let fmt = "[>1000]#,##0;[>100]0.0;0.00";
1709        assert_eq!(format_number(5000.0, fmt), "5,000");
1710        assert_eq!(format_number(500.0, fmt), "500.0");
1711        assert_eq!(format_number(50.0, fmt), "50.00");
1712    }
1713
1714    #[test]
1715    fn test_conditional_equals_zero() {
1716        // [=0]"zero";General -- value 0 matches first section, value 42 falls through
1717        let fmt = "[=0]\"zero\";0";
1718        assert_eq!(format_number(0.0, fmt), "zero");
1719        assert_eq!(format_number(42.0, fmt), "42");
1720    }
1721
1722    #[test]
1723    fn test_conditional_with_sign_format() {
1724        // [Red][>0]+0;[Blue][<0]-0;0
1725        let fmt = "[Red][>0]+0;[Blue][<0]-0;0";
1726        assert_eq!(format_number(5.0, fmt), "+5");
1727        assert_eq!(format_number(-3.0, fmt), "-3");
1728        assert_eq!(format_number(0.0, fmt), "0");
1729    }
1730
1731    #[test]
1732    fn test_extract_condition_from_section() {
1733        let cond = extract_condition("[Red][>100]0.00").unwrap();
1734        assert_eq!(cond.op, ConditionOp::Gt);
1735        assert_eq!(cond.threshold, 100.0);
1736
1737        let cond = extract_condition("[<=0]0");
1738        assert!(cond.is_some());
1739        assert_eq!(cond.unwrap().op, ConditionOp::Le);
1740
1741        assert!(extract_condition("[Red]0.00").is_none());
1742        assert!(extract_condition("0.00").is_none());
1743    }
1744}