Skip to main content

rosetta_date/
formatter.rs

1//! Formatting module: date-time output and relative-time descriptions.
2
3use crate::datetime::RosettaDateTime;
4use crate::delta::RosettaDelta;
5use crate::i18n::LanguageData;
6
7// ── Strftime-style formatting ─────────────────────────────────────────
8
9/// Format a `RosettaDateTime` using a strftime-style format string.
10///
11/// Supported specifiers:
12/// - `%Y` — 4-digit year
13/// - `%m` — 2-digit month (01–12)
14/// - `%d` — 2-digit day (01–31)
15/// - `%H` — 2-digit hour in 24h (00–23)
16/// - `%M` — 2-digit minute (00–59)
17/// - `%S` — 2-digit second (00–59)
18/// - `%z` — UTC offset (`+0800`)
19/// - `%Z` — UTC offset with colon (`+08:00`)
20/// - `%A` — Full weekday name (from language data, or English default)
21/// - `%a` — Abbreviated weekday name
22/// - `%B` — Full month name
23/// - `%b` — Abbreviated month name
24/// - `%p` — AM/PM
25/// - `%%` — Literal `%`
26pub fn format_datetime(dt: &RosettaDateTime, fmt: &str, lang: Option<&LanguageData>) -> String {
27    let mut result = String::with_capacity(fmt.len() + 16);
28    let mut chars = fmt.chars().peekable();
29
30    while let Some(c) = chars.next() {
31        if c == '%' {
32            if let Some(&spec) = chars.peek() {
33                chars.next();
34                match spec {
35                    'Y' => result.push_str(&format!("{:04}", dt.year())),
36                    'm' => result.push_str(&format!("{:02}", dt.month())),
37                    'd' => result.push_str(&format!("{:02}", dt.day())),
38                    'H' => result.push_str(&format!("{:02}", dt.hour())),
39                    'M' => result.push_str(&format!("{:02}", dt.minute())),
40                    'S' => result.push_str(&format!("{:02}", dt.second())),
41                    'z' => {
42                        let off = dt.offset();
43                        let sign = if off.total_seconds >= 0 { '+' } else { '-' };
44                        let abs = off.total_seconds.unsigned_abs();
45                        let h = abs / 3600;
46                        let m = (abs % 3600) / 60;
47                        result.push_str(&format!("{}{:02}{:02}", sign, h, m));
48                    }
49                    'Z' => {
50                        result.push_str(&format!("{}", dt.offset()));
51                    }
52                    'A' => {
53                        let wd = dt.weekday() as usize;
54                        if let Some(l) = lang {
55                            result.push_str(l.weekdays_long.get(wd).unwrap_or(&"?"));
56                        } else {
57                            let defaults = [
58                                "Monday",
59                                "Tuesday",
60                                "Wednesday",
61                                "Thursday",
62                                "Friday",
63                                "Saturday",
64                                "Sunday",
65                            ];
66                            result.push_str(defaults.get(wd).unwrap_or(&"?"));
67                        }
68                    }
69                    'a' => {
70                        let wd = dt.weekday() as usize;
71                        if let Some(l) = lang {
72                            result.push_str(l.weekdays_short.get(wd).unwrap_or(&"?"));
73                        } else {
74                            let defaults = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
75                            result.push_str(defaults.get(wd).unwrap_or(&"?"));
76                        }
77                    }
78                    'B' => {
79                        let mo = (dt.month() as usize).wrapping_sub(1);
80                        if let Some(l) = lang {
81                            result.push_str(l.months_long.get(mo).unwrap_or(&"?"));
82                        } else {
83                            let defaults = [
84                                "January",
85                                "February",
86                                "March",
87                                "April",
88                                "May",
89                                "June",
90                                "July",
91                                "August",
92                                "September",
93                                "October",
94                                "November",
95                                "December",
96                            ];
97                            result.push_str(defaults.get(mo).unwrap_or(&"?"));
98                        }
99                    }
100                    'b' => {
101                        let mo = (dt.month() as usize).wrapping_sub(1);
102                        if let Some(l) = lang {
103                            result.push_str(l.months_short.get(mo).unwrap_or(&"?"));
104                        } else {
105                            let defaults = [
106                                "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep",
107                                "Oct", "Nov", "Dec",
108                            ];
109                            result.push_str(defaults.get(mo).unwrap_or(&"?"));
110                        }
111                    }
112                    'p' => {
113                        let is_pm = dt.hour() >= 12;
114                        if let Some(l) = lang {
115                            if is_pm {
116                                result.push_str(l.pm_indicators.first().unwrap_or(&"PM"));
117                            } else {
118                                result.push_str(l.am_indicators.first().unwrap_or(&"AM"));
119                            }
120                        } else {
121                            result.push_str(if is_pm { "PM" } else { "AM" });
122                        }
123                    }
124                    '%' => result.push('%'),
125                    other => {
126                        result.push('%');
127                        result.push(other);
128                    }
129                }
130            } else {
131                result.push('%');
132            }
133        } else {
134            result.push(c);
135        }
136    }
137
138    result
139}
140
141// ── Relative time formatting ──────────────────────────────────────────
142
143/// Format the time difference between `dt` and `now` as a human-readable
144/// relative time string (e.g. "5 minutes ago", "3天前").
145///
146/// When `lang` is `None`, falls back to English-like output.
147pub fn time_ago(
148    dt: &RosettaDateTime,
149    now: &RosettaDateTime,
150    lang: Option<&LanguageData>,
151) -> String {
152    let delta = RosettaDelta::between(dt, now);
153    let abs_secs = delta.abs_seconds();
154    let is_past = delta.is_positive(); // dt is in the past relative to now
155
156    let (value, unit_idx) = pick_dominant_unit(abs_secs);
157
158    let unit_names_en = ["second", "minute", "hour", "day", "week", "month", "year"];
159
160    if let Some(l) = lang {
161        let unit_str = l
162            .time_units
163            .get(unit_idx)
164            .and_then(|(_, kws)| kws.first())
165            .unwrap_or(&unit_names_en[unit_idx]);
166
167        if is_past {
168            let ago = l.ago_words.first().unwrap_or(&"ago");
169            // Chinese style: "3小时前"  (no space between number and unit)
170            if l.code == "zh" {
171                format!("{}{}{}", value, unit_str, ago)
172            } else {
173                let plural = if value != 1 { "s" } else { "" };
174                format!("{} {}{} {}", value, unit_str, plural, ago)
175            }
176        } else {
177            let future = l.future_words.first().unwrap_or(&"later");
178            let prefix = l.future_prefix.first();
179            if l.code == "zh" {
180                format!("{}{}{}", value, unit_str, future)
181            } else if let Some(p) = prefix {
182                let plural = if value != 1 { "s" } else { "" };
183                format!("{} {} {}{}", p, value, unit_str, plural)
184            } else {
185                let plural = if value != 1 { "s" } else { "" };
186                format!("{} {}{} {}", value, unit_str, plural, future)
187            }
188        }
189    } else {
190        // English fallback
191        let unit = unit_names_en[unit_idx];
192        let plural = if value != 1 { "s" } else { "" };
193        if is_past {
194            format!("{} {}{} ago", value, unit, plural)
195        } else {
196            format!("in {} {}{}", value, unit, plural)
197        }
198    }
199}
200
201/// Pick the most significant unit for a given number of seconds.
202/// Returns `(value, unit_index)` where unit_index maps to TimeUnit order:
203/// 0=Second, 1=Minute, 2=Hour, 3=Day, 4=Week, 5=Month, 6=Year
204fn pick_dominant_unit(abs_secs: u64) -> (u64, usize) {
205    if abs_secs < 60 {
206        (abs_secs, 0) // seconds
207    } else if abs_secs < 3600 {
208        (abs_secs / 60, 1) // minutes
209    } else if abs_secs < 86400 {
210        (abs_secs / 3600, 2) // hours
211    } else if abs_secs < 7 * 86400 {
212        (abs_secs / 86400, 3) // days
213    } else if abs_secs < 30 * 86400 {
214        (abs_secs / (7 * 86400), 4) // weeks
215    } else if abs_secs < 365 * 86400 {
216        (abs_secs / (30 * 86400), 5) // months
217    } else {
218        (abs_secs / (365 * 86400), 6) // years
219    }
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225    use crate::timezone::TzOffset;
226
227    #[test]
228    fn test_format_strftime() {
229        let dt = RosettaDateTime::from_components(2023, 3, 15, 14, 30, 45, TzOffset::from_hm(8, 0))
230            .unwrap();
231        assert_eq!(
232            format_datetime(&dt, "%Y-%m-%d %H:%M:%S %Z", None),
233            "2023-03-15 14:30:45 +08:00"
234        );
235        assert_eq!(format_datetime(&dt, "%Y/%m/%d", None), "2023/03/15");
236    }
237
238    #[test]
239    fn test_format_weekday_month() {
240        let dt = RosettaDateTime::from_components(2023, 3, 15, 14, 30, 0, TzOffset::UTC).unwrap();
241        let result = format_datetime(&dt, "%A, %B %d, %Y", None);
242        // 2023-03-15 is a Wednesday
243        assert_eq!(result, "Wednesday, March 15, 2023");
244    }
245
246    #[test]
247    fn test_time_ago_english() {
248        let now = RosettaDateTime::from_components(2023, 10, 15, 12, 0, 0, TzOffset::UTC).unwrap();
249        let past = now.clone().add_hours(-3);
250        assert_eq!(time_ago(&past, &now, None), "3 hours ago");
251
252        let future = now.clone().add_days(2);
253        assert_eq!(time_ago(&future, &now, None), "in 2 days");
254    }
255
256    #[cfg(feature = "lang-zh")]
257    #[test]
258    fn test_time_ago_chinese() {
259        use crate::i18n::zh::CHINESE;
260        let now = RosettaDateTime::from_components(2023, 10, 15, 12, 0, 0, TzOffset::UTC).unwrap();
261        let past = now.clone().add_hours(-3);
262        let result = time_ago(&past, &now, Some(&CHINESE));
263        assert_eq!(result, "3个小时前");
264    }
265}