Skip to main content

coreutils_rs/date/
core.rs

1use std::time::{Duration, SystemTime, UNIX_EPOCH};
2
3/// Configuration for the date command.
4#[derive(Default)]
5pub struct DateConfig {
6    /// Display time described by STRING (-d).
7    pub date_string: Option<String>,
8    /// Read dates from a file, one per line (-f).
9    pub date_file: Option<String>,
10    /// ISO 8601 output format (-I).
11    pub iso_format: Option<IsoFormat>,
12    /// RFC 5322 / email format (-R).
13    pub rfc_email: bool,
14    /// RFC 3339 format.
15    pub rfc_3339: Option<Rfc3339Format>,
16    /// Show modification time of FILE (-r).
17    pub reference_file: Option<String>,
18    /// Set system time (-s). We only parse; actual setting requires root.
19    pub set_string: Option<String>,
20    /// Use UTC (-u).
21    pub utc: bool,
22    /// Custom format string (starts with +).
23    pub format: Option<String>,
24}
25
26/// ISO 8601 format precision levels.
27#[derive(Clone, Debug, PartialEq)]
28pub enum IsoFormat {
29    Date,
30    Hours,
31    Minutes,
32    Seconds,
33    Ns,
34}
35
36/// RFC 3339 format precision levels.
37#[derive(Clone, Debug, PartialEq)]
38pub enum Rfc3339Format {
39    Date,
40    Seconds,
41    Ns,
42}
43
44/// Parse an ISO format precision string.
45pub fn parse_iso_format(s: &str) -> Result<IsoFormat, String> {
46    match s {
47        "" | "date" => Ok(IsoFormat::Date),
48        "hours" => Ok(IsoFormat::Hours),
49        "minutes" => Ok(IsoFormat::Minutes),
50        "seconds" => Ok(IsoFormat::Seconds),
51        "ns" => Ok(IsoFormat::Ns),
52        _ => Err(format!("invalid ISO 8601 format: '{}'", s)),
53    }
54}
55
56/// Parse an RFC 3339 format precision string.
57pub fn parse_rfc3339_format(s: &str) -> Result<Rfc3339Format, String> {
58    match s {
59        "date" => Ok(Rfc3339Format::Date),
60        "seconds" => Ok(Rfc3339Format::Seconds),
61        "ns" => Ok(Rfc3339Format::Ns),
62        _ => Err(format!("invalid RFC 3339 format: '{}'", s)),
63    }
64}
65
66/// Format a `SystemTime` using the given format string.
67///
68/// Uses libc `strftime` for most specifiers. Handles `%N` (nanoseconds) manually
69/// since strftime does not support it.
70pub fn format_date(time: &SystemTime, format: &str, utc: bool) -> String {
71    let dur = time.duration_since(UNIX_EPOCH).unwrap_or_default();
72    let secs = dur.as_secs() as i64;
73    let nanos = dur.subsec_nanos();
74
75    let mut tm: libc::tm = unsafe { std::mem::zeroed() };
76    if utc {
77        unsafe {
78            libc::gmtime_r(&secs, &mut tm);
79        }
80    } else {
81        unsafe {
82            libc::localtime_r(&secs, &mut tm);
83        }
84    }
85
86    // Process the format string, handling %N, %-X, %_X specially and passing
87    // everything else through to strftime.
88    let mut result = String::with_capacity(format.len() * 2);
89    let chars: Vec<char> = format.chars().collect();
90    let mut i = 0;
91
92    while i < chars.len() {
93        if chars[i] == '%' && i + 1 < chars.len() {
94            // Check for GNU format modifiers: %-X (no pad), %_X (space pad), %0X (zero pad)
95            let modifier = if i + 2 < chars.len()
96                && (chars[i + 1] == '-' || chars[i + 1] == '_' || chars[i + 1] == '0')
97                && chars[i + 2].is_ascii_alphabetic()
98            {
99                let m = chars[i + 1];
100                i += 1; // skip the modifier, will process specifier next
101                Some(m)
102            } else {
103                None
104            };
105
106            match chars[i + 1] {
107                's' => {
108                    // Unix timestamp — output directly to avoid mktime timezone issues.
109                    // strftime("%s") calls mktime() internally which treats the tm struct
110                    // as local time, causing wrong results when tm was filled with gmtime_r().
111                    result.push_str(&secs.to_string());
112                    i += 2;
113                }
114                'N' => {
115                    // Nanoseconds (9 digits, zero-padded)
116                    result.push_str(&format!("{:09}", nanos));
117                    i += 2;
118                }
119                'q' => {
120                    // Quarter (1-4), not in standard strftime
121                    let month = tm.tm_mon; // 0-11
122                    let quarter = (month / 3) + 1;
123                    result.push_str(&quarter.to_string());
124                    i += 2;
125                }
126                'P' => {
127                    // am/pm (lowercase), not always available in strftime
128                    let ampm = if tm.tm_hour < 12 { "am" } else { "pm" };
129                    result.push_str(ampm);
130                    i += 2;
131                }
132                'Z' if utc => {
133                    // Force "UTC" instead of platform-dependent "GMT"
134                    result.push_str("UTC");
135                    i += 2;
136                }
137                'n' => {
138                    result.push('\n');
139                    i += 2;
140                }
141                't' => {
142                    result.push('\t');
143                    i += 2;
144                }
145                _ => {
146                    // Pass this specifier to strftime
147                    let spec = format!("%{}", chars[i + 1]);
148                    let formatted = strftime_single(&tm, &spec);
149                    // Apply modifier if present
150                    let formatted = if let Some(mod_char) = modifier {
151                        apply_format_modifier(&formatted, mod_char)
152                    } else {
153                        formatted
154                    };
155                    result.push_str(&formatted);
156                    i += 2;
157                }
158            }
159        } else {
160            result.push(chars[i]);
161            i += 1;
162        }
163    }
164
165    result
166}
167
168/// Call libc strftime for a single format specifier.
169fn strftime_single(tm: &libc::tm, fmt: &str) -> String {
170    let c_fmt = match std::ffi::CString::new(fmt) {
171        Ok(c) => c,
172        Err(_) => return String::new(),
173    };
174    let mut buf = vec![0u8; 128];
175    let len = unsafe {
176        libc::strftime(
177            buf.as_mut_ptr() as *mut libc::c_char,
178            buf.len(),
179            c_fmt.as_ptr(),
180            tm,
181        )
182    };
183    if len == 0 && !fmt.is_empty() && fmt != "%%" {
184        // strftime returns 0 on error or if the result is empty string
185        // For %%, it legitimately returns "%"
186        return String::new();
187    }
188    buf.truncate(len);
189    String::from_utf8_lossy(&buf).into_owned()
190}
191
192/// Apply a GNU format modifier to a strftime result.
193/// '-' removes leading zeros/spaces (no padding).
194/// '_' replaces leading zeros with spaces.
195/// '0' replaces leading spaces with zeros.
196fn apply_format_modifier(formatted: &str, modifier: char) -> String {
197    match modifier {
198        '-' => {
199            // Remove leading zeros and spaces (no padding)
200            let trimmed = formatted.trim_start_matches(['0', ' ']);
201            if trimmed.is_empty() {
202                "0".to_string()
203            } else {
204                trimmed.to_string()
205            }
206        }
207        '_' => {
208            // Replace leading zeros with spaces
209            let mut result = String::with_capacity(formatted.len());
210            let mut leading = true;
211            for ch in formatted.chars() {
212                if leading && ch == '0' {
213                    result.push(' ');
214                } else {
215                    leading = false;
216                    result.push(ch);
217                }
218            }
219            result
220        }
221        '0' => {
222            // Replace leading spaces with zeros
223            let mut result = String::with_capacity(formatted.len());
224            let mut leading = true;
225            for ch in formatted.chars() {
226                if leading && ch == ' ' {
227                    result.push('0');
228                } else {
229                    leading = false;
230                    result.push(ch);
231                }
232            }
233            result
234        }
235        _ => formatted.to_string(),
236    }
237}
238
239/// Format a SystemTime in ISO 8601 format.
240pub fn format_iso(time: &SystemTime, precision: &IsoFormat, utc: bool) -> String {
241    match precision {
242        IsoFormat::Date => format_date(time, "%Y-%m-%d", utc),
243        IsoFormat::Hours => {
244            let date_part = format_date(time, "%Y-%m-%dT%H", utc);
245            let tz = format_timezone_colon(time, utc);
246            format!("{}{}", date_part, tz)
247        }
248        IsoFormat::Minutes => {
249            let date_part = format_date(time, "%Y-%m-%dT%H:%M", utc);
250            let tz = format_timezone_colon(time, utc);
251            format!("{}{}", date_part, tz)
252        }
253        IsoFormat::Seconds => {
254            let date_part = format_date(time, "%Y-%m-%dT%H:%M:%S", utc);
255            let tz = format_timezone_colon(time, utc);
256            format!("{}{}", date_part, tz)
257        }
258        IsoFormat::Ns => {
259            let dur = time.duration_since(UNIX_EPOCH).unwrap_or_default();
260            let nanos = dur.subsec_nanos();
261            let date_part = format_date(time, "%Y-%m-%dT%H:%M:%S", utc);
262            let tz = format_timezone_colon(time, utc);
263            format!("{},{:09}{}", date_part, nanos, tz)
264        }
265    }
266}
267
268/// Format a SystemTime in RFC 5322 (email) format.
269pub fn format_rfc_email(time: &SystemTime, utc: bool) -> String {
270    format_date(time, "%a, %d %b %Y %H:%M:%S %z", utc)
271}
272
273/// Format a SystemTime in RFC 3339 format.
274pub fn format_rfc3339(time: &SystemTime, precision: &Rfc3339Format, utc: bool) -> String {
275    match precision {
276        Rfc3339Format::Date => format_date(time, "%Y-%m-%d", utc),
277        Rfc3339Format::Seconds => {
278            let date_part = format_date(time, "%Y-%m-%d %H:%M:%S", utc);
279            let tz = format_timezone_colon(time, utc);
280            format!("{}{}", date_part, tz)
281        }
282        Rfc3339Format::Ns => {
283            let dur = time.duration_since(UNIX_EPOCH).unwrap_or_default();
284            let nanos = dur.subsec_nanos();
285            let date_part = format_date(time, "%Y-%m-%d %H:%M:%S", utc);
286            let tz = format_timezone_colon(time, utc);
287            format!("{}.{:09}{}", date_part, nanos, tz)
288        }
289    }
290}
291
292/// Format a timezone offset with a colon (e.g., +05:30).
293fn format_timezone_colon(time: &SystemTime, utc: bool) -> String {
294    if utc {
295        return "+00:00".to_string();
296    }
297    let raw = format_date(time, "%z", false);
298    // raw is like "+0530" or "-0800"
299    if raw.len() >= 5 {
300        format!("{}:{}", &raw[..3], &raw[3..5])
301    } else {
302        raw
303    }
304}
305
306/// Parse a date string into a SystemTime.
307///
308/// Supports:
309/// - ISO format: "2024-01-15 10:30:00", "2024-01-15T10:30:00"
310/// - Relative: "yesterday", "tomorrow", "now", "today"
311/// - Relative offset: "1 day ago", "2 hours ago", "3 days", "+1 week"
312/// - Epoch: "@SECONDS"
313pub fn parse_date_string(s: &str, utc: bool) -> Result<SystemTime, String> {
314    let s = s.trim();
315
316    // Handle epoch format: @SECONDS
317    if let Some(epoch_str) = s.strip_prefix('@') {
318        let secs: i64 = epoch_str
319            .trim()
320            .parse()
321            .map_err(|_| format!("invalid date '@{}'", epoch_str))?;
322        if secs >= 0 {
323            return Ok(UNIX_EPOCH + Duration::from_secs(secs as u64));
324        } else {
325            return Ok(UNIX_EPOCH - Duration::from_secs((-secs) as u64));
326        }
327    }
328
329    let now = SystemTime::now();
330
331    // Handle relative words
332    match s.to_lowercase().as_str() {
333        "now" | "today" => return Ok(now),
334        "yesterday" => {
335            return Ok(now - Duration::from_secs(86400));
336        }
337        "tomorrow" => {
338            return Ok(now + Duration::from_secs(86400));
339        }
340        _ => {}
341    }
342
343    // Handle relative offsets: "N unit ago", "N unit", "+N unit"
344    if let Some(result) = try_parse_relative(s, &now) {
345        return Ok(result);
346    }
347
348    // Try time-only format: "HH:MM[ +OFFSET]"
349    if let Some(result) = try_parse_time_only(s, utc) {
350        return Ok(result);
351    }
352
353    // Try ISO-like format: "YYYY-MM-DD[ HH:MM[:SS]]"
354    if let Some(result) = try_parse_iso(s, utc) {
355        return Ok(result);
356    }
357
358    Err(format!("invalid date '{}'", s))
359}
360
361/// Try to parse a time-only string like "HH:MM", "HH:MM:SS", "HH:MM +OFFSET".
362/// Interprets as today's date with the given time. Seconds default to 00.
363fn try_parse_time_only(s: &str, utc: bool) -> Option<SystemTime> {
364    let s = s.trim();
365    let parts: Vec<&str> = s.split_whitespace().collect();
366    if parts.is_empty() || parts.len() > 2 {
367        return None;
368    }
369
370    let time_str = parts[0];
371    let tz_str = if parts.len() == 2 {
372        Some(parts[1])
373    } else {
374        None
375    };
376
377    // Validate time format: HH:MM or HH:MM:SS
378    let time_fields: Vec<&str> = time_str.split(':').collect();
379    if time_fields.len() < 2 || time_fields.len() > 3 {
380        return None;
381    }
382
383    let hour: u32 = time_fields[0].parse().ok()?;
384    let minute: u32 = time_fields[1].parse().ok()?;
385    let second: u32 = if time_fields.len() == 3 {
386        time_fields[2].parse().ok()?
387    } else {
388        0
389    };
390
391    if hour > 23 || minute > 59 || second > 60 {
392        return None;
393    }
394
395    // Determine timezone offset in seconds east of UTC
396    let mut use_utc = utc;
397    let mut tz_offset_secs: i64 = 0;
398    if let Some(tz) = tz_str {
399        if tz.eq_ignore_ascii_case("UTC") || tz == "Z" {
400            use_utc = true;
401        } else if (tz.starts_with('+') || tz.starts_with('-')) && (tz.len() == 5 || tz.len() == 3) {
402            let sign: i64 = if tz.starts_with('-') { -1 } else { 1 };
403            let digits = &tz[1..];
404            if !digits.chars().all(|c| c.is_ascii_digit()) {
405                return None;
406            }
407            let (oh, om) = if digits.len() == 4 {
408                let h: i64 = digits[..2].parse().ok()?;
409                let m: i64 = digits[2..].parse().ok()?;
410                (h, m)
411            } else {
412                let h: i64 = digits.parse().ok()?;
413                (h, 0)
414            };
415            tz_offset_secs = sign * (oh * 3600 + om * 60);
416            use_utc = true;
417        } else {
418            return None;
419        }
420    }
421
422    // Get today's date
423    let now = SystemTime::now();
424    let now_secs = now.duration_since(UNIX_EPOCH).ok()?.as_secs() as i64;
425
426    let mut tm: libc::tm = unsafe { std::mem::zeroed() };
427    if use_utc {
428        let now_t = now_secs as libc::time_t;
429        unsafe {
430            libc::gmtime_r(&now_t, &mut tm);
431        }
432    } else {
433        let now_t = now_secs as libc::time_t;
434        unsafe {
435            libc::localtime_r(&now_t, &mut tm);
436        }
437    }
438
439    tm.tm_hour = hour as i32;
440    tm.tm_min = minute as i32;
441    tm.tm_sec = second as i32;
442    tm.tm_isdst = -1;
443
444    let epoch_secs = if use_utc {
445        unsafe { libc::timegm(&mut tm) }
446    } else {
447        unsafe { libc::mktime(&mut tm) }
448    };
449    if epoch_secs == -1 {
450        return None;
451    }
452
453    // Subtract timezone offset: input time is in the given tz, convert to UTC
454    let final_secs = epoch_secs as i64 - tz_offset_secs;
455
456    if final_secs >= 0 {
457        Some(UNIX_EPOCH + Duration::from_secs(final_secs as u64))
458    } else {
459        Some(UNIX_EPOCH - Duration::from_secs((-final_secs) as u64))
460    }
461}
462
463/// Try to parse a relative time expression.
464fn try_parse_relative(s: &str, now: &SystemTime) -> Option<SystemTime> {
465    let lower = s.to_lowercase();
466    let parts: Vec<&str> = lower.split_whitespace().collect();
467
468    if parts.len() < 2 {
469        return None;
470    }
471
472    let is_ago = parts.last().map_or(false, |&p| p == "ago");
473    let num_str = parts[0].trim_start_matches('+');
474    let amount: i64 = num_str.parse().ok()?;
475
476    let unit_idx = 1;
477    if unit_idx >= parts.len() {
478        return None;
479    }
480    let unit = parts[unit_idx];
481
482    let seconds = match unit.trim_end_matches('s') {
483        "second" => amount,
484        "minute" => amount * 60,
485        "hour" => amount * 3600,
486        "day" => amount * 86400,
487        "week" => amount * 86400 * 7,
488        "month" => amount * 86400 * 30,
489        "year" => amount * 86400 * 365,
490        _ => return None,
491    };
492
493    let duration = Duration::from_secs(seconds.unsigned_abs());
494    if is_ago || seconds < 0 {
495        Some(*now - duration)
496    } else {
497        Some(*now + duration)
498    }
499}
500
501/// Try to parse an ISO-like date string.
502fn try_parse_iso(s: &str, utc: bool) -> Option<SystemTime> {
503    // Split on space or T
504    let s = s.replace('T', " ");
505    let parts: Vec<&str> = s.splitn(2, ' ').collect();
506    let date_part = parts[0];
507    let time_part = if parts.len() > 1 {
508        parts[1]
509    } else {
510        "00:00:00"
511    };
512
513    let date_fields: Vec<&str> = date_part.split('-').collect();
514    if date_fields.len() != 3 {
515        return None;
516    }
517
518    let year: i32 = date_fields[0].parse().ok()?;
519    let month: u32 = date_fields[1].parse().ok()?;
520    let day: u32 = date_fields[2].parse().ok()?;
521
522    if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
523        return None;
524    }
525
526    // Parse time (strip timezone info for simplicity)
527    let time_clean = time_part
528        .split('+')
529        .next()
530        .unwrap_or(time_part)
531        .split('Z')
532        .next()
533        .unwrap_or(time_part);
534    let time_fields: Vec<&str> = time_clean.split(':').collect();
535    let hour: u32 = time_fields
536        .first()
537        .and_then(|s| s.parse().ok())
538        .unwrap_or(0);
539    let minute: u32 = time_fields.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
540    let second: u32 = time_fields
541        .get(2)
542        .and_then(|s| s.split('.').next())
543        .and_then(|s| s.parse().ok())
544        .unwrap_or(0);
545
546    if hour > 23 || minute > 59 || second > 60 {
547        return None;
548    }
549
550    // Convert to Unix timestamp using libc mktime
551    let mut tm: libc::tm = unsafe { std::mem::zeroed() };
552    tm.tm_year = year - 1900;
553    tm.tm_mon = month as i32 - 1;
554    tm.tm_mday = day as i32;
555    tm.tm_hour = hour as i32;
556    tm.tm_min = minute as i32;
557    tm.tm_sec = second as i32;
558    tm.tm_isdst = -1; // Let mktime determine DST
559
560    let epoch_secs = if utc {
561        unsafe { libc::timegm(&mut tm) }
562    } else {
563        unsafe { libc::mktime(&mut tm) }
564    };
565    if epoch_secs == -1 {
566        return None;
567    }
568
569    if epoch_secs >= 0 {
570        Some(UNIX_EPOCH + Duration::from_secs(epoch_secs as u64))
571    } else {
572        Some(UNIX_EPOCH - Duration::from_secs((-epoch_secs) as u64))
573    }
574}
575
576/// Get the modification time of a file.
577pub fn file_mod_time(path: &str) -> Result<SystemTime, String> {
578    std::fs::metadata(path)
579        .map_err(|e| format!("{}: {}", path, e))?
580        .modified()
581        .map_err(|e| format!("{}: {}", path, e))
582}
583
584/// Get the default date format (matches GNU date default output).
585/// Uses %H:%M:%S (24-hour clock) to match GNU behavior.
586pub fn default_format() -> &'static str {
587    "%a %b %e %H:%M:%S %Z %Y"
588}