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;
11use ytil_sys::cli::Args;
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    // Commit subject is only prompt decoration; keep the previous empty fallback for missing
44    // or undecodable libgit2 summaries.
45    write_commit_truncated_msg(&mut out, commit.summary().ok().flatten().unwrap_or(""), MAX_SUBJECT_LEN)?;
46    writeln!(out)?;
47
48    Ok(())
49}
50
51/// Writes a duration in seconds as the shortest human-readable unit directly to `out`.
52fn write_commit_relative_time(out: &mut impl Write, secs: u64) -> std::io::Result<()> {
53    let (value, suffix) = if secs < 60 {
54        (secs, "s")
55    } else if secs < 3_600 {
56        (secs.saturating_div(60), "m")
57    } else if secs < 86_400 {
58        (secs.saturating_div(3_600), "h")
59    } else if secs < 604_800 {
60        (secs.saturating_div(86_400), "d")
61    } else if secs < 2_592_000 {
62        (secs.saturating_div(604_800), "w")
63    } else if secs < 31_536_000 {
64        (secs.saturating_div(2_592_000), "mo")
65    } else {
66        (secs.saturating_div(31_536_000), "y")
67    };
68
69    write!(out, "{value}{suffix}")
70}
71
72/// Writes up to `max` characters of `s`, appending `…` when the string is longer.
73fn write_commit_truncated_msg(out: &mut impl Write, commit_msg: &str, max: usize) -> std::io::Result<()> {
74    let boundary = commit_msg.char_indices().nth(max).map(|(i, _)| i);
75    match boundary {
76        Some(i) => write!(out, "{}…", commit_msg.get(..i).unwrap_or(commit_msg)),
77        None => out.write_all(commit_msg.as_bytes()),
78    }
79}
80
81#[cfg(test)]
82mod tests {
83    use rstest::rstest;
84
85    use super::*;
86
87    /// Helper: write to a `Vec<u8>` and return the resulting UTF-8 string.
88    fn collect(f: impl FnOnce(&mut Vec<u8>) -> std::io::Result<()>) -> String {
89        let mut buf = Vec::new();
90        f(&mut buf).unwrap();
91        String::from_utf8(buf).unwrap()
92    }
93
94    #[rstest]
95    #[case::zero_seconds(0, "0s")]
96    #[case::thirty_seconds(30, "30s")]
97    #[case::fifty_nine_seconds(59, "59s")]
98    #[case::one_minute(60, "1m")]
99    #[case::ninety_seconds(90, "1m")]
100    #[case::thirty_minutes(1_800, "30m")]
101    #[case::fifty_nine_minutes(3_599, "59m")]
102    #[case::one_hour(3_600, "1h")]
103    #[case::twelve_hours(43_200, "12h")]
104    #[case::twenty_three_hours(86_399, "23h")]
105    #[case::one_day(86_400, "1d")]
106    #[case::six_days(518_400, "6d")]
107    #[case::one_week(604_800, "1w")]
108    #[case::three_weeks(1_814_400, "3w")]
109    #[case::one_month(2_592_000, "1mo")]
110    #[case::six_months(15_552_000, "6mo")]
111    #[case::one_year(31_536_000, "1y")]
112    #[case::three_years(94_608_000, "3y")]
113    fn test_write_commit_relative_time_formats_correctly(#[case] secs: u64, #[case] expected: &str) {
114        let result = collect(|buf| write_commit_relative_time(buf, secs));
115        pretty_assertions::assert_eq!(result, expected);
116    }
117
118    #[rstest]
119    #[case::empty("", 33, "")]
120    #[case::short("hello", 33, "hello")]
121    #[case::exact_limit("abc", 3, "abc")]
122    #[case::one_over("abcd", 3, "abc…")]
123    #[case::long_subject(
124        "feat: add user authentication and session management",
125        33,
126        "feat: add user authentication and…"
127    )]
128    #[case::unicode_safe("áéíóú_abcdef", 5, "áéíóú…")]
129    fn write_commit_truncated_msg_formats_correctly(#[case] input: &str, #[case] max: usize, #[case] expected: &str) {
130        let result = collect(|buf| write_commit_truncated_msg(buf, input, max));
131        pretty_assertions::assert_eq!(result, expected);
132    }
133}