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    Millisecond, // %.3f - Millisecond (000-999)
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                        if chars.peek() == Some(&'3') && chars.peek().is_some() && {
82                            let mut peek_chars = chars.clone();
83                            peek_chars.next(); // consume '3'
84                            peek_chars.next() == Some('f')
85                        } =>
86                    {
87                        chars.next(); // consume '3'
88                        chars.next(); // consume 'f'
89                        FormatSpecifier::Millisecond
90                    }
91                    Some('f') => FormatSpecifier::Microsecond,
92                    Some('p') => FormatSpecifier::AmPm,
93                    Some('z') => FormatSpecifier::Timezone,
94                    Some(':') if chars.peek() == Some(&'z') => {
95                        chars.next(); // consume the 'z'
96                        FormatSpecifier::TimezoneColon
97                    }
98                    Some('D') => FormatSpecifier::Date,
99                    Some('T') => FormatSpecifier::Time,
100                    Some('F') => FormatSpecifier::DateTime,
101                    Some('+') => FormatSpecifier::Iso8601,
102                    Some('%') => {
103                        literal.push('%');
104                        continue;
105                    }
106                    Some(_) => return Err(DateErrorKind::WrongDateTimeStringFormat.into()),
107                    None => return Err(DateErrorKind::WrongDateTimeStringFormat.into()),
108                };
109                parts.push(FormatPart::Specifier(spec));
110            } else {
111                literal.push(ch);
112            }
113        }
114
115        if !literal.is_empty() {
116            parts.push(FormatPart::Literal(literal));
117        }
118
119        Ok(FormatString { parts })
120    }
121
122    /// Format a DateTime using this format string
123    pub fn format_datetime(&self, dt: &DateTime) -> String {
124        let mut result = String::new();
125        for part in &self.parts {
126            match part {
127                FormatPart::Literal(s) => result.push_str(s),
128                FormatPart::Specifier(spec) => {
129                    result.push_str(&self.format_specifier(spec, dt));
130                }
131            }
132        }
133        result
134    }
135
136    /// Format a Date using this format string
137    pub fn format_date(&self, date: &Date) -> String {
138        let mut result = String::new();
139        for part in &self.parts {
140            match part {
141                FormatPart::Literal(s) => result.push_str(s),
142                FormatPart::Specifier(spec) => {
143                    result.push_str(&self.format_specifier_date(spec, date));
144                }
145            }
146        }
147        result
148    }
149
150    /// Format a Time using this format string
151    pub fn format_time(&self, time: &Time) -> String {
152        let mut result = String::new();
153        for part in &self.parts {
154            match part {
155                FormatPart::Literal(s) => result.push_str(s),
156                FormatPart::Specifier(spec) => {
157                    result.push_str(&self.format_specifier_time(spec, time));
158                }
159            }
160        }
161        result
162    }
163
164    fn format_specifier(&self, spec: &FormatSpecifier, dt: &DateTime) -> String {
165        match spec {
166            FormatSpecifier::Year => format!("{:04}", dt.date.year),
167            FormatSpecifier::YearShort => format!("{:02}", dt.date.year % 100),
168            FormatSpecifier::Month => format!("{:02}", dt.date.month),
169            FormatSpecifier::MonthName => self.month_name(dt.date.month),
170            FormatSpecifier::MonthNameShort => self.month_name_short(dt.date.month),
171            FormatSpecifier::Day => format!("{:02}", dt.date.day),
172            FormatSpecifier::DayOfYear => format!("{:03}", dt.date.year_day()),
173            FormatSpecifier::Weekday => self.weekday_name(&dt.date),
174            FormatSpecifier::WeekdayShort => self.weekday_name_short(&dt.date),
175            FormatSpecifier::WeekdayNum => format!("{}", self.weekday_number(&dt.date)),
176            FormatSpecifier::Hour24 => format!("{:02}", dt.time.hour),
177            FormatSpecifier::Hour12 => {
178                let hour = if dt.time.hour == 0 {
179                    12
180                } else if dt.time.hour > 12 {
181                    dt.time.hour - 12
182                } else {
183                    dt.time.hour
184                };
185                format!("{:02}", hour)
186            }
187            FormatSpecifier::Minute => format!("{:02}", dt.time.minute),
188            FormatSpecifier::Second => format!("{:02}", dt.time.second),
189            FormatSpecifier::Microsecond => format!("{:06}", dt.time.microsecond),
190            FormatSpecifier::Millisecond => format!("{:03}", dt.time.microsecond / 1000),
191            FormatSpecifier::AmPm => if dt.time.hour < 12 { "AM" } else { "PM" }.to_string(),
192            FormatSpecifier::Timezone => self.format_timezone(dt.shift_minutes, false),
193            FormatSpecifier::TimezoneColon => self.format_timezone(dt.shift_minutes, true),
194            FormatSpecifier::Date => format!(
195                "{:02}/{:02}/{:02}",
196                dt.date.month,
197                dt.date.day,
198                dt.date.year % 100
199            ),
200            FormatSpecifier::Time => format!(
201                "{:02}:{:02}:{:02}",
202                dt.time.hour, dt.time.minute, dt.time.second
203            ),
204            FormatSpecifier::DateTime => format!(
205                "{:04}-{:02}-{:02}",
206                dt.date.year, dt.date.month, dt.date.day
207            ),
208            FormatSpecifier::Iso8601 => dt.to_iso_8061(),
209        }
210    }
211
212    fn format_specifier_date(&self, spec: &FormatSpecifier, date: &Date) -> String {
213        match spec {
214            FormatSpecifier::Year => format!("{:04}", date.year),
215            FormatSpecifier::YearShort => format!("{:02}", date.year % 100),
216            FormatSpecifier::Month => format!("{:02}", date.month),
217            FormatSpecifier::MonthName => self.month_name(date.month),
218            FormatSpecifier::MonthNameShort => self.month_name_short(date.month),
219            FormatSpecifier::Day => format!("{:02}", date.day),
220            FormatSpecifier::DayOfYear => format!("{:03}", date.year_day()),
221            FormatSpecifier::Weekday => self.weekday_name(date),
222            FormatSpecifier::WeekdayShort => self.weekday_name_short(date),
223            FormatSpecifier::WeekdayNum => format!("{}", self.weekday_number(date)),
224            FormatSpecifier::DateTime => {
225                format!("{:04}-{:02}-{:02}", date.year, date.month, date.day)
226            }
227            FormatSpecifier::Date => {
228                format!("{:02}/{:02}/{:02}", date.month, date.day, date.year % 100)
229            }
230            _ => String::new(), // Time-related specifiers are empty for Date
231        }
232    }
233
234    fn format_specifier_time(&self, spec: &FormatSpecifier, time: &Time) -> String {
235        match spec {
236            FormatSpecifier::Hour24 => format!("{:02}", time.hour),
237            FormatSpecifier::Hour12 => {
238                let hour = if time.hour == 0 {
239                    12
240                } else if time.hour > 12 {
241                    time.hour - 12
242                } else {
243                    time.hour
244                };
245                format!("{:02}", hour)
246            }
247            FormatSpecifier::Minute => format!("{:02}", time.minute),
248            FormatSpecifier::Second => format!("{:02}", time.second),
249            FormatSpecifier::Microsecond => format!("{:06}", time.microsecond),
250            FormatSpecifier::Millisecond => format!("{:03}", time.microsecond / 1000),
251            FormatSpecifier::AmPm => if time.hour < 12 { "AM" } else { "PM" }.to_string(),
252            FormatSpecifier::Time => {
253                format!("{:02}:{:02}:{:02}", time.hour, time.minute, time.second)
254            }
255            _ => String::new(), // Date-related specifiers are empty for Time
256        }
257    }
258
259    fn month_name(&self, month: u64) -> String {
260        match month {
261            1 => "January".to_string(),
262            2 => "February".to_string(),
263            3 => "March".to_string(),
264            4 => "April".to_string(),
265            5 => "May".to_string(),
266            6 => "June".to_string(),
267            7 => "July".to_string(),
268            8 => "August".to_string(),
269            9 => "September".to_string(),
270            10 => "October".to_string(),
271            11 => "November".to_string(),
272            12 => "December".to_string(),
273            _ => "Unknown".to_string(),
274        }
275    }
276
277    fn month_name_short(&self, month: u64) -> String {
278        match month {
279            1 => "Jan".to_string(),
280            2 => "Feb".to_string(),
281            3 => "Mar".to_string(),
282            4 => "Apr".to_string(),
283            5 => "May".to_string(),
284            6 => "Jun".to_string(),
285            7 => "Jul".to_string(),
286            8 => "Aug".to_string(),
287            9 => "Sep".to_string(),
288            10 => "Oct".to_string(),
289            11 => "Nov".to_string(),
290            12 => "Dec".to_string(),
291            _ => "Unknown".to_string(),
292        }
293    }
294
295    fn weekday_name(&self, date: &Date) -> String {
296        if date.is_sunday() {
297            "Sunday".to_string()
298        } else if date.is_monday() {
299            "Monday".to_string()
300        } else if date.is_tuesday() {
301            "Tuesday".to_string()
302        } else if date.is_wednesday() {
303            "Wednesday".to_string()
304        } else if date.is_thursday() {
305            "Thursday".to_string()
306        } else if date.is_friday() {
307            "Friday".to_string()
308        } else if date.is_saturday() {
309            "Saturday".to_string()
310        } else {
311            "Unknown".to_string()
312        }
313    }
314
315    fn weekday_name_short(&self, date: &Date) -> String {
316        if date.is_sunday() {
317            "Sun".to_string()
318        } else if date.is_monday() {
319            "Mon".to_string()
320        } else if date.is_tuesday() {
321            "Tue".to_string()
322        } else if date.is_wednesday() {
323            "Wed".to_string()
324        } else if date.is_thursday() {
325            "Thu".to_string()
326        } else if date.is_friday() {
327            "Fri".to_string()
328        } else if date.is_saturday() {
329            "Sat".to_string()
330        } else {
331            "Unknown".to_string()
332        }
333    }
334
335    fn weekday_number(&self, date: &Date) -> u64 {
336        if date.is_sunday() {
337            0
338        } else if date.is_monday() {
339            1
340        } else if date.is_tuesday() {
341            2
342        } else if date.is_wednesday() {
343            3
344        } else if date.is_thursday() {
345            4
346        } else if date.is_friday() {
347            5
348        } else if date.is_saturday() {
349            6
350        } else {
351            0
352        }
353    }
354
355    fn format_timezone(&self, shift_minutes: isize, with_colon: bool) -> String {
356        if shift_minutes == 0 {
357            return "Z".to_string();
358        }
359
360        let abs_minutes = shift_minutes.abs() as u64;
361        let hours = abs_minutes / 60;
362        let minutes = abs_minutes % 60;
363
364        let sign = if shift_minutes > 0 { "+" } else { "-" };
365
366        if with_colon {
367            format!("{}{:02}:{:02}", sign, hours, minutes)
368        } else {
369            format!("{}{:02}{:02}", sign, hours, minutes)
370        }
371    }
372}
373
374/// A trait for objects that can be formatted
375pub trait Format {
376    /// Format this object using the given format string
377    fn format(&self, format: &str) -> Result<String, DateError>;
378}
379
380impl Format for DateTime {
381    fn format(&self, format: &str) -> Result<String, DateError> {
382        let format_string = FormatString::new(format)?;
383        Ok(format_string.format_datetime(self))
384    }
385}
386
387impl Format for Date {
388    fn format(&self, format: &str) -> Result<String, DateError> {
389        let format_string = FormatString::new(format)?;
390        Ok(format_string.format_date(self))
391    }
392}
393
394impl Format for Time {
395    fn format(&self, format: &str) -> Result<String, DateError> {
396        let format_string = FormatString::new(format)?;
397        Ok(format_string.format_time(self))
398    }
399}
400
401#[cfg(test)]
402mod tests {
403    use super::*;
404
405    #[test]
406    fn test_format_string_parsing() {
407        let format = FormatString::new("%Y-%m-%d %H:%M:%S").unwrap();
408        assert_eq!(format.parts.len(), 11); // 6 specifiers + 5 literals
409
410        let format = FormatString::new("Date: %Y-%m-%d").unwrap();
411        assert_eq!(format.parts.len(), 6); // 3 specifiers + 3 literals ("Date: ", "-", "")
412    }
413
414    #[test]
415    fn test_datetime_formatting() {
416        let dt = DateTime::new(Date::new(2023, 6, 15), Time::new(14, 30, 45), 0);
417
418        assert_eq!(dt.format("%Y-%m-%d").unwrap(), "2023-06-15");
419        assert_eq!(dt.format("%H:%M:%S").unwrap(), "14:30:45");
420        assert_eq!(
421            dt.format("%Y-%m-%d %H:%M:%S").unwrap(),
422            "2023-06-15 14:30:45"
423        );
424        assert_eq!(dt.format("%B %d, %Y").unwrap(), "June 15, 2023");
425        assert_eq!(
426            dt.format("%A, %B %d, %Y").unwrap(),
427            "Thursday, June 15, 2023"
428        );
429        assert_eq!(dt.format("%I:%M %p").unwrap(), "02:30 PM");
430    }
431
432    #[test]
433    fn test_datetime_millisecond_formatting() {
434        let time = Time::new_with_microseconds(14, 30, 45, 123456);
435        let dt = DateTime::new(Date::new(2023, 6, 15), time, 0);
436
437        assert_eq!(
438            dt.format("%Y-%m-%d %H:%M:%S.%.3f").unwrap(),
439            "2023-06-15 14:30:45.123"
440        );
441    }
442
443    #[test]
444    fn test_date_formatting() {
445        let date = Date::new(2023, 6, 15);
446
447        assert_eq!(date.format("%Y-%m-%d").unwrap(), "2023-06-15");
448        assert_eq!(date.format("%B %d, %Y").unwrap(), "June 15, 2023");
449        assert_eq!(
450            date.format("%A, %B %d, %Y").unwrap(),
451            "Thursday, June 15, 2023"
452        );
453        assert_eq!(date.format("%j").unwrap(), "166"); // Day of year
454    }
455
456    #[test]
457    fn test_time_formatting() {
458        let time = Time::new(14, 30, 45);
459
460        assert_eq!(time.format("%H:%M:%S").unwrap(), "14:30:45");
461        assert_eq!(time.format("%I:%M %p").unwrap(), "02:30 PM");
462        assert_eq!(time.format("%T").unwrap(), "14:30:45");
463    }
464
465    #[test]
466    fn test_timezone_formatting() {
467        let dt = DateTime::new(Date::new(2023, 6, 15), Time::new(14, 30, 45), 120);
468
469        assert_eq!(dt.format("%z").unwrap(), "+0200");
470        assert_eq!(dt.format("%:z").unwrap(), "+02:00");
471
472        let dt_utc = DateTime::new(Date::new(2023, 6, 15), Time::new(14, 30, 45), 0);
473        assert_eq!(dt_utc.format("%z").unwrap(), "Z");
474    }
475
476    #[test]
477    fn test_microsecond_formatting() {
478        let time = Time::new_with_microseconds(14, 30, 45, 123456);
479
480        assert_eq!(time.format("%H:%M:%S.%f").unwrap(), "14:30:45.123456");
481        assert_eq!(time.format("%T.%f").unwrap(), "14:30:45.123456");
482    }
483
484    #[test]
485    fn test_millisecond_formatting() {
486        let time = Time::new_with_microseconds(14, 30, 45, 123456);
487
488        assert_eq!(time.format("%H:%M:%S.%.3f").unwrap(), "14:30:45.123");
489        assert_eq!(time.format("%T.%.3f").unwrap(), "14:30:45.123");
490
491        // Test with different millisecond values
492        let time2 = Time::new_with_microseconds(9, 15, 30, 50000);
493        assert_eq!(time2.format("%H:%M:%S.%.3f").unwrap(), "09:15:30.050");
494
495        let time3 = Time::new_with_microseconds(23, 59, 59, 999000);
496        assert_eq!(time3.format("%H:%M:%S.%.3f").unwrap(), "23:59:59.999");
497    }
498
499    #[test]
500    fn test_iso8601_formatting() {
501        let dt = DateTime::new(Date::new(2023, 6, 15), Time::new(14, 30, 45), 0);
502
503        assert_eq!(dt.format("%+").unwrap(), "2023-06-15T14:30:45Z");
504    }
505
506    #[test]
507    fn test_escaped_percent() {
508        let format = FormatString::new("100%% complete").unwrap();
509        assert_eq!(format.parts.len(), 2);
510
511        let dt = DateTime::new(Date::new(2023, 6, 15), Time::new(14, 30, 45), 0);
512        assert_eq!(dt.format("100%% complete").unwrap(), "100% complete");
513    }
514
515    #[test]
516    fn test_invalid_format() {
517        assert!(FormatString::new("%").is_err());
518        assert!(FormatString::new("%X").is_err());
519    }
520}