1use std::io::Write;
7use std::path::Path;
8
9use chrono::Utc;
10use rootcause::prelude::ResultExt as _;
11use ytil_sys::cli::Args as _;
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().unwrap_or(""), MAX_SUBJECT_LEN)?;
44 writeln!(out)?;
45
46 Ok(())
47}
48
49fn 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
70fn 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 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}