1use chrono::Utc;
23
24pub fn id_short(id: &str) -> &str {
31 let end = id.len().min(8);
32 let mut end = end;
33 while end > 0 && !id.is_char_boundary(end) {
34 end -= 1;
35 }
36 &id[..end]
37}
38
39pub fn auto_namespace() -> String {
44 if let Ok(out) = std::process::Command::new("git")
45 .args(["remote", "get-url", "origin"])
46 .stderr(std::process::Stdio::null())
47 .output()
48 {
49 let url = String::from_utf8_lossy(&out.stdout).trim().to_string();
50 if !url.is_empty()
51 && let Some(name) = url.rsplit('/').next()
52 {
53 let name = name.trim_end_matches(".git");
54 if !name.is_empty() {
55 return name.to_string();
56 }
57 }
58 }
59 std::env::current_dir()
60 .ok()
61 .and_then(|p| p.file_name().map(|n| n.to_string_lossy().to_string()))
62 .unwrap_or_else(|| "global".to_string())
63}
64
65pub fn human_age(iso: &str) -> String {
69 let Ok(dt) = chrono::DateTime::parse_from_rfc3339(iso) else {
70 return iso.to_string();
71 };
72 let dur = Utc::now().signed_duration_since(dt);
73 if dur.num_seconds() < 60 {
74 return "just now".to_string();
75 }
76 if dur.num_minutes() < 60 {
77 return format!("{}m ago", dur.num_minutes());
78 }
79 if dur.num_hours() < 24 {
80 return format!("{}h ago", dur.num_hours());
81 }
82 if dur.num_days() < 30 {
83 return format!("{}d ago", dur.num_days());
84 }
85 format!("{}mo ago", dur.num_days() / 30)
86}
87
88#[cfg(test)]
89mod tests {
90 use super::*;
91
92 #[test]
95 fn test_id_short_empty() {
96 assert_eq!(id_short(""), "");
97 }
98
99 #[test]
100 fn test_id_short_under_8() {
101 assert_eq!(id_short("abc"), "abc");
102 assert_eq!(id_short("1234567"), "1234567");
103 }
104
105 #[test]
106 fn test_id_short_exactly_8() {
107 assert_eq!(id_short("12345678"), "12345678");
108 }
109
110 #[test]
111 fn test_id_short_over_8() {
112 assert_eq!(id_short("abcdefghijklmnop"), "abcdefgh");
113 }
114
115 #[test]
116 fn test_id_short_utf8_boundary() {
117 let s = "abcdefgé";
121 let out = id_short(s);
122 assert!(out.len() <= 8);
125 assert_eq!(out, "abcdefg");
126 }
127
128 #[test]
131 fn test_human_age_just_now() {
132 let now = Utc::now().to_rfc3339();
133 assert_eq!(human_age(&now), "just now");
134 }
135
136 #[test]
137 fn test_human_age_minutes() {
138 let past = (Utc::now() - chrono::Duration::minutes(5)).to_rfc3339();
139 let age = human_age(&past);
140 assert!(age.ends_with("m ago"), "got: {age}");
141 }
142
143 #[test]
144 fn test_human_age_hours() {
145 let past = (Utc::now() - chrono::Duration::hours(3)).to_rfc3339();
146 let age = human_age(&past);
147 assert!(age.ends_with("h ago"), "got: {age}");
148 }
149
150 #[test]
151 fn test_human_age_days() {
152 let past = (Utc::now() - chrono::Duration::days(5)).to_rfc3339();
153 let age = human_age(&past);
154 assert!(age.ends_with("d ago"), "got: {age}");
155 }
156
157 #[test]
158 fn test_human_age_months() {
159 let past = (Utc::now() - chrono::Duration::days(120)).to_rfc3339();
160 let age = human_age(&past);
161 assert!(age.ends_with("mo ago"), "got: {age}");
162 }
163
164 #[test]
165 fn test_human_age_invalid_rfc3339_returns_input() {
166 assert_eq!(human_age("not-a-date"), "not-a-date");
167 assert_eq!(human_age(""), "");
168 }
169
170 #[test]
171 fn test_human_age_future_timestamp() {
172 let future = (Utc::now() + chrono::Duration::seconds(30)).to_rfc3339();
176 let out = human_age(&future);
177 assert!(!out.is_empty());
179 }
180
181 #[test]
184 fn test_auto_namespace_in_git_repo() {
185 let ns = auto_namespace();
189 assert!(!ns.is_empty(), "auto_namespace must return non-empty");
190 }
191
192 #[test]
193 fn test_auto_namespace_no_git_uses_dirname() {
194 let ns = auto_namespace();
199 assert!(!ns.is_empty());
200 }
201
202 #[test]
203 fn test_auto_namespace_falls_back_to_global() {
204 let ns = auto_namespace();
208 assert!(!ns.is_empty());
209 }
210}