Skip to main content

grit_lib/git_date/
show.rs

1//! Git-compatible date display (`show_date`, `show_date_relative`, strftime handling).
2
3use super::compat::{self, time_t, tm};
4use super::tm::{
5    empty_tm, get_time_sec, init_tm_unknown, local_time_tzoffset, local_tzoffset, time_to_tm,
6    time_to_tm_local, tm_to_time_t, TzHhmm,
7};
8use std::ffi::CString;
9use std::io::IsTerminal;
10
11const MONTH_NAMES: [&str; 12] = [
12    "January",
13    "February",
14    "March",
15    "April",
16    "May",
17    "June",
18    "July",
19    "August",
20    "September",
21    "October",
22    "November",
23    "December",
24];
25
26const WEEKDAY_NAMES: [&str; 7] = [
27    "Sundays",
28    "Mondays",
29    "Tuesdays",
30    "Wednesdays",
31    "Thursdays",
32    "Fridays",
33    "Saturdays",
34];
35
36#[derive(Clone, Copy, PartialEq, Eq)]
37pub enum DateModeType {
38    Normal,
39    Human,
40    Relative,
41    Short,
42    Iso8601,
43    Iso8601Strict,
44    Rfc2822,
45    Strftime,
46    Raw,
47    Unix,
48}
49
50pub struct DateMode {
51    pub ty: DateModeType,
52    pub local: bool,
53    pub strftime_fmt: Option<String>,
54}
55
56impl DateMode {
57    pub fn from_type(ty: DateModeType) -> Self {
58        Self {
59            ty,
60            local: false,
61            strftime_fmt: None,
62        }
63    }
64}
65
66pub fn parse_date_format(format: &str) -> Result<DateMode, &'static str> {
67    let mut s = format;
68    if let Some(rest) = s.strip_prefix("auto:") {
69        s = if std::io::stdout().is_terminal() {
70            rest
71        } else {
72            "default"
73        };
74    }
75    if s == "local" {
76        s = "default-local";
77    }
78    let (ty, mut p) = parse_date_type(s)?;
79    let mut local = false;
80    if let Some(r) = p.strip_prefix("-local") {
81        local = true;
82        p = r;
83    }
84    let mut mode = DateMode {
85        ty,
86        local,
87        strftime_fmt: None,
88    };
89    if ty == DateModeType::Strftime {
90        let rest = p
91            .strip_prefix(':')
92            .ok_or("date format missing colon separator")?;
93        mode.strftime_fmt = Some(rest.to_string());
94    } else if !p.is_empty() {
95        return Err("unknown date format");
96    }
97    Ok(mode)
98}
99
100fn parse_date_type(s: &str) -> Result<(DateModeType, &str), &'static str> {
101    if let Some(r) = s.strip_prefix("relative") {
102        return Ok((DateModeType::Relative, r));
103    }
104    if let Some(r) = s
105        .strip_prefix("iso8601-strict")
106        .or_else(|| s.strip_prefix("iso-strict"))
107    {
108        return Ok((DateModeType::Iso8601Strict, r));
109    }
110    if let Some(r) = s.strip_prefix("iso8601").or_else(|| s.strip_prefix("iso")) {
111        return Ok((DateModeType::Iso8601, r));
112    }
113    if let Some(r) = s.strip_prefix("rfc2822").or_else(|| s.strip_prefix("rfc")) {
114        return Ok((DateModeType::Rfc2822, r));
115    }
116    if let Some(r) = s.strip_prefix("short") {
117        return Ok((DateModeType::Short, r));
118    }
119    if let Some(r) = s.strip_prefix("default") {
120        return Ok((DateModeType::Normal, r));
121    }
122    if let Some(r) = s.strip_prefix("human") {
123        return Ok((DateModeType::Human, r));
124    }
125    if let Some(r) = s.strip_prefix("raw") {
126        return Ok((DateModeType::Raw, r));
127    }
128    if let Some(r) = s.strip_prefix("unix") {
129        return Ok((DateModeType::Unix, r));
130    }
131    if let Some(r) = s.strip_prefix("format") {
132        return Ok((DateModeType::Strftime, r));
133    }
134    Err("unknown date format")
135}
136
137pub fn date_mode_release(mode: &mut DateMode) {
138    mode.strftime_fmt = None;
139}
140
141pub fn show_date_relative(time: u64, now_sec: i64) -> String {
142    let now = now_sec as i128;
143    let t = time as i128;
144    if now < t {
145        return "in the future".to_string();
146    }
147    let mut diff = (now - t) as u64;
148    if diff < 90 {
149        return if diff == 1 {
150            "1 second ago".to_string()
151        } else {
152            format!("{diff} seconds ago")
153        };
154    }
155    diff = (diff + 30) / 60;
156    if diff < 90 {
157        return if diff == 1 {
158            "1 minute ago".to_string()
159        } else {
160            format!("{diff} minutes ago")
161        };
162    }
163    diff = (diff + 30) / 60;
164    if diff < 36 {
165        return if diff == 1 {
166            "1 hour ago".to_string()
167        } else {
168            format!("{diff} hours ago")
169        };
170    }
171    diff = (diff + 12) / 24;
172    if diff < 14 {
173        return if diff == 1 {
174            "1 day ago".to_string()
175        } else {
176            format!("{diff} days ago")
177        };
178    }
179    if diff < 70 {
180        let w = (diff + 3) / 7;
181        return if w == 1 {
182            "1 week ago".to_string()
183        } else {
184            format!("{w} weeks ago")
185        };
186    }
187    if diff < 365 {
188        let m = (diff + 15) / 30;
189        return if m == 1 {
190            "1 month ago".to_string()
191        } else {
192            format!("{m} months ago")
193        };
194    }
195    if diff < 1825 {
196        let totalmonths = (diff * 12 * 2 + 365) / (365 * 2);
197        let years = totalmonths / 12;
198        let months = totalmonths % 12;
199        if months > 0 {
200            let ys = if years == 1 {
201                "1 year".to_string()
202            } else {
203                format!("{years} years")
204            };
205            return if months == 1 {
206                format!("{ys}, 1 month ago")
207            } else {
208                format!("{ys}, {months} months ago")
209            };
210        }
211        return if years == 1 {
212            "1 year ago".to_string()
213        } else {
214            format!("{years} years ago")
215        };
216    }
217    let y = (diff + 183) / 365;
218    if y == 1 {
219        "1 year ago".to_string()
220    } else {
221        format!("{y} years ago")
222    }
223}
224
225fn strbuf_rtrim(s: &mut String) {
226    while let Some(c) = s.pop() {
227        if !c.is_whitespace() {
228            s.push(c);
229            break;
230        }
231    }
232}
233
234fn show_date_normal(
235    time: u64,
236    tm: &tm,
237    tz: TzHhmm,
238    human_tm: &tm,
239    human_tz: TzHhmm,
240    local: bool,
241) -> String {
242    #[derive(Clone, Copy)]
243    struct Hide {
244        year: bool,
245        date: bool,
246        wday: bool,
247        time: bool,
248        seconds: bool,
249        tz: bool,
250    }
251    let mut hide = Hide {
252        year: false,
253        date: false,
254        wday: false,
255        time: false,
256        seconds: false,
257        tz: false,
258    };
259
260    hide.tz = local || tz == human_tz;
261    hide.year = tm.tm_year == human_tm.tm_year;
262    if hide.year && tm.tm_mon == human_tm.tm_mon {
263        if tm.tm_mday > human_tm.tm_mday {
264            // future date in same month
265        } else if tm.tm_mday == human_tm.tm_mday {
266            hide.date = true;
267            hide.wday = true;
268        } else if tm.tm_mday + 5 > human_tm.tm_mday {
269            hide.date = true;
270        }
271    }
272
273    if hide.wday {
274        return show_date_relative(time, get_time_sec());
275    }
276
277    if human_tm.tm_year != 0 {
278        hide.seconds = true;
279        hide.tz |= !hide.date;
280        hide.wday = !hide.year;
281        hide.time = !hide.year;
282    }
283
284    let mut out = String::new();
285    if !hide.wday {
286        let w = WEEKDAY_NAMES[tm.tm_wday as usize].as_bytes();
287        out.push_str(std::str::from_utf8(&w[..3]).unwrap_or("Sun"));
288        out.push(' ');
289    }
290    if !hide.date {
291        let m = MONTH_NAMES[tm.tm_mon as usize].as_bytes();
292        out.push_str(std::str::from_utf8(&m[..3]).unwrap_or("Jan"));
293        out.push(' ');
294        out.push_str(&format!("{} ", tm.tm_mday));
295    }
296    if !hide.time {
297        out.push_str(&format!("{:02}:{:02}", tm.tm_hour, tm.tm_min));
298        if !hide.seconds {
299            out.push_str(&format!(":{:02}", tm.tm_sec));
300        }
301    } else {
302        strbuf_rtrim(&mut out);
303    }
304    if !hide.year {
305        out.push_str(&format!(" {}", tm.tm_year + 1900));
306    }
307    if !hide.tz {
308        out.push_str(&format!(" {:+05}", tz));
309    }
310    out
311}
312
313fn strbuf_expand_step(munged: &mut String, fmt: &mut &str) -> bool {
314    let Some(pct) = fmt.find('%') else {
315        munged.push_str(fmt);
316        *fmt = "";
317        return false;
318    };
319    munged.push_str(&fmt[..pct]);
320    *fmt = &fmt[pct + 1..];
321    true
322}
323
324pub fn strbuf_addftime(tm: &tm, tz_hhmm: TzHhmm, fmt: &str, suppress_tz_name: bool) -> String {
325    if fmt.is_empty() {
326        return String::new();
327    }
328    let mut munged = String::new();
329    let mut rest = fmt;
330    while strbuf_expand_step(&mut munged, &mut rest) {
331        if rest.starts_with('%') {
332            munged.push_str("%%");
333            rest = &rest[1..];
334        } else if rest.starts_with('s') {
335            let secs = tm_to_time_t(tm) as i64
336                - 3600 * (tz_hhmm / 100) as i64
337                - 60 * (tz_hhmm % 100) as i64;
338            munged.push_str(&format!("{secs}"));
339            rest = &rest[1..];
340        } else if rest.starts_with('z') {
341            munged.push_str(&format!("{:+05}", tz_hhmm));
342            rest = &rest[1..];
343        } else if !suppress_tz_name && rest.starts_with('Z') && tz_hhmm == 0 {
344            munged.push_str("UTC");
345            rest = &rest[1..];
346        } else if suppress_tz_name && rest.starts_with('Z') {
347            rest = &rest[1..];
348        } else {
349            munged.push('%');
350        }
351    }
352    strftime_c(&munged, tm)
353}
354
355fn strftime_c(fmt: &str, tm: &tm) -> String {
356    let mut buf = vec![0u8; 4096];
357    let cfmt = match CString::new(fmt) {
358        Ok(c) => c,
359        // Fall back to a fixed format when `fmt` contains an interior NUL.
360        Err(_) => match CString::new("%Y") {
361            Ok(c) => c,
362            Err(_) => return String::new(),
363        },
364    };
365    unsafe {
366        let n = compat::strftime(
367            buf.as_mut_ptr() as *mut std::ffi::c_char,
368            buf.len(),
369            cfmt.as_ptr(),
370            tm,
371        );
372        if n == 0 && !fmt.is_empty() {
373            let mut munged = fmt.to_string();
374            munged.push(' ');
375            if let Ok(c2) = CString::new(munged.as_str()) {
376                let n2 = compat::strftime(
377                    buf.as_mut_ptr() as *mut std::ffi::c_char,
378                    buf.len(),
379                    c2.as_ptr(),
380                    tm,
381                );
382                if n2 > 0 {
383                    return String::from_utf8_lossy(&buf[..n2 - 1]).into_owned();
384                }
385            }
386        }
387        String::from_utf8_lossy(&buf[..n]).into_owned()
388    }
389}
390
391pub fn show_date(time: u64, mut tz: TzHhmm, mode: &mut DateMode) -> String {
392    if mode.ty == DateModeType::Unix {
393        return format!("{time}");
394    }
395    let mut tmbuf = init_tm_unknown();
396    let mut human_tm = empty_tm();
397    let mut human_tz: TzHhmm = -1;
398
399    if mode.ty == DateModeType::Human {
400        let now = get_time_sec();
401        unsafe {
402            human_tz = local_time_tzoffset(now as time_t, &mut human_tm);
403        }
404    }
405
406    if mode.local {
407        tz = local_tzoffset(time);
408    }
409
410    if mode.ty == DateModeType::Raw {
411        return format!("{time} {:+05}", tz);
412    }
413
414    if mode.ty == DateModeType::Relative {
415        return show_date_relative(time, get_time_sec());
416    }
417
418    let mut tz = tz;
419    let ok = if mode.local {
420        unsafe { time_to_tm_local(time, &mut tmbuf).is_some() }
421    } else {
422        unsafe { time_to_tm(time, tz, &mut tmbuf).is_some() }
423    };
424    if !ok {
425        unsafe {
426            time_to_tm(0, 0, &mut tmbuf);
427        }
428        tz = 0;
429    }
430
431    match mode.ty {
432        DateModeType::Short => format!(
433            "{:04}-{:02}-{:02}",
434            tmbuf.tm_year + 1900,
435            tmbuf.tm_mon + 1,
436            tmbuf.tm_mday
437        ),
438        DateModeType::Iso8601 => format!(
439            "{:04}-{:02}-{:02} {:02}:{:02}:{:02} {:+05}",
440            tmbuf.tm_year + 1900,
441            tmbuf.tm_mon + 1,
442            tmbuf.tm_mday,
443            tmbuf.tm_hour,
444            tmbuf.tm_min,
445            tmbuf.tm_sec,
446            tz
447        ),
448        DateModeType::Iso8601Strict => {
449            let mut s = format!(
450                "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}",
451                tmbuf.tm_year + 1900,
452                tmbuf.tm_mon + 1,
453                tmbuf.tm_mday,
454                tmbuf.tm_hour,
455                tmbuf.tm_min,
456                tmbuf.tm_sec
457            );
458            if tz == 0 {
459                s.push('Z');
460            } else {
461                let sign = if tz >= 0 { '+' } else { '-' };
462                let a = tz.abs();
463                s.push(sign);
464                s.push_str(&format!("{:02}:{:02}", a / 100, a % 100));
465            }
466            s
467        }
468        DateModeType::Rfc2822 => format!(
469            "{}, {} {} {} {:02}:{:02}:{:02} {:+05}",
470            &WEEKDAY_NAMES[tmbuf.tm_wday as usize][..3],
471            tmbuf.tm_mday,
472            &MONTH_NAMES[tmbuf.tm_mon as usize][..3],
473            tmbuf.tm_year + 1900,
474            tmbuf.tm_hour,
475            tmbuf.tm_min,
476            tmbuf.tm_sec,
477            tz
478        ),
479        DateModeType::Strftime => {
480            let fmt = mode.strftime_fmt.as_deref().unwrap_or("");
481            strbuf_addftime(&tmbuf, tz, fmt, !mode.local)
482        }
483        _ => show_date_normal(time, &tmbuf, tz, &human_tm, human_tz, mode.local),
484    }
485}