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::tm::{
4    empty_tm, get_time_sec, init_tm_unknown, local_time_tzoffset, local_tzoffset, time_to_tm,
5    time_to_tm_local, tm_to_time_t, TzHhmm,
6};
7use libc::{time_t, tm};
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') {
344            rest = &rest[1..];
345        } else {
346            munged.push('%');
347        }
348    }
349    strftime_c(&munged, tm)
350}
351
352fn strftime_c(fmt: &str, tm: &tm) -> String {
353    let mut buf = vec![0u8; 4096];
354    let cfmt = match CString::new(fmt) {
355        Ok(c) => c,
356        Err(_) => CString::new("%Y").unwrap(),
357    };
358    unsafe {
359        let n = libc::strftime(
360            buf.as_mut_ptr() as *mut libc::c_char,
361            buf.len(),
362            cfmt.as_ptr(),
363            tm,
364        );
365        if n == 0 && !fmt.is_empty() {
366            let mut munged = fmt.to_string();
367            munged.push(' ');
368            let c2 = CString::new(munged.as_str()).unwrap();
369            let n2 = libc::strftime(
370                buf.as_mut_ptr() as *mut libc::c_char,
371                buf.len(),
372                c2.as_ptr(),
373                tm,
374            );
375            if n2 > 0 {
376                return String::from_utf8_lossy(&buf[..n2 - 1]).into_owned();
377            }
378        }
379        String::from_utf8_lossy(&buf[..n]).into_owned()
380    }
381}
382
383pub fn show_date(time: u64, mut tz: TzHhmm, mode: &mut DateMode) -> String {
384    if mode.ty == DateModeType::Unix {
385        return format!("{time}");
386    }
387    let mut tmbuf = init_tm_unknown();
388    let mut human_tm = empty_tm();
389    let mut human_tz: TzHhmm = -1;
390
391    if mode.ty == DateModeType::Human {
392        let now = get_time_sec();
393        unsafe {
394            human_tz = local_time_tzoffset(now as time_t, &mut human_tm);
395        }
396    }
397
398    if mode.local {
399        tz = local_tzoffset(time);
400    }
401
402    if mode.ty == DateModeType::Raw {
403        return format!("{time} {:+05}", tz);
404    }
405
406    if mode.ty == DateModeType::Relative {
407        return show_date_relative(time, get_time_sec());
408    }
409
410    let mut tz = tz;
411    let ok = if mode.local {
412        unsafe { time_to_tm_local(time, &mut tmbuf).is_some() }
413    } else {
414        unsafe { time_to_tm(time, tz, &mut tmbuf).is_some() }
415    };
416    if !ok {
417        unsafe {
418            time_to_tm(0, 0, &mut tmbuf);
419        }
420        tz = 0;
421    }
422
423    match mode.ty {
424        DateModeType::Short => format!(
425            "{:04}-{:02}-{:02}",
426            tmbuf.tm_year + 1900,
427            tmbuf.tm_mon + 1,
428            tmbuf.tm_mday
429        ),
430        DateModeType::Iso8601 => format!(
431            "{:04}-{:02}-{:02} {:02}:{:02}:{:02} {:+05}",
432            tmbuf.tm_year + 1900,
433            tmbuf.tm_mon + 1,
434            tmbuf.tm_mday,
435            tmbuf.tm_hour,
436            tmbuf.tm_min,
437            tmbuf.tm_sec,
438            tz
439        ),
440        DateModeType::Iso8601Strict => {
441            let mut s = format!(
442                "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}",
443                tmbuf.tm_year + 1900,
444                tmbuf.tm_mon + 1,
445                tmbuf.tm_mday,
446                tmbuf.tm_hour,
447                tmbuf.tm_min,
448                tmbuf.tm_sec
449            );
450            if tz == 0 {
451                s.push('Z');
452            } else {
453                let sign = if tz >= 0 { '+' } else { '-' };
454                let a = tz.abs();
455                s.push(sign);
456                s.push_str(&format!("{:02}:{:02}", a / 100, a % 100));
457            }
458            s
459        }
460        DateModeType::Rfc2822 => format!(
461            "{}, {} {} {} {:02}:{:02}:{:02} {:+05}",
462            &WEEKDAY_NAMES[tmbuf.tm_wday as usize][..3],
463            tmbuf.tm_mday,
464            &MONTH_NAMES[tmbuf.tm_mon as usize][..3],
465            tmbuf.tm_year + 1900,
466            tmbuf.tm_hour,
467            tmbuf.tm_min,
468            tmbuf.tm_sec,
469            tz
470        ),
471        DateModeType::Strftime => {
472            let fmt = mode.strftime_fmt.as_deref().unwrap_or("");
473            strbuf_addftime(&tmbuf, tz, fmt, !mode.local)
474        }
475        _ => show_date_normal(time, &tmbuf, tz, &human_tm, human_tz, mode.local),
476    }
477}