simple_datetime_rs/
format.rs

1use crate::date::Date;
2use crate::date_error::{DateError, DateErrorKind};
3use crate::date_time::DateTime;
4use crate::time::Time;
5
6/// A format string parser and formatter for date/time objects
7#[derive(Debug, Clone)]
8pub struct FormatString {
9    parts: Vec<FormatPart>,
10}
11
12#[derive(Debug, Clone)]
13enum FormatPart {
14    Literal(String),
15    Specifier(FormatSpecifier),
16}
17
18#[derive(Debug, Clone)]
19enum FormatSpecifier {
20    // Date specifiers
21    Year,           // %Y - 4-digit year (e.g., 2023)
22    YearShort,      // %y - 2-digit year (e.g., 23)
23    Month,          // %m - Month as number (01-12)
24    MonthName,      // %B - Full month name (January, February, etc.)
25    MonthNameShort, // %b - Short month name (Jan, Feb, etc.)
26    Day,            // %d - Day of month (01-31)
27    DayOfYear,      // %j - Day of year (001-366)
28    Weekday,        // %A - Full weekday name (Monday, Tuesday, etc.)
29    WeekdayShort,   // %a - Short weekday name (Mon, Tue, etc.)
30    WeekdayNum,     // %w - Weekday as number (0=Sunday, 1=Monday, etc.)
31
32    // Time specifiers
33    Hour24,      // %H - Hour in 24-hour format (00-23)
34    Hour12,      // %I - Hour in 12-hour format (01-12)
35    Minute,      // %M - Minute (00-59)
36    Second,      // %S - Second (00-59)
37    Microsecond, // %f - Microsecond (000000-999999)
38    FractionalSecond(u8), // %.Nf - Fractional seconds with N digits (1-6)
39    AmPm,        // %p - AM/PM indicator
40
41    // Timezone specifiers
42    Timezone,      // %z - Timezone offset (+HHMM or -HHMM)
43    TimezoneColon, // %:z - Timezone offset with colon (+HH:MM or -HH:MM)
44
45    // Common combinations
46    Date,     // %D - Date in MM/DD/YY format
47    Time,     // %T - Time in HH:MM:SS format
48    DateTime, // %F - Date in YYYY-MM-DD format
49    Iso8601,  // %+ - ISO 8601 format
50}
51
52impl FormatString {
53    /// Parse a format string into format parts
54    pub fn new(format: &str) -> Result<Self, DateError> {
55        let mut parts = Vec::new();
56        let mut chars = format.chars().peekable();
57        let mut literal = String::new();
58
59        while let Some(ch) = chars.next() {
60            if ch == '%' {
61                if !literal.is_empty() {
62                    parts.push(FormatPart::Literal(literal.clone()));
63                    literal.clear();
64                }
65                let spec = match chars.next() {
66                    Some('Y') => FormatSpecifier::Year,
67                    Some('y') => FormatSpecifier::YearShort,
68                    Some('m') => FormatSpecifier::Month,
69                    Some('B') => FormatSpecifier::MonthName,
70                    Some('b') => FormatSpecifier::MonthNameShort,
71                    Some('d') => FormatSpecifier::Day,
72                    Some('j') => FormatSpecifier::DayOfYear,
73                    Some('A') => FormatSpecifier::Weekday,
74                    Some('a') => FormatSpecifier::WeekdayShort,
75                    Some('w') => FormatSpecifier::WeekdayNum,
76                    Some('H') => FormatSpecifier::Hour24,
77                    Some('I') => FormatSpecifier::Hour12,
78                    Some('M') => FormatSpecifier::Minute,
79                    Some('S') => FormatSpecifier::Second,
80                    Some('.') => {
81                        // Parse %.Nf where N is 1-6 digits, fallback to 6 digits if invalid
82                        let mut precision = 0u8;
83                        let mut temp_chars = chars.clone();
84                        let mut digit_count = 0;
85                        
86                        // Collect digits
87                        while let Some(&digit) = temp_chars.peek() {
88                            if digit.is_ascii_digit() {
89                                precision = precision * 10 + (digit as u8 - b'0');
90                                temp_chars.next();
91                                digit_count += 1;
92                            } else {
93                                break;
94                            }
95                        }
96                        
97                        // Check if we have 'f' after the digits
98                        if temp_chars.next() == Some('f') {
99                            // Consume the digits and 'f' from the original iterator
100                            for _ in 0..digit_count {
101                                chars.next();
102                            }
103                            chars.next(); // consume 'f'
104                            
105                            // Use fractional seconds if precision is valid (1-6), otherwise fallback to microsecond
106                            if precision >= 1 && precision <= 6 {
107                                FormatSpecifier::FractionalSecond(precision)
108                            } else {
109                                FormatSpecifier::Microsecond
110                            }
111                        } else {
112                            // No 'f' found, treat as literal '.' and continue
113                            literal.push('.');
114                            continue;
115                        }
116                    }
117                    Some('f') => FormatSpecifier::Microsecond,
118                    Some('p') => FormatSpecifier::AmPm,
119                    Some('z') => FormatSpecifier::Timezone,
120                    Some(':') if chars.peek() == Some(&'z') => {
121                        chars.next(); // consume the 'z'
122                        FormatSpecifier::TimezoneColon
123                    }
124                    Some('D') => FormatSpecifier::Date,
125                    Some('T') => FormatSpecifier::Time,
126                    Some('F') => FormatSpecifier::DateTime,
127                    Some('+') => FormatSpecifier::Iso8601,
128                    Some('%') => {
129                        literal.push('%');
130                        continue;
131                    }
132                    Some(_) => return Err(DateErrorKind::WrongDateTimeStringFormat.into()),
133                    None => return Err(DateErrorKind::WrongDateTimeStringFormat.into()),
134                };
135                parts.push(FormatPart::Specifier(spec));
136            } else {
137                literal.push(ch);
138            }
139        }
140
141        if !literal.is_empty() {
142            parts.push(FormatPart::Literal(literal));
143        }
144
145        Ok(FormatString { parts })
146    }
147
148    /// Format a DateTime using this format string
149    pub fn format_datetime(&self, dt: &DateTime) -> String {
150        let mut result = String::new();
151        for part in &self.parts {
152            match part {
153                FormatPart::Literal(s) => result.push_str(s),
154                FormatPart::Specifier(spec) => {
155                    result.push_str(&self.format_specifier(spec, dt));
156                }
157            }
158        }
159        result
160    }
161
162    /// Format a Date using this format string
163    pub fn format_date(&self, date: &Date) -> String {
164        let mut result = String::new();
165        for part in &self.parts {
166            match part {
167                FormatPart::Literal(s) => result.push_str(s),
168                FormatPart::Specifier(spec) => {
169                    result.push_str(&self.format_specifier_date(spec, date));
170                }
171            }
172        }
173        result
174    }
175
176    /// Format a Time using this format string
177    pub fn format_time(&self, time: &Time) -> String {
178        let mut result = String::new();
179        for part in &self.parts {
180            match part {
181                FormatPart::Literal(s) => result.push_str(s),
182                FormatPart::Specifier(spec) => {
183                    result.push_str(&self.format_specifier_time(spec, time));
184                }
185            }
186        }
187        result
188    }
189
190    fn format_specifier(&self, spec: &FormatSpecifier, dt: &DateTime) -> String {
191        match spec {
192            FormatSpecifier::Year => format!("{:04}", dt.date.year),
193            FormatSpecifier::YearShort => format!("{:02}", dt.date.year % 100),
194            FormatSpecifier::Month => format!("{:02}", dt.date.month),
195            FormatSpecifier::MonthName => self.month_name(dt.date.month),
196            FormatSpecifier::MonthNameShort => self.month_name_short(dt.date.month),
197            FormatSpecifier::Day => format!("{:02}", dt.date.day),
198            FormatSpecifier::DayOfYear => format!("{:03}", dt.date.year_day()),
199            FormatSpecifier::Weekday => self.weekday_name(&dt.date),
200            FormatSpecifier::WeekdayShort => self.weekday_name_short(&dt.date),
201            FormatSpecifier::WeekdayNum => format!("{}", self.weekday_number(&dt.date)),
202            FormatSpecifier::Hour24 => format!("{:02}", dt.time.hour),
203            FormatSpecifier::Hour12 => {
204                let hour = if dt.time.hour == 0 {
205                    12
206                } else if dt.time.hour > 12 {
207                    dt.time.hour - 12
208                } else {
209                    dt.time.hour
210                };
211                format!("{:02}", hour)
212            }
213            FormatSpecifier::Minute => format!("{:02}", dt.time.minute),
214            FormatSpecifier::Second => format!("{:02}", dt.time.second),
215            FormatSpecifier::Microsecond => format!(".{:06}", dt.time.microsecond),
216            FormatSpecifier::FractionalSecond(precision) => {
217                let divisor = 10u64.pow(6 - *precision as u32);
218                let value = dt.time.microsecond / divisor;
219                format!(".{:0width$}", value, width = *precision as usize)
220            }
221            FormatSpecifier::AmPm => if dt.time.hour < 12 { "AM" } else { "PM" }.to_string(),
222            FormatSpecifier::Timezone => self.format_timezone(dt.shift_minutes, false),
223            FormatSpecifier::TimezoneColon => self.format_timezone(dt.shift_minutes, true),
224            FormatSpecifier::Date => format!(
225                "{:02}/{:02}/{:02}",
226                dt.date.month,
227                dt.date.day,
228                dt.date.year % 100
229            ),
230            FormatSpecifier::Time => format!(
231                "{:02}:{:02}:{:02}",
232                dt.time.hour, dt.time.minute, dt.time.second
233            ),
234            FormatSpecifier::DateTime => format!(
235                "{:04}-{:02}-{:02}",
236                dt.date.year, dt.date.month, dt.date.day
237            ),
238            FormatSpecifier::Iso8601 => dt.to_iso_8061(),
239        }
240    }
241
242    fn format_specifier_date(&self, spec: &FormatSpecifier, date: &Date) -> String {
243        match spec {
244            FormatSpecifier::Year => format!("{:04}", date.year),
245            FormatSpecifier::YearShort => format!("{:02}", date.year % 100),
246            FormatSpecifier::Month => format!("{:02}", date.month),
247            FormatSpecifier::MonthName => self.month_name(date.month),
248            FormatSpecifier::MonthNameShort => self.month_name_short(date.month),
249            FormatSpecifier::Day => format!("{:02}", date.day),
250            FormatSpecifier::DayOfYear => format!("{:03}", date.year_day()),
251            FormatSpecifier::Weekday => self.weekday_name(date),
252            FormatSpecifier::WeekdayShort => self.weekday_name_short(date),
253            FormatSpecifier::WeekdayNum => format!("{}", self.weekday_number(date)),
254            FormatSpecifier::DateTime => {
255                format!("{:04}-{:02}-{:02}", date.year, date.month, date.day)
256            }
257            FormatSpecifier::Date => {
258                format!("{:02}/{:02}/{:02}", date.month, date.day, date.year % 100)
259            }
260            _ => String::new(), // Time-related specifiers are empty for Date
261        }
262    }
263
264    fn format_specifier_time(&self, spec: &FormatSpecifier, time: &Time) -> String {
265        match spec {
266            FormatSpecifier::Hour24 => format!("{:02}", time.hour),
267            FormatSpecifier::Hour12 => {
268                let hour = if time.hour == 0 {
269                    12
270                } else if time.hour > 12 {
271                    time.hour - 12
272                } else {
273                    time.hour
274                };
275                format!("{:02}", hour)
276            }
277            FormatSpecifier::Minute => format!("{:02}", time.minute),
278            FormatSpecifier::Second => format!("{:02}", time.second),
279            FormatSpecifier::Microsecond => format!(".{:06}", time.microsecond),
280            FormatSpecifier::FractionalSecond(precision) => {
281                let divisor = 10u64.pow(6 - *precision as u32);
282                let value = time.microsecond / divisor;
283                format!(".{:0width$}", value, width = *precision as usize)
284            }
285            FormatSpecifier::AmPm => if time.hour < 12 { "AM" } else { "PM" }.to_string(),
286            FormatSpecifier::Time => {
287                format!("{:02}:{:02}:{:02}", time.hour, time.minute, time.second)
288            }
289            _ => String::new(), // Date-related specifiers are empty for Time
290        }
291    }
292
293    fn month_name(&self, month: u64) -> String {
294        match month {
295            1 => "January".to_string(),
296            2 => "February".to_string(),
297            3 => "March".to_string(),
298            4 => "April".to_string(),
299            5 => "May".to_string(),
300            6 => "June".to_string(),
301            7 => "July".to_string(),
302            8 => "August".to_string(),
303            9 => "September".to_string(),
304            10 => "October".to_string(),
305            11 => "November".to_string(),
306            12 => "December".to_string(),
307            _ => "Unknown".to_string(),
308        }
309    }
310
311    fn month_name_short(&self, month: u64) -> String {
312        match month {
313            1 => "Jan".to_string(),
314            2 => "Feb".to_string(),
315            3 => "Mar".to_string(),
316            4 => "Apr".to_string(),
317            5 => "May".to_string(),
318            6 => "Jun".to_string(),
319            7 => "Jul".to_string(),
320            8 => "Aug".to_string(),
321            9 => "Sep".to_string(),
322            10 => "Oct".to_string(),
323            11 => "Nov".to_string(),
324            12 => "Dec".to_string(),
325            _ => "Unknown".to_string(),
326        }
327    }
328
329    fn weekday_name(&self, date: &Date) -> String {
330        if date.is_sunday() {
331            "Sunday".to_string()
332        } else if date.is_monday() {
333            "Monday".to_string()
334        } else if date.is_tuesday() {
335            "Tuesday".to_string()
336        } else if date.is_wednesday() {
337            "Wednesday".to_string()
338        } else if date.is_thursday() {
339            "Thursday".to_string()
340        } else if date.is_friday() {
341            "Friday".to_string()
342        } else if date.is_saturday() {
343            "Saturday".to_string()
344        } else {
345            "Unknown".to_string()
346        }
347    }
348
349    fn weekday_name_short(&self, date: &Date) -> String {
350        if date.is_sunday() {
351            "Sun".to_string()
352        } else if date.is_monday() {
353            "Mon".to_string()
354        } else if date.is_tuesday() {
355            "Tue".to_string()
356        } else if date.is_wednesday() {
357            "Wed".to_string()
358        } else if date.is_thursday() {
359            "Thu".to_string()
360        } else if date.is_friday() {
361            "Fri".to_string()
362        } else if date.is_saturday() {
363            "Sat".to_string()
364        } else {
365            "Unknown".to_string()
366        }
367    }
368
369    fn weekday_number(&self, date: &Date) -> u64 {
370        if date.is_sunday() {
371            0
372        } else if date.is_monday() {
373            1
374        } else if date.is_tuesday() {
375            2
376        } else if date.is_wednesday() {
377            3
378        } else if date.is_thursday() {
379            4
380        } else if date.is_friday() {
381            5
382        } else if date.is_saturday() {
383            6
384        } else {
385            0
386        }
387    }
388
389    fn format_timezone(&self, shift_minutes: isize, with_colon: bool) -> String {
390        if shift_minutes == 0 {
391            return "Z".to_string();
392        }
393
394        let abs_minutes = shift_minutes.abs() as u64;
395        let hours = abs_minutes / 60;
396        let minutes = abs_minutes % 60;
397
398        let sign = if shift_minutes > 0 { "+" } else { "-" };
399
400        if with_colon {
401            format!("{}{:02}:{:02}", sign, hours, minutes)
402        } else {
403            format!("{}{:02}{:02}", sign, hours, minutes)
404        }
405    }
406}
407
408/// A trait for objects that can be formatted
409pub trait Format {
410    /// Format this object using the given format string
411    fn format(&self, format: &str) -> Result<String, DateError>;
412}
413
414impl Format for DateTime {
415    fn format(&self, format: &str) -> Result<String, DateError> {
416        let format_string = FormatString::new(format)?;
417        Ok(format_string.format_datetime(self))
418    }
419}
420
421impl Format for Date {
422    fn format(&self, format: &str) -> Result<String, DateError> {
423        let format_string = FormatString::new(format)?;
424        Ok(format_string.format_date(self))
425    }
426}
427
428impl Format for Time {
429    fn format(&self, format: &str) -> Result<String, DateError> {
430        let format_string = FormatString::new(format)?;
431        Ok(format_string.format_time(self))
432    }
433}
434
435#[cfg(test)]
436mod tests {
437    use super::*;
438
439    #[test]
440    fn test_format_string_parsing() {
441        let format = FormatString::new("%Y-%m-%d %H:%M:%S").unwrap();
442        assert_eq!(format.parts.len(), 11); // 6 specifiers + 5 literals
443
444        let format = FormatString::new("Date: %Y-%m-%d").unwrap();
445        assert_eq!(format.parts.len(), 6); // 3 specifiers + 3 literals ("Date: ", "-", "")
446    }
447
448    #[test]
449    fn test_datetime_formatting() {
450        let dt = DateTime::new(Date::new(2023, 6, 15), Time::new(14, 30, 45), 0);
451
452        assert_eq!(dt.format("%Y-%m-%d").unwrap(), "2023-06-15");
453        assert_eq!(dt.format("%H:%M:%S").unwrap(), "14:30:45");
454        assert_eq!(
455            dt.format("%Y-%m-%d %H:%M:%S").unwrap(),
456            "2023-06-15 14:30:45"
457        );
458        assert_eq!(dt.format("%B %d, %Y").unwrap(), "June 15, 2023");
459        assert_eq!(
460            dt.format("%A, %B %d, %Y").unwrap(),
461            "Thursday, June 15, 2023"
462        );
463        assert_eq!(dt.format("%I:%M %p").unwrap(), "02:30 PM");
464    }
465
466    #[test]
467    fn test_datetime_millisecond_formatting() {
468        let time = Time::new_with_microseconds(14, 30, 45, 123456);
469        let dt = DateTime::new(Date::new(2023, 6, 15), time, 0);
470
471        assert_eq!(
472            dt.format("%Y-%m-%d %H:%M:%S%.4f").unwrap(),
473            "2023-06-15 14:30:45.1234"
474        );
475    }
476
477    #[test]
478    fn test_fractional_second_formatting() {
479        let time = Time::new_with_microseconds(14, 30, 45, 123456);
480        let dt = DateTime::new(Date::new(2023, 6, 15), time, 0);
481
482        // Test different precision levels (without literal dot to avoid double dots)
483        assert_eq!(dt.format("%H:%M:%S%.1f").unwrap(), "14:30:45.1");
484        assert_eq!(dt.format("%H:%M:%S%.2f").unwrap(), "14:30:45.12");
485        assert_eq!(dt.format("%H:%M:%S%.3f").unwrap(), "14:30:45.123");
486        assert_eq!(dt.format("%H:%M:%S%.4f").unwrap(), "14:30:45.1234");
487        assert_eq!(dt.format("%H:%M:%S%.5f").unwrap(), "14:30:45.12345");
488        assert_eq!(dt.format("%H:%M:%S%.6f").unwrap(), "14:30:45.123456");
489
490        // Test with Time object
491        assert_eq!(time.format("%H:%M:%S%.3f").unwrap(), "14:30:45.123");
492        assert_eq!(time.format("%H:%M:%S%.4f").unwrap(), "14:30:45.1234");
493    }
494
495    #[test]
496    fn test_fractional_second_fallback() {
497        let time = Time::new_with_microseconds(14, 30, 45, 123456);
498        let dt = DateTime::new(Date::new(2023, 6, 15), time, 0);
499
500        // Test fallback to 6-digit microsecond format for invalid cases
501        assert_eq!(dt.format("%H:%M:%S%.0f").unwrap(), "14:30:45.123456"); // 0 digits -> fallback
502        assert_eq!(dt.format("%H:%M:%S%.7f").unwrap(), "14:30:45.123456"); // 7 digits -> fallback
503        assert_eq!(dt.format("%H:%M:%S%.10f").unwrap(), "14:30:45.123456"); // 10 digits -> fallback
504        assert_eq!(dt.format("%H:%M:%S%.99f").unwrap(), "14:30:45.123456"); // 99 digits -> fallback
505        
506        // Test with Time object
507        assert_eq!(time.format("%H:%M:%S%.0f").unwrap(), "14:30:45.123456");
508        assert_eq!(time.format("%H:%M:%S%.7f").unwrap(), "14:30:45.123456");
509        assert_eq!(time.format("%H:%M:%S%f").unwrap(), "14:30:45.123456");
510    }
511
512    #[test]
513    fn test_literal_dot_handling() {
514        let time = Time::new_with_microseconds(14, 30, 45, 123456);
515        let dt = DateTime::new(Date::new(2023, 6, 15), time, 0);
516
517        // Test that literal dots (not followed by 'f') are handled correctly
518        assert_eq!(dt.format("%H:%M:%S.").unwrap(), "14:30:45.");
519        assert_eq!(dt.format("%H:%M:%S.123").unwrap(), "14:30:45.123");
520        assert_eq!(dt.format("%H:%M:%S.abc").unwrap(), "14:30:45.abc");
521    }
522
523    #[test]
524    fn test_date_formatting() {
525        let date = Date::new(2023, 6, 15);
526
527        assert_eq!(date.format("%Y-%m-%d").unwrap(), "2023-06-15");
528        assert_eq!(date.format("%B %d, %Y").unwrap(), "June 15, 2023");
529        assert_eq!(
530            date.format("%A, %B %d, %Y").unwrap(),
531            "Thursday, June 15, 2023"
532        );
533        assert_eq!(date.format("%j").unwrap(), "166"); // Day of year
534    }
535
536    #[test]
537    fn test_time_formatting() {
538        let time = Time::new(14, 30, 45);
539
540        assert_eq!(time.format("%H:%M:%S").unwrap(), "14:30:45");
541        assert_eq!(time.format("%I:%M %p").unwrap(), "02:30 PM");
542        assert_eq!(time.format("%T").unwrap(), "14:30:45");
543    }
544
545    #[test]
546    fn test_timezone_formatting() {
547        let dt = DateTime::new(Date::new(2023, 6, 15), Time::new(14, 30, 45), 120);
548
549        assert_eq!(dt.format("%z").unwrap(), "+0200");
550        assert_eq!(dt.format("%:z").unwrap(), "+02:00");
551
552        let dt_utc = DateTime::new(Date::new(2023, 6, 15), Time::new(14, 30, 45), 0);
553        assert_eq!(dt_utc.format("%z").unwrap(), "Z");
554    }
555
556    #[test]
557    fn test_microsecond_formatting() {
558        let time = Time::new_with_microseconds(14, 30, 45, 123456);
559
560        assert_eq!(time.format("%H:%M:%S%f").unwrap(), "14:30:45.123456");
561        assert_eq!(time.format("%T%f").unwrap(), "14:30:45.123456");
562    }
563
564    #[test]
565    fn test_millisecond_formatting() {
566        let time = Time::new_with_microseconds(14, 30, 45, 123456);
567
568        assert_eq!(time.format("%H:%M:%S%.3f").unwrap(), "14:30:45.123");
569        assert_eq!(time.format("%T%.3f").unwrap(), "14:30:45.123");
570
571        // Test with different millisecond values
572        let time2 = Time::new_with_microseconds(9, 15, 30, 50000);
573        assert_eq!(time2.format("%H:%M:%S%.3f").unwrap(), "09:15:30.050");
574
575        let time3 = Time::new_with_microseconds(23, 59, 59, 999000);
576        assert_eq!(time3.format("%H:%M:%S%.3f").unwrap(), "23:59:59.999");
577    }
578
579    #[test]
580    fn test_iso8601_formatting() {
581        let dt = DateTime::new(Date::new(2023, 6, 15), Time::new(14, 30, 45), 0);
582
583        assert_eq!(dt.format("%+").unwrap(), "2023-06-15T14:30:45Z");
584    }
585
586    #[test]
587    fn test_escaped_percent() {
588        let format = FormatString::new("100%% complete").unwrap();
589        assert_eq!(format.parts.len(), 2);
590
591        let dt = DateTime::new(Date::new(2023, 6, 15), Time::new(14, 30, 45), 0);
592        assert_eq!(dt.format("100%% complete").unwrap(), "100% complete");
593    }
594
595    #[test]
596    fn test_invalid_format() {
597        assert!(FormatString::new("%").is_err());
598        assert!(FormatString::new("%X").is_err());
599    }
600}