Skip to main content

strgci/
main.rs

1//! Print compact HEAD commit info for shell prompts.
2//!
3//! # Errors
4//! - Repository discovery or HEAD resolution fails.
5
6use std::io::Write;
7use std::path::Path;
8
9use chrono::Utc;
10use rootcause::prelude::ResultExt as _;
11use ytil_sys::cli::Args as _;
12
13/// Maximum number of characters shown for the commit subject before truncating with `…`.
14const MAX_SUBJECT_LEN: usize = 33;
15
16/// Short hash length (standard Git abbreviation).
17const SHORT_HASH_LEN: usize = 7;
18
19#[ytil_sys::main]
20fn main() -> rootcause::Result<()> {
21    let args = ytil_sys::cli::get();
22    if args.has_help() {
23        println!("{}", include_str!("../help.txt"));
24        return Ok(());
25    }
26
27    let repo = ytil_git::repo::discover(Path::new("."))?;
28    let head = repo.head().context("error resolving HEAD")?;
29    let commit = head.peel_to_commit().context("error peeling HEAD to commit")?;
30
31    let hash = commit.id().to_string();
32    let short_hash = hash.get(..SHORT_HASH_LEN).unwrap_or(&hash);
33
34    let commit_epoch = commit.time().seconds();
35    let commit_seconds_delta = u64::try_from(Utc::now().timestamp().saturating_sub(commit_epoch).max(0))
36        .context("negative time delta after clamp")?;
37
38    let out = std::io::stdout();
39    let mut out = out.lock();
40    write!(out, "{short_hash} ")?;
41    write_commit_relative_time(&mut out, commit_seconds_delta)?;
42    write!(out, " | ")?;
43    write_commit_truncated_msg(&mut out, commit.summary().unwrap_or(""), MAX_SUBJECT_LEN)?;
44    writeln!(out)?;
45
46    Ok(())
47}
48
49/// Writes a duration in seconds as the shortest human-readable unit directly to `out`.
50fn write_commit_relative_time(out: &mut impl Write, secs: u64) -> std::io::Result<()> {
51    let (value, suffix) = if secs < 60 {
52        (secs, "s")
53    } else if secs < 3_600 {
54        (secs.saturating_div(60), "m")
55    } else if secs < 86_400 {
56        (secs.saturating_div(3_600), "h")
57    } else if secs < 604_800 {
58        (secs.saturating_div(86_400), "d")
59    } else if secs < 2_592_000 {
60        (secs.saturating_div(604_800), "w")
61    } else if secs < 31_536_000 {
62        (secs.saturating_div(2_592_000), "mo")
63    } else {
64        (secs.saturating_div(31_536_000), "y")
65    };
66
67    write!(out, "{value}{suffix}")
68}
69
70/// Writes up to `max` characters of `s`, appending `…` when the string is longer.
71fn write_commit_truncated_msg(out: &mut impl Write, commit_msg: &str, max: usize) -> std::io::Result<()> {
72    let boundary = commit_msg.char_indices().nth(max).map(|(i, _)| i);
73    match boundary {
74        Some(i) => write!(out, "{}…", commit_msg.get(..i).unwrap_or(commit_msg)),
75        None => out.write_all(commit_msg.as_bytes()),
76    }
77}
78
79#[cfg(test)]
80mod tests {
81    use rstest::rstest;
82
83    use super::*;
84
85    /// Helper: write to a `Vec<u8>` and return the resulting UTF-8 string.
86    fn collect(f: impl FnOnce(&mut Vec<u8>) -> std::io::Result<()>) -> String {
87        let mut buf = Vec::new();
88        f(&mut buf).unwrap();
89        String::from_utf8(buf).unwrap()
90    }
91
92    #[rstest]
93    #[case::zero_seconds(0, "0s")]
94    #[case::thirty_seconds(30, "30s")]
95    #[case::fifty_nine_seconds(59, "59s")]
96    #[case::one_minute(60, "1m")]
97    #[case::ninety_seconds(90, "1m")]
98    #[case::thirty_minutes(1_800, "30m")]
99    #[case::fifty_nine_minutes(3_599, "59m")]
100    #[case::one_hour(3_600, "1h")]
101    #[case::twelve_hours(43_200, "12h")]
102    #[case::twenty_three_hours(86_399, "23h")]
103    #[case::one_day(86_400, "1d")]
104    #[case::six_days(518_400, "6d")]
105    #[case::one_week(604_800, "1w")]
106    #[case::three_weeks(1_814_400, "3w")]
107    #[case::one_month(2_592_000, "1mo")]
108    #[case::six_months(15_552_000, "6mo")]
109    #[case::one_year(31_536_000, "1y")]
110    #[case::three_years(94_608_000, "3y")]
111    fn write_commit_relative_time_formats_correctly(#[case] secs: u64, #[case] expected: &str) {
112        let result = collect(|buf| write_commit_relative_time(buf, secs));
113        pretty_assertions::assert_eq!(result, expected);
114    }
115
116    #[rstest]
117    #[case::empty("", 33, "")]
118    #[case::short("hello", 33, "hello")]
119    #[case::exact_limit("abc", 3, "abc")]
120    #[case::one_over("abcd", 3, "abc…")]
121    #[case::long_subject(
122        "feat: add user authentication and session management",
123        33,
124        "feat: add user authentication and…"
125    )]
126    #[case::unicode_safe("áéíóú_abcdef", 5, "áéíóú…")]
127    fn write_commit_truncated_msg_formats_correctly(#[case] input: &str, #[case] max: usize, #[case] expected: &str) {
128        let result = collect(|buf| write_commit_truncated_msg(buf, input, max));
129        pretty_assertions::assert_eq!(result, expected);
130    }
131}