chartml_core/format/
date.rs1use chrono::NaiveDateTime;
3
4#[derive(Debug, Clone)]
5pub struct DateFormatter {
6 format_str: String,
7}
8
9impl DateFormatter {
10 pub fn new(format_str: &str) -> Self {
11 Self {
12 format_str: format_str.to_string(),
13 }
14 }
15
16 pub fn format_datetime(&self, dt: &NaiveDateTime) -> String {
18 dt.format(&self.format_str).to_string()
19 }
20
21 pub fn format_date_str(&self, date_str: &str) -> Option<String> {
24 if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(date_str, "%Y-%m-%dT%H:%M:%S") {
26 return Some(self.format_datetime(&dt));
27 }
28 if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(date_str, "%Y-%m-%dT%H:%M:%S%.f") {
29 return Some(self.format_datetime(&dt));
30 }
31 if let Ok(date) = chrono::NaiveDate::parse_from_str(date_str, "%Y-%m-%d") {
32 let dt = date.and_hms_opt(0, 0, 0)?;
33 return Some(self.format_datetime(&dt));
34 }
35 None
36 }
37}
38
39pub(crate) const QUARTER_FORMAT: &str = "__QUARTER__";
43
44pub fn detect_date_format(labels: &[String]) -> Option<String> {
49 if labels.is_empty() {
50 return None;
51 }
52
53 let sample_size = labels.len().min(5);
55 let mut date_count = 0;
56 let mut has_time = false;
57
58 for label in labels.iter().take(sample_size) {
59 let trimmed = label.trim();
60 if chrono::NaiveDate::parse_from_str(trimmed, "%Y-%m-%d").is_ok() {
61 date_count += 1;
62 } else if chrono::NaiveDateTime::parse_from_str(trimmed, "%Y-%m-%dT%H:%M:%S").is_ok()
63 || chrono::NaiveDateTime::parse_from_str(trimmed, "%Y-%m-%dT%H:%M:%S%.f").is_ok()
64 {
65 date_count += 1;
66 has_time = true;
67 }
68 }
69
70 if date_count * 5 >= sample_size * 4 {
72 if !has_time && is_quarterly(labels) {
74 return Some(QUARTER_FORMAT.to_string());
75 }
76
77 let multi_year = spans_multiple_years(labels);
79
80 if has_time {
81 if all_same_date(labels) {
83 Some("%H:%M".to_string())
84 } else if multi_year {
85 Some("%b %d '%y %H:%M".to_string())
86 } else {
87 Some("%b %d %H:%M".to_string())
88 }
89 } else if multi_year {
90 Some("%b '%y".to_string())
91 } else {
92 Some("%b %d".to_string())
93 }
94 } else {
95 None
96 }
97}
98
99fn is_quarterly(labels: &[String]) -> bool {
102 use chrono::Datelike;
103
104 const QUARTER_MONTHS: [u32; 4] = [1, 4, 7, 10];
105
106 let mut found_any = false;
107 for label in labels {
108 let trimmed = label.trim();
109 if let Ok(d) = chrono::NaiveDate::parse_from_str(trimmed, "%Y-%m-%d") {
110 if d.day() != 1 || !QUARTER_MONTHS.contains(&d.month()) {
111 return false;
112 }
113 found_any = true;
114 } else if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(trimmed, "%Y-%m-%dT%H:%M:%S") {
115 let d = dt.date();
116 if d.day() != 1 || !QUARTER_MONTHS.contains(&d.month()) {
117 return false;
118 }
119 found_any = true;
120 }
121 }
123 found_any
124}
125
126fn all_same_date(labels: &[String]) -> bool {
130 fn extract_date_prefix(s: &str) -> Option<String> {
131 let t = s.trim();
132 if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(t, "%Y-%m-%dT%H:%M:%S") {
134 return Some(dt.date().to_string());
135 }
136 if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(t, "%Y-%m-%dT%H:%M:%S%.f") {
137 return Some(dt.date().to_string());
138 }
139 None
140 }
141
142 let mut first_date: Option<String> = None;
143 for label in labels {
144 if let Some(d) = extract_date_prefix(label) {
145 match &first_date {
146 None => first_date = Some(d),
147 Some(fd) => {
148 if *fd != d {
149 return false;
150 }
151 }
152 }
153 }
154 }
155 first_date.is_some()
157}
158
159fn spans_multiple_years(labels: &[String]) -> bool {
162 fn extract_year(s: &str) -> Option<i32> {
163 use chrono::Datelike;
164 let t = s.trim();
165 if let Ok(d) = chrono::NaiveDate::parse_from_str(t, "%Y-%m-%d") {
166 return Some(d.year());
167 }
168 if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(t, "%Y-%m-%dT%H:%M:%S") {
169 return Some(dt.date().year());
170 }
171 if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(t, "%Y-%m-%dT%H:%M:%S%.f") {
172 return Some(dt.date().year());
173 }
174 None
175 }
176
177 let first_year = labels.iter().find_map(|l| extract_year(l));
178 let last_year = labels.iter().rev().find_map(|l| extract_year(l));
179
180 match (first_year, last_year) {
181 (Some(f), Some(l)) => f != l,
182 _ => false,
183 }
184}
185
186pub fn reformat_date_label(label: &str, format_str: &str) -> String {
190 if format_str == QUARTER_FORMAT {
191 return format_as_quarter(label).unwrap_or_else(|| label.to_string());
192 }
193 let formatter = DateFormatter::new(format_str);
194 formatter.format_date_str(label).unwrap_or_else(|| label.to_string())
195}
196
197fn format_as_quarter(label: &str) -> Option<String> {
199 use chrono::Datelike;
200
201 let trimmed = label.trim();
202 let date = if let Ok(d) = chrono::NaiveDate::parse_from_str(trimmed, "%Y-%m-%d") {
203 d
204 } else if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(trimmed, "%Y-%m-%dT%H:%M:%S") {
205 dt.date()
206 } else {
207 return None;
208 };
209
210 let quarter = match date.month() {
211 1 => 1,
212 4 => 2,
213 7 => 3,
214 10 => 4,
215 _ => return None,
216 };
217
218 let year_short = date.year() % 100;
219 Some(format!("Q{} '{:02}", quarter, year_short))
220}
221
222#[cfg(test)]
223mod tests {
224 use super::*;
225
226 #[test]
227 fn test_format_date_identity() {
228 assert_eq!(
229 DateFormatter::new("%Y-%m-%d").format_date_str("2024-01-15"),
230 Some("2024-01-15".to_string())
231 );
232 }
233
234 #[test]
235 fn test_format_date_month_day_year() {
236 assert_eq!(
237 DateFormatter::new("%b %d, %Y").format_date_str("2024-01-15"),
238 Some("Jan 15, 2024".to_string())
239 );
240 }
241
242 #[test]
243 fn test_format_date_month_year() {
244 assert_eq!(
245 DateFormatter::new("%b %Y").format_date_str("2024-03-01"),
246 Some("Mar 2024".to_string())
247 );
248 }
249
250 #[test]
251 fn test_format_datetime() {
252 let dt = chrono::NaiveDate::from_ymd_opt(2024, 6, 15)
253 .unwrap()
254 .and_hms_opt(14, 30, 0)
255 .unwrap();
256 assert_eq!(
257 DateFormatter::new("%Y-%m-%d %H:%M").format_datetime(&dt),
258 "2024-06-15 14:30"
259 );
260 }
261
262 #[test]
263 fn test_format_date_str_with_time() {
264 assert_eq!(
265 DateFormatter::new("%Y-%m-%d %H:%M:%S").format_date_str("2024-01-15T10:30:00"),
266 Some("2024-01-15 10:30:00".to_string())
267 );
268 }
269
270 #[test]
271 fn test_format_date_str_invalid() {
272 assert_eq!(
273 DateFormatter::new("%Y-%m-%d").format_date_str("not-a-date"),
274 None
275 );
276 }
277
278 #[test]
279 fn test_detect_quarterly_format() {
280 let labels: Vec<String> = vec![
281 "2023-01-01".into(), "2023-04-01".into(),
282 "2023-07-01".into(), "2023-10-01".into(),
283 ];
284 let fmt = detect_date_format(&labels).unwrap();
285 assert_eq!(fmt, QUARTER_FORMAT);
286 }
287
288 #[test]
289 fn test_reformat_quarterly_labels() {
290 assert_eq!(reformat_date_label("2023-01-01", QUARTER_FORMAT), "Q1 '23");
291 assert_eq!(reformat_date_label("2023-04-01", QUARTER_FORMAT), "Q2 '23");
292 assert_eq!(reformat_date_label("2023-07-01", QUARTER_FORMAT), "Q3 '23");
293 assert_eq!(reformat_date_label("2023-10-01", QUARTER_FORMAT), "Q4 '23");
294 assert_eq!(reformat_date_label("2025-01-01", QUARTER_FORMAT), "Q1 '25");
295 }
296
297 #[test]
298 fn test_non_quarterly_dates_not_detected_as_quarterly() {
299 let labels: Vec<String> = vec![
301 "2023-01-01".into(), "2023-02-01".into(),
302 "2023-03-01".into(), "2023-04-01".into(),
303 ];
304 let fmt = detect_date_format(&labels).unwrap();
305 assert_ne!(fmt, QUARTER_FORMAT);
306 }
307}