1use chrono::{DateTime, FixedOffset, Local, Locale, NaiveDate, Utc};
23
24pub const DEFAULT_LOCALE: Locale = Locale::POSIX;
25
26pub fn now_in(timezone: Option<&str>) -> DateTime<FixedOffset> {
27 match parse_tz(timezone) {
28 Some(tz) => Utc::now().with_timezone(&tz).fixed_offset(),
29 None => Local::now().fixed_offset(),
30 }
31}
32
33pub fn today_in(timezone: Option<&str>) -> NaiveDate {
34 now_in(timezone).date_naive()
35}
36
37pub fn parse_tz(name: Option<&str>) -> Option<chrono_tz::Tz> {
38 name.and_then(|s| s.parse().ok())
39}
40
41pub fn parse_locale(name: Option<&str>) -> Locale {
42 name.and_then(|s| Locale::try_from(s).ok())
43 .unwrap_or(DEFAULT_LOCALE)
44}
45
46pub fn format_local<Tz>(dt: &DateTime<Tz>, fmt: &str, locale: Option<&str>) -> String
47where
48 Tz: chrono::TimeZone,
49 Tz::Offset: std::fmt::Display,
50{
51 let loc = parse_locale(locale);
52 format_with(dt, fmt, loc).unwrap_or_else(|| dt.format("%H:%M").to_string())
53}
54
55fn format_with<Tz>(dt: &DateTime<Tz>, fmt: &str, locale: Locale) -> Option<String>
56where
57 Tz: chrono::TimeZone,
58 Tz::Offset: std::fmt::Display,
59{
60 use std::fmt::Write;
61 let mut buf = String::new();
62 write!(&mut buf, "{}", dt.format_localized(fmt, locale)).ok()?;
63 Some(buf)
64}
65
66pub fn format_relative_compact(seconds_ago: i64, _locale: Option<&str>) -> Option<String> {
70 if seconds_ago.abs() < 45 {
71 return Some("now".into());
72 }
73 let abs = seconds_ago.abs();
74 let body = compact_body(abs)?;
75 Some(if seconds_ago >= 0 {
76 body
77 } else {
78 format!("in {body}")
79 })
80}
81
82fn compact_body(abs: i64) -> Option<String> {
83 const MIN: i64 = 60;
84 const HOUR: i64 = 60 * 60;
85 const DAY: i64 = 60 * 60 * 24;
86 const WEEK: i64 = DAY * 7;
87 if abs < HOUR {
88 Some(format!("{}m", (abs + MIN / 2) / MIN))
89 } else if abs < DAY {
90 Some(format!("{}h", abs / HOUR))
91 } else if abs < WEEK {
92 Some(format!("{}d", abs / DAY))
93 } else if abs < WEEK * 4 {
94 Some(format!("{}w", abs / WEEK))
95 } else {
96 None
97 }
98}
99
100#[cfg(test)]
101mod tests {
102 use super::*;
103 use chrono::TimeZone;
104
105 #[test]
106 fn now_in_invalid_tz_falls_back_to_local() {
107 let _ = now_in(Some("Not/AZone"));
108 }
109
110 #[test]
111 fn now_in_resolves_iana_name() {
112 let dt = now_in(Some("Asia/Tokyo"));
113 assert_eq!(dt.offset().local_minus_utc(), 9 * 3600);
114 }
115
116 #[test]
117 fn parse_locale_defaults_to_posix() {
118 assert_eq!(parse_locale(None), Locale::POSIX);
119 assert_eq!(parse_locale(Some("nonsense_XX")), Locale::POSIX);
120 }
121
122 #[test]
123 fn parse_locale_recognises_known_name() {
124 assert_eq!(parse_locale(Some("ja_JP")), Locale::ja_JP);
125 }
126
127 #[test]
128 fn format_local_invalid_directive_falls_back_to_safe_default() {
129 let dt = Utc.with_ymd_and_hms(2026, 4, 22, 12, 34, 0).unwrap();
130 assert_eq!(format_local(&dt, "%Q", None), "12:34");
131 }
132
133 #[test]
134 fn format_local_default_locale_matches_posix_format() {
135 let dt = Utc.with_ymd_and_hms(2026, 4, 22, 0, 0, 0).unwrap();
136 assert_eq!(format_local(&dt, "%b %d", None), "Apr 22");
137 }
138
139 #[test]
140 fn format_local_japanese_weekday_uses_locale_table() {
141 let dt = Utc.with_ymd_and_hms(2026, 4, 22, 0, 0, 0).unwrap();
142 assert_eq!(format_local(&dt, "%A", Some("ja_JP")), "水曜日");
143 }
144
145 #[test]
146 fn format_relative_compact_under_45_seconds_is_now() {
147 assert_eq!(format_relative_compact(30, None).as_deref(), Some("now"));
148 }
149
150 #[test]
151 fn format_relative_compact_minutes_round_half_up() {
152 assert_eq!(format_relative_compact(45, None).as_deref(), Some("1m"));
153 assert_eq!(format_relative_compact(120, None).as_deref(), Some("2m"));
154 }
155
156 #[test]
157 fn format_relative_compact_hours() {
158 assert_eq!(
159 format_relative_compact(3 * 3600, None).as_deref(),
160 Some("3h")
161 );
162 }
163
164 #[test]
165 fn format_relative_compact_future_grows_in_prefix() {
166 assert_eq!(
167 format_relative_compact(-3600, None).as_deref(),
168 Some("in 1h")
169 );
170 }
171
172 #[test]
173 fn format_relative_compact_returns_none_past_4_weeks() {
174 assert!(format_relative_compact(60 * 86_400, None).is_none());
175 }
176
177 #[test]
178 fn today_in_matches_now_in_date() {
179 let now = now_in(Some("UTC"));
180 assert_eq!(today_in(Some("UTC")), now.date_naive());
181 }
182}