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 ISO-like format: "YYYY-MM-DD[ HH:MM[:SS]]"
349    if let Some(result) = try_parse_iso(s, utc) {
350        return Ok(result);
351    }
352
353    Err(format!("invalid date '{}'", s))
354}
355
356/// Try to parse a relative time expression.
357fn try_parse_relative(s: &str, now: &SystemTime) -> Option<SystemTime> {
358    let lower = s.to_lowercase();
359    let parts: Vec<&str> = lower.split_whitespace().collect();
360
361    if parts.len() < 2 {
362        return None;
363    }
364
365    let is_ago = parts.last().map_or(false, |&p| p == "ago");
366    let num_str = parts[0].trim_start_matches('+');
367    let amount: i64 = num_str.parse().ok()?;
368
369    let unit_idx = 1;
370    if unit_idx >= parts.len() {
371        return None;
372    }
373    let unit = parts[unit_idx];
374
375    let seconds = match unit.trim_end_matches('s') {
376        "second" => amount,
377        "minute" => amount * 60,
378        "hour" => amount * 3600,
379        "day" => amount * 86400,
380        "week" => amount * 86400 * 7,
381        "month" => amount * 86400 * 30,
382        "year" => amount * 86400 * 365,
383        _ => return None,
384    };
385
386    let duration = Duration::from_secs(seconds.unsigned_abs());
387    if is_ago || seconds < 0 {
388        Some(*now - duration)
389    } else {
390        Some(*now + duration)
391    }
392}
393
394/// Try to parse an ISO-like date string.
395fn try_parse_iso(s: &str, utc: bool) -> Option<SystemTime> {
396    // Split on space or T
397    let s = s.replace('T', " ");
398    let parts: Vec<&str> = s.splitn(2, ' ').collect();
399    let date_part = parts[0];
400    let time_part = if parts.len() > 1 {
401        parts[1]
402    } else {
403        "00:00:00"
404    };
405
406    let date_fields: Vec<&str> = date_part.split('-').collect();
407    if date_fields.len() != 3 {
408        return None;
409    }
410
411    let year: i32 = date_fields[0].parse().ok()?;
412    let month: u32 = date_fields[1].parse().ok()?;
413    let day: u32 = date_fields[2].parse().ok()?;
414
415    if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
416        return None;
417    }
418
419    // Parse time (strip timezone info for simplicity)
420    let time_clean = time_part
421        .split('+')
422        .next()
423        .unwrap_or(time_part)
424        .split('Z')
425        .next()
426        .unwrap_or(time_part);
427    let time_fields: Vec<&str> = time_clean.split(':').collect();
428    let hour: u32 = time_fields
429        .first()
430        .and_then(|s| s.parse().ok())
431        .unwrap_or(0);
432    let minute: u32 = time_fields.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
433    let second: u32 = time_fields
434        .get(2)
435        .and_then(|s| s.split('.').next())
436        .and_then(|s| s.parse().ok())
437        .unwrap_or(0);
438
439    if hour > 23 || minute > 59 || second > 60 {
440        return None;
441    }
442
443    // Convert to Unix timestamp using libc mktime
444    let mut tm: libc::tm = unsafe { std::mem::zeroed() };
445    tm.tm_year = year - 1900;
446    tm.tm_mon = month as i32 - 1;
447    tm.tm_mday = day as i32;
448    tm.tm_hour = hour as i32;
449    tm.tm_min = minute as i32;
450    tm.tm_sec = second as i32;
451    tm.tm_isdst = -1; // Let mktime determine DST
452
453    let epoch_secs = if utc {
454        unsafe { libc::timegm(&mut tm) }
455    } else {
456        unsafe { libc::mktime(&mut tm) }
457    };
458    if epoch_secs == -1 {
459        return None;
460    }
461
462    if epoch_secs >= 0 {
463        Some(UNIX_EPOCH + Duration::from_secs(epoch_secs as u64))
464    } else {
465        Some(UNIX_EPOCH - Duration::from_secs((-epoch_secs) as u64))
466    }
467}
468
469/// Get the modification time of a file.
470pub fn file_mod_time(path: &str) -> Result<SystemTime, String> {
471    std::fs::metadata(path)
472        .map_err(|e| format!("{}: {}", path, e))?
473        .modified()
474        .map_err(|e| format!("{}: {}", path, e))
475}
476
477/// Get the default date format (matches GNU date default output).
478/// Uses %H:%M:%S (24-hour clock) to match GNU behavior.
479pub fn default_format() -> &'static str {
480    "%a %b %e %H:%M:%S %Z %Y"
481}