simple_datetime_rs/
format.rs

1use crate::date::Date;
2use crate::time::Time;
3use crate::date_time::DateTime;
4use crate::date_error::{DateError, DateErrorKind};
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    AmPm,           // %p - AM/PM indicator
39    
40    // Timezone specifiers
41    Timezone,       // %z - Timezone offset (+HHMM or -HHMM)
42    TimezoneColon,  // %:z - Timezone offset with colon (+HH:MM or -HH:MM)
43    
44    // Common combinations
45    Date,           // %D - Date in MM/DD/YY format
46    Time,           // %T - Time in HH:MM:SS format
47    DateTime,       // %F - Date in YYYY-MM-DD format
48    Iso8601,        // %+ - ISO 8601 format
49}
50
51impl FormatString {
52    /// Parse a format string into format parts
53    pub fn new(format: &str) -> Result<Self, DateError> {
54        let mut parts = Vec::new();
55        let mut chars = format.chars().peekable();
56        let mut literal = String::new();
57        
58        while let Some(ch) = chars.next() {
59            if ch == '%' {
60                if !literal.is_empty() {
61                    parts.push(FormatPart::Literal(literal.clone()));
62                    literal.clear();
63                }
64                let spec = match chars.next() {
65                    Some('Y') => FormatSpecifier::Year,
66                    Some('y') => FormatSpecifier::YearShort,
67                    Some('m') => FormatSpecifier::Month,
68                    Some('B') => FormatSpecifier::MonthName,
69                    Some('b') => FormatSpecifier::MonthNameShort,
70                    Some('d') => FormatSpecifier::Day,
71                    Some('j') => FormatSpecifier::DayOfYear,
72                    Some('A') => FormatSpecifier::Weekday,
73                    Some('a') => FormatSpecifier::WeekdayShort,
74                    Some('w') => FormatSpecifier::WeekdayNum,
75                    Some('H') => FormatSpecifier::Hour24,
76                    Some('I') => FormatSpecifier::Hour12,
77                    Some('M') => FormatSpecifier::Minute,
78                    Some('S') => FormatSpecifier::Second,
79                    Some('f') => FormatSpecifier::Microsecond,
80                    Some('p') => FormatSpecifier::AmPm,
81                    Some('z') => FormatSpecifier::Timezone,
82                    Some(':') if chars.peek() == Some(&'z') => {
83                        chars.next(); // consume the 'z'
84                        FormatSpecifier::TimezoneColon
85                    }
86                    Some('D') => FormatSpecifier::Date,
87                    Some('T') => FormatSpecifier::Time,
88                    Some('F') => FormatSpecifier::DateTime,
89                    Some('+') => FormatSpecifier::Iso8601,
90                    Some('%') => {
91                        literal.push('%');
92                        continue;
93                    }
94                    Some(_) => return Err(DateErrorKind::WrongDateTimeStringFormat.into()),
95                    None => return Err(DateErrorKind::WrongDateTimeStringFormat.into()),
96                };
97                parts.push(FormatPart::Specifier(spec));
98            } else {
99                literal.push(ch);
100            }
101        }
102        
103        if !literal.is_empty() {
104            parts.push(FormatPart::Literal(literal));
105        }
106        
107        Ok(FormatString { parts })
108    }
109    
110    /// Format a DateTime using this format string
111    pub fn format_datetime(&self, dt: &DateTime) -> String {
112        let mut result = String::new();
113        for part in &self.parts {
114            match part {
115                FormatPart::Literal(s) => result.push_str(s),
116                FormatPart::Specifier(spec) => {
117                    result.push_str(&self.format_specifier(spec, dt));
118                }
119            }
120        }
121        result
122    }
123    
124    /// Format a Date using this format string
125    pub fn format_date(&self, date: &Date) -> String {
126        let mut result = String::new();
127        for part in &self.parts {
128            match part {
129                FormatPart::Literal(s) => result.push_str(s),
130                FormatPart::Specifier(spec) => {
131                    result.push_str(&self.format_specifier_date(spec, date));
132                }
133            }
134        }
135        result
136    }
137    
138    /// Format a Time using this format string
139    pub fn format_time(&self, time: &Time) -> String {
140        let mut result = String::new();
141        for part in &self.parts {
142            match part {
143                FormatPart::Literal(s) => result.push_str(s),
144                FormatPart::Specifier(spec) => {
145                    result.push_str(&self.format_specifier_time(spec, time));
146                }
147            }
148        }
149        result
150    }
151    
152    fn format_specifier(&self, spec: &FormatSpecifier, dt: &DateTime) -> String {
153        match spec {
154            FormatSpecifier::Year => format!("{:04}", dt.date.year),
155            FormatSpecifier::YearShort => format!("{:02}", dt.date.year % 100),
156            FormatSpecifier::Month => format!("{:02}", dt.date.month),
157            FormatSpecifier::MonthName => self.month_name(dt.date.month),
158            FormatSpecifier::MonthNameShort => self.month_name_short(dt.date.month),
159            FormatSpecifier::Day => format!("{:02}", dt.date.day),
160            FormatSpecifier::DayOfYear => format!("{:03}", dt.date.year_day()),
161            FormatSpecifier::Weekday => self.weekday_name(&dt.date),
162            FormatSpecifier::WeekdayShort => self.weekday_name_short(&dt.date),
163            FormatSpecifier::WeekdayNum => format!("{}", self.weekday_number(&dt.date)),
164            FormatSpecifier::Hour24 => format!("{:02}", dt.time.hour),
165            FormatSpecifier::Hour12 => {
166                let hour = if dt.time.hour == 0 { 12 } else if dt.time.hour > 12 { dt.time.hour - 12 } else { dt.time.hour };
167                format!("{:02}", hour)
168            }
169            FormatSpecifier::Minute => format!("{:02}", dt.time.minute),
170            FormatSpecifier::Second => format!("{:02}", dt.time.second),
171            FormatSpecifier::Microsecond => format!("{:06}", dt.time.microsecond),
172            FormatSpecifier::AmPm => if dt.time.hour < 12 { "AM" } else { "PM" }.to_string(),
173            FormatSpecifier::Timezone => self.format_timezone(dt.shift_minutes, false),
174            FormatSpecifier::TimezoneColon => self.format_timezone(dt.shift_minutes, true),
175            FormatSpecifier::Date => format!("{:02}/{:02}/{:02}", dt.date.month, dt.date.day, dt.date.year % 100),
176            FormatSpecifier::Time => format!("{:02}:{:02}:{:02}", dt.time.hour, dt.time.minute, dt.time.second),
177            FormatSpecifier::DateTime => format!("{:04}-{:02}-{:02}", dt.date.year, dt.date.month, dt.date.day),
178            FormatSpecifier::Iso8601 => dt.to_iso_8061(),
179        }
180    }
181    
182    fn format_specifier_date(&self, spec: &FormatSpecifier, date: &Date) -> String {
183        match spec {
184            FormatSpecifier::Year => format!("{:04}", date.year),
185            FormatSpecifier::YearShort => format!("{:02}", date.year % 100),
186            FormatSpecifier::Month => format!("{:02}", date.month),
187            FormatSpecifier::MonthName => self.month_name(date.month),
188            FormatSpecifier::MonthNameShort => self.month_name_short(date.month),
189            FormatSpecifier::Day => format!("{:02}", date.day),
190            FormatSpecifier::DayOfYear => format!("{:03}", date.year_day()),
191            FormatSpecifier::Weekday => self.weekday_name(date),
192            FormatSpecifier::WeekdayShort => self.weekday_name_short(date),
193            FormatSpecifier::WeekdayNum => format!("{}", self.weekday_number(date)),
194            FormatSpecifier::DateTime => format!("{:04}-{:02}-{:02}", date.year, date.month, date.day),
195            FormatSpecifier::Date => format!("{:02}/{:02}/{:02}", date.month, date.day, date.year % 100),
196            _ => String::new(), // Time-related specifiers are empty for Date
197        }
198    }
199    
200    fn format_specifier_time(&self, spec: &FormatSpecifier, time: &Time) -> String {
201        match spec {
202            FormatSpecifier::Hour24 => format!("{:02}", time.hour),
203            FormatSpecifier::Hour12 => {
204                let hour = if time.hour == 0 { 12 } else if time.hour > 12 { time.hour - 12 } else { time.hour };
205                format!("{:02}", hour)
206            }
207            FormatSpecifier::Minute => format!("{:02}", time.minute),
208            FormatSpecifier::Second => format!("{:02}", time.second),
209            FormatSpecifier::Microsecond => format!("{:06}", time.microsecond),
210            FormatSpecifier::AmPm => if time.hour < 12 { "AM" } else { "PM" }.to_string(),
211            FormatSpecifier::Time => format!("{:02}:{:02}:{:02}", time.hour, time.minute, time.second),
212            _ => String::new(), // Date-related specifiers are empty for Time
213        }
214    }
215    
216    fn month_name(&self, month: u64) -> String {
217        match month {
218            1 => "January".to_string(),
219            2 => "February".to_string(),
220            3 => "March".to_string(),
221            4 => "April".to_string(),
222            5 => "May".to_string(),
223            6 => "June".to_string(),
224            7 => "July".to_string(),
225            8 => "August".to_string(),
226            9 => "September".to_string(),
227            10 => "October".to_string(),
228            11 => "November".to_string(),
229            12 => "December".to_string(),
230            _ => "Unknown".to_string(),
231        }
232    }
233    
234    fn month_name_short(&self, month: u64) -> String {
235        match month {
236            1 => "Jan".to_string(),
237            2 => "Feb".to_string(),
238            3 => "Mar".to_string(),
239            4 => "Apr".to_string(),
240            5 => "May".to_string(),
241            6 => "Jun".to_string(),
242            7 => "Jul".to_string(),
243            8 => "Aug".to_string(),
244            9 => "Sep".to_string(),
245            10 => "Oct".to_string(),
246            11 => "Nov".to_string(),
247            12 => "Dec".to_string(),
248            _ => "Unknown".to_string(),
249        }
250    }
251    
252    fn weekday_name(&self, date: &Date) -> String {
253        if date.is_sunday() { "Sunday".to_string() }
254        else if date.is_monday() { "Monday".to_string() }
255        else if date.is_tuesday() { "Tuesday".to_string() }
256        else if date.is_wednesday() { "Wednesday".to_string() }
257        else if date.is_thursday() { "Thursday".to_string() }
258        else if date.is_friday() { "Friday".to_string() }
259        else if date.is_saturday() { "Saturday".to_string() }
260        else { "Unknown".to_string() }
261    }
262    
263    fn weekday_name_short(&self, date: &Date) -> String {
264        if date.is_sunday() { "Sun".to_string() }
265        else if date.is_monday() { "Mon".to_string() }
266        else if date.is_tuesday() { "Tue".to_string() }
267        else if date.is_wednesday() { "Wed".to_string() }
268        else if date.is_thursday() { "Thu".to_string() }
269        else if date.is_friday() { "Fri".to_string() }
270        else if date.is_saturday() { "Sat".to_string() }
271        else { "Unknown".to_string() }
272    }
273    
274    fn weekday_number(&self, date: &Date) -> u64 {
275        if date.is_sunday() { 0 }
276        else if date.is_monday() { 1 }
277        else if date.is_tuesday() { 2 }
278        else if date.is_wednesday() { 3 }
279        else if date.is_thursday() { 4 }
280        else if date.is_friday() { 5 }
281        else if date.is_saturday() { 6 }
282        else { 0 }
283    }
284    
285    fn format_timezone(&self, shift_minutes: isize, with_colon: bool) -> String {
286        if shift_minutes == 0 {
287            return "Z".to_string();
288        }
289        
290        let abs_minutes = shift_minutes.abs() as u64;
291        let hours = abs_minutes / 60;
292        let minutes = abs_minutes % 60;
293        
294        let sign = if shift_minutes > 0 { "+" } else { "-" };
295        
296        if with_colon {
297            format!("{}{:02}:{:02}", sign, hours, minutes)
298        } else {
299            format!("{}{:02}{:02}", sign, hours, minutes)
300        }
301    }
302}
303
304/// A trait for objects that can be formatted
305pub trait Format {
306    /// Format this object using the given format string
307    fn format(&self, format: &str) -> Result<String, DateError>;
308}
309
310impl Format for DateTime {
311    fn format(&self, format: &str) -> Result<String, DateError> {
312        let format_string = FormatString::new(format)?;
313        Ok(format_string.format_datetime(self))
314    }
315}
316
317impl Format for Date {
318    fn format(&self, format: &str) -> Result<String, DateError> {
319        let format_string = FormatString::new(format)?;
320        Ok(format_string.format_date(self))
321    }
322}
323
324impl Format for Time {
325    fn format(&self, format: &str) -> Result<String, DateError> {
326        let format_string = FormatString::new(format)?;
327        Ok(format_string.format_time(self))
328    }
329}
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334
335    #[test]
336    fn test_format_string_parsing() {
337        let format = FormatString::new("%Y-%m-%d %H:%M:%S").unwrap();
338        assert_eq!(format.parts.len(), 11); // 6 specifiers + 5 literals
339        
340        let format = FormatString::new("Date: %Y-%m-%d").unwrap();
341        assert_eq!(format.parts.len(), 6); // 3 specifiers + 3 literals ("Date: ", "-", "")
342    }
343
344    #[test]
345    fn test_datetime_formatting() {
346        let dt = DateTime::new(Date::new(2023, 6, 15), Time::new(14, 30, 45), 0);
347        
348        assert_eq!(dt.format("%Y-%m-%d").unwrap(), "2023-06-15");
349        assert_eq!(dt.format("%H:%M:%S").unwrap(), "14:30:45");
350        assert_eq!(dt.format("%Y-%m-%d %H:%M:%S").unwrap(), "2023-06-15 14:30:45");
351        assert_eq!(dt.format("%B %d, %Y").unwrap(), "June 15, 2023");
352        assert_eq!(dt.format("%A, %B %d, %Y").unwrap(), "Thursday, June 15, 2023");
353        assert_eq!(dt.format("%I:%M %p").unwrap(), "02:30 PM");
354    }
355
356    #[test]
357    fn test_date_formatting() {
358        let date = Date::new(2023, 6, 15);
359        
360        assert_eq!(date.format("%Y-%m-%d").unwrap(), "2023-06-15");
361        assert_eq!(date.format("%B %d, %Y").unwrap(), "June 15, 2023");
362        assert_eq!(date.format("%A, %B %d, %Y").unwrap(), "Thursday, June 15, 2023");
363        assert_eq!(date.format("%j").unwrap(), "166"); // Day of year
364    }
365
366    #[test]
367    fn test_time_formatting() {
368        let time = Time::new(14, 30, 45);
369        
370        assert_eq!(time.format("%H:%M:%S").unwrap(), "14:30:45");
371        assert_eq!(time.format("%I:%M %p").unwrap(), "02:30 PM");
372        assert_eq!(time.format("%T").unwrap(), "14:30:45");
373    }
374
375    #[test]
376    fn test_timezone_formatting() {
377        let dt = DateTime::new(Date::new(2023, 6, 15), Time::new(14, 30, 45), 120);
378        
379        assert_eq!(dt.format("%z").unwrap(), "+0200");
380        assert_eq!(dt.format("%:z").unwrap(), "+02:00");
381        
382        let dt_utc = DateTime::new(Date::new(2023, 6, 15), Time::new(14, 30, 45), 0);
383        assert_eq!(dt_utc.format("%z").unwrap(), "Z");
384    }
385
386    #[test]
387    fn test_microsecond_formatting() {
388        let time = Time::new_with_microseconds(14, 30, 45, 123456);
389        
390        assert_eq!(time.format("%H:%M:%S.%f").unwrap(), "14:30:45.123456");
391        assert_eq!(time.format("%T.%f").unwrap(), "14:30:45.123456");
392    }
393
394    #[test]
395    fn test_iso8601_formatting() {
396        let dt = DateTime::new(Date::new(2023, 6, 15), Time::new(14, 30, 45), 0);
397        
398        assert_eq!(dt.format("%+").unwrap(), "2023-06-15T14:30:45Z");
399    }
400
401    #[test]
402    fn test_escaped_percent() {
403        let format = FormatString::new("100%% complete").unwrap();
404        assert_eq!(format.parts.len(), 2);
405        
406        let dt = DateTime::new(Date::new(2023, 6, 15), Time::new(14, 30, 45), 0);
407        assert_eq!(dt.format("100%% complete").unwrap(), "100% complete");
408    }
409
410    #[test]
411    fn test_invalid_format() {
412        assert!(FormatString::new("%").is_err());
413        assert!(FormatString::new("%X").is_err());
414    }
415
416}