1use std::io::Write;
7use std::path::Path;
8
9use chrono::Utc;
10use rootcause::prelude::ResultExt;
11use ytil_sys::cli::Args;
12
13const MAX_SUBJECT_LEN: usize = 33;
15
16const 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().ok().flatten().unwrap_or(""), MAX_SUBJECT_LEN)?;
46 writeln!(out)?;
47
48 Ok(())
49}
50
51fn 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
72fn 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 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}