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                'N' => {
108                    // Nanoseconds (9 digits, zero-padded)
109                    result.push_str(&format!("{:09}", nanos));
110                    i += 2;
111                }
112                'q' => {
113                    // Quarter (1-4), not in standard strftime
114                    let month = tm.tm_mon; // 0-11
115                    let quarter = (month / 3) + 1;
116                    result.push_str(&quarter.to_string());
117                    i += 2;
118                }
119                'P' => {
120                    // am/pm (lowercase), not always available in strftime
121                    let ampm = if tm.tm_hour < 12 { "am" } else { "pm" };
122                    result.push_str(ampm);
123                    i += 2;
124                }
125                'Z' if utc => {
126                    // Force "UTC" instead of platform-dependent "GMT"
127                    result.push_str("UTC");
128                    i += 2;
129                }
130                'n' => {
131                    result.push('\n');
132                    i += 2;
133                }
134                't' => {
135                    result.push('\t');
136                    i += 2;
137                }
138                _ => {
139                    // Pass this specifier to strftime
140                    let spec = format!("%{}", chars[i + 1]);
141                    let formatted = strftime_single(&tm, &spec);
142                    // Apply modifier if present
143                    let formatted = if let Some(mod_char) = modifier {
144                        apply_format_modifier(&formatted, mod_char)
145                    } else {
146                        formatted
147                    };
148                    result.push_str(&formatted);
149                    i += 2;
150                }
151            }
152        } else {
153            result.push(chars[i]);
154            i += 1;
155        }
156    }
157
158    result
159}
160
161/// Call libc strftime for a single format specifier.
162fn strftime_single(tm: &libc::tm, fmt: &str) -> String {
163    let c_fmt = match std::ffi::CString::new(fmt) {
164        Ok(c) => c,
165        Err(_) => return String::new(),
166    };
167    let mut buf = vec![0u8; 128];
168    let len = unsafe {
169        libc::strftime(
170            buf.as_mut_ptr() as *mut libc::c_char,
171            buf.len(),
172            c_fmt.as_ptr(),
173            tm,
174        )
175    };
176    if len == 0 && !fmt.is_empty() && fmt != "%%" {
177        // strftime returns 0 on error or if the result is empty string
178        // For %%, it legitimately returns "%"
179        return String::new();
180    }
181    buf.truncate(len);
182    String::from_utf8_lossy(&buf).into_owned()
183}
184
185/// Apply a GNU format modifier to a strftime result.
186/// '-' removes leading zeros/spaces (no padding).
187/// '_' replaces leading zeros with spaces.
188/// '0' replaces leading spaces with zeros.
189fn apply_format_modifier(formatted: &str, modifier: char) -> String {
190    match modifier {
191        '-' => {
192            // Remove leading zeros and spaces (no padding)
193            let trimmed = formatted.trim_start_matches(['0', ' ']);
194            if trimmed.is_empty() {
195                "0".to_string()
196            } else {
197                trimmed.to_string()
198            }
199        }
200        '_' => {
201            // Replace leading zeros with spaces
202            let mut result = String::with_capacity(formatted.len());
203            let mut leading = true;
204            for ch in formatted.chars() {
205                if leading && ch == '0' {
206                    result.push(' ');
207                } else {
208                    leading = false;
209                    result.push(ch);
210                }
211            }
212            result
213        }
214        '0' => {
215            // Replace leading spaces with zeros
216            let mut result = String::with_capacity(formatted.len());
217            let mut leading = true;
218            for ch in formatted.chars() {
219                if leading && ch == ' ' {
220                    result.push('0');
221                } else {
222                    leading = false;
223                    result.push(ch);
224                }
225            }
226            result
227        }
228        _ => formatted.to_string(),
229    }
230}
231
232/// Format a SystemTime in ISO 8601 format.
233pub fn format_iso(time: &SystemTime, precision: &IsoFormat, utc: bool) -> String {
234    match precision {
235        IsoFormat::Date => format_date(time, "%Y-%m-%d", utc),
236        IsoFormat::Hours => {
237            let date_part = format_date(time, "%Y-%m-%dT%H", utc);
238            let tz = format_timezone_colon(time, utc);
239            format!("{}{}", date_part, tz)
240        }
241        IsoFormat::Minutes => {
242            let date_part = format_date(time, "%Y-%m-%dT%H:%M", utc);
243            let tz = format_timezone_colon(time, utc);
244            format!("{}{}", date_part, tz)
245        }
246        IsoFormat::Seconds => {
247            let date_part = format_date(time, "%Y-%m-%dT%H:%M:%S", utc);
248            let tz = format_timezone_colon(time, utc);
249            format!("{}{}", date_part, tz)
250        }
251        IsoFormat::Ns => {
252            let dur = time.duration_since(UNIX_EPOCH).unwrap_or_default();
253            let nanos = dur.subsec_nanos();
254            let date_part = format_date(time, "%Y-%m-%dT%H:%M:%S", utc);
255            let tz = format_timezone_colon(time, utc);
256            format!("{},{:09}{}", date_part, nanos, tz)
257        }
258    }
259}
260
261/// Format a SystemTime in RFC 5322 (email) format.
262pub fn format_rfc_email(time: &SystemTime, utc: bool) -> String {
263    format_date(time, "%a, %d %b %Y %H:%M:%S %z", utc)
264}
265
266/// Format a SystemTime in RFC 3339 format.
267pub fn format_rfc3339(time: &SystemTime, precision: &Rfc3339Format, utc: bool) -> String {
268    match precision {
269        Rfc3339Format::Date => format_date(time, "%Y-%m-%d", utc),
270        Rfc3339Format::Seconds => {
271            let date_part = format_date(time, "%Y-%m-%d %H:%M:%S", utc);
272            let tz = format_timezone_colon(time, utc);
273            format!("{}{}", date_part, tz)
274        }
275        Rfc3339Format::Ns => {
276            let dur = time.duration_since(UNIX_EPOCH).unwrap_or_default();
277            let nanos = dur.subsec_nanos();
278            let date_part = format_date(time, "%Y-%m-%d %H:%M:%S", utc);
279            let tz = format_timezone_colon(time, utc);
280            format!("{}.{:09}{}", date_part, nanos, tz)
281        }
282    }
283}
284
285/// Format a timezone offset with a colon (e.g., +05:30).
286fn format_timezone_colon(time: &SystemTime, utc: bool) -> String {
287    if utc {
288        return "+00:00".to_string();
289    }
290    let raw = format_date(time, "%z", false);
291    // raw is like "+0530" or "-0800"
292    if raw.len() >= 5 {
293        format!("{}:{}", &raw[..3], &raw[3..5])
294    } else {
295        raw
296    }
297}
298
299/// Parse a date string into a SystemTime.
300///
301/// Supports:
302/// - ISO format: "2024-01-15 10:30:00", "2024-01-15T10:30:00"
303/// - Relative: "yesterday", "tomorrow", "now", "today"
304/// - Relative offset: "1 day ago", "2 hours ago", "3 days", "+1 week"
305/// - Epoch: "@SECONDS"
306pub fn parse_date_string(s: &str) -> Result<SystemTime, String> {
307    let s = s.trim();
308
309    // Handle epoch format: @SECONDS
310    if let Some(epoch_str) = s.strip_prefix('@') {
311        let secs: i64 = epoch_str
312            .trim()
313            .parse()
314            .map_err(|_| format!("invalid date '@{}'", epoch_str))?;
315        if secs >= 0 {
316            return Ok(UNIX_EPOCH + Duration::from_secs(secs as u64));
317        } else {
318            return Ok(UNIX_EPOCH - Duration::from_secs((-secs) as u64));
319        }
320    }
321
322    let now = SystemTime::now();
323
324    // Handle relative words
325    match s.to_lowercase().as_str() {
326        "now" | "today" => return Ok(now),
327        "yesterday" => {
328            return Ok(now - Duration::from_secs(86400));
329        }
330        "tomorrow" => {
331            return Ok(now + Duration::from_secs(86400));
332        }
333        _ => {}
334    }
335
336    // Handle relative offsets: "N unit ago", "N unit", "+N unit"
337    if let Some(result) = try_parse_relative(s, &now) {
338        return Ok(result);
339    }
340
341    // Try ISO-like format: "YYYY-MM-DD[ HH:MM[:SS]]"
342    if let Some(result) = try_parse_iso(s) {
343        return Ok(result);
344    }
345
346    Err(format!("invalid date '{}'", s))
347}
348
349/// Try to parse a relative time expression.
350fn try_parse_relative(s: &str, now: &SystemTime) -> Option<SystemTime> {
351    let lower = s.to_lowercase();
352    let parts: Vec<&str> = lower.split_whitespace().collect();
353
354    if parts.len() < 2 {
355        return None;
356    }
357
358    let is_ago = parts.last().map_or(false, |&p| p == "ago");
359    let num_str = parts[0].trim_start_matches('+');
360    let amount: i64 = num_str.parse().ok()?;
361
362    let unit_idx = 1;
363    if unit_idx >= parts.len() {
364        return None;
365    }
366    let unit = parts[unit_idx];
367
368    let seconds = match unit.trim_end_matches('s') {
369        "second" => amount,
370        "minute" => amount * 60,
371        "hour" => amount * 3600,
372        "day" => amount * 86400,
373        "week" => amount * 86400 * 7,
374        "month" => amount * 86400 * 30,
375        "year" => amount * 86400 * 365,
376        _ => return None,
377    };
378
379    let duration = Duration::from_secs(seconds.unsigned_abs());
380    if is_ago || seconds < 0 {
381        Some(*now - duration)
382    } else {
383        Some(*now + duration)
384    }
385}
386
387/// Try to parse an ISO-like date string.
388fn try_parse_iso(s: &str) -> Option<SystemTime> {
389    // Split on space or T
390    let s = s.replace('T', " ");
391    let parts: Vec<&str> = s.splitn(2, ' ').collect();
392    let date_part = parts[0];
393    let time_part = if parts.len() > 1 {
394        parts[1]
395    } else {
396        "00:00:00"
397    };
398
399    let date_fields: Vec<&str> = date_part.split('-').collect();
400    if date_fields.len() != 3 {
401        return None;
402    }
403
404    let year: i32 = date_fields[0].parse().ok()?;
405    let month: u32 = date_fields[1].parse().ok()?;
406    let day: u32 = date_fields[2].parse().ok()?;
407
408    if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
409        return None;
410    }
411
412    // Parse time (strip timezone info for simplicity)
413    let time_clean = time_part
414        .split('+')
415        .next()
416        .unwrap_or(time_part)
417        .split('Z')
418        .next()
419        .unwrap_or(time_part);
420    let time_fields: Vec<&str> = time_clean.split(':').collect();
421    let hour: u32 = time_fields
422        .first()
423        .and_then(|s| s.parse().ok())
424        .unwrap_or(0);
425    let minute: u32 = time_fields.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
426    let second: u32 = time_fields
427        .get(2)
428        .and_then(|s| s.split('.').next())
429        .and_then(|s| s.parse().ok())
430        .unwrap_or(0);
431
432    if hour > 23 || minute > 59 || second > 60 {
433        return None;
434    }
435
436    // Convert to Unix timestamp using libc mktime
437    let mut tm: libc::tm = unsafe { std::mem::zeroed() };
438    tm.tm_year = year - 1900;
439    tm.tm_mon = month as i32 - 1;
440    tm.tm_mday = day as i32;
441    tm.tm_hour = hour as i32;
442    tm.tm_min = minute as i32;
443    tm.tm_sec = second as i32;
444    tm.tm_isdst = -1; // Let mktime determine DST
445
446    let epoch_secs = unsafe { libc::mktime(&mut tm) };
447    if epoch_secs == -1 {
448        return None;
449    }
450
451    if epoch_secs >= 0 {
452        Some(UNIX_EPOCH + Duration::from_secs(epoch_secs as u64))
453    } else {
454        Some(UNIX_EPOCH - Duration::from_secs((-epoch_secs) as u64))
455    }
456}
457
458/// Get the modification time of a file.
459pub fn file_mod_time(path: &str) -> Result<SystemTime, String> {
460    std::fs::metadata(path)
461        .map_err(|e| format!("{}: {}", path, e))?
462        .modified()
463        .map_err(|e| format!("{}: {}", path, e))
464}
465
466/// Get the default date format (matches GNU date default output).
467pub fn default_format() -> &'static str {
468    "%a %b %e %H:%M:%S %Z %Y"
469}