astrolabe/util/
format.rs

1use super::{
2    constants::{
3        MONTH_ABBREVIATED, MONTH_NARROW, MONTH_WIDE, NANOS_PER_SEC, SECS_PER_DAY, SECS_PER_HOUR,
4        SECS_PER_MINUTE, WDAY_ABBREVIATED, WDAY_NARROW, WDAY_SHORT, WDAY_WIDE,
5    },
6    date::convert::{days_to_date, days_to_doy, days_to_wday, days_to_wyear},
7    time::convert::nanos_to_time,
8};
9
10/// Formats string parts based on https://www.unicode.org/reports/tr35/tr35-dates.html#table-date-field-symbol-table
11/// **Note**: Not all field types/symbols are implemented.
12pub(crate) fn format_part(chars: &str, days: i32, nanoseconds: u64, offset: i32) -> String {
13    // Using unwrap because it's safe to assume that chars has a length of at least 1
14    let first_char = chars.chars().next().unwrap();
15    match first_char {
16        'G' | 'y' | 'q' | 'M' | 'w' | 'd' | 'D' | 'e' => format_date_part(chars, days),
17        'a' | 'b' | 'h' | 'H' | 'K' | 'k' | 'm' | 's' | 'n' | 'X' | 'x' => {
18            format_time_part(chars, nanoseconds, offset)
19        }
20        _ => chars.to_string(),
21    }
22}
23
24/// Formats string parts based on https://www.unicode.org/reports/tr35/tr35-dates.html#table-date-field-symbol-table
25/// This function only formats date parts while ignoring time related parts (E.g. hour, minute)
26pub(crate) fn format_date_part(chars: &str, days: i32) -> String {
27    // Using unwrap because it's safe to assume that chars has a length of at least 1
28    let first_char = chars.chars().next().unwrap();
29    match first_char {
30        'G' => match chars.len() {
31            1..=3 => {
32                if days.is_negative() {
33                    "BC".to_string()
34                } else {
35                    "AD".to_string()
36                }
37            }
38            5 => {
39                if days.is_negative() {
40                    "B".to_string()
41                } else {
42                    "A".to_string()
43                }
44            }
45            _ => {
46                if days.is_negative() {
47                    "Before Christ".to_string()
48                } else {
49                    "Anno Domini".to_string()
50                }
51            }
52        },
53        'y' => match chars.len() {
54            2 => {
55                let mut year = days_to_date(days).0;
56                let year_string = year.to_string();
57
58                if year_string.len() > 2 {
59                    let last_two = &year_string[year_string.len() - 2..];
60                    // Using unwrap because it's safe to assume that this string can be parsed
61                    year = last_two.parse::<i32>().unwrap();
62                }
63                zero_padded_i(year, 2)
64            }
65            _ => zero_padded_i(days_to_date(days).0, chars.len()),
66        },
67        'q' => {
68            let quarter = (days_to_date(days).1 - 1) / 3 + 1;
69            match chars.len() {
70                1 | 2 => zero_padded(quarter, chars.len()),
71                3 => format!("Q{}", quarter),
72                4 => {
73                    let ordinal = add_ordinal_indicator(quarter);
74                    format!("{} quarter", ordinal)
75                }
76                _ => zero_padded(quarter, 1),
77            }
78        }
79        'M' => format_month(chars.len(), days),
80        'w' => zero_padded(days_to_wyear(days), get_length(chars.len(), 2, 2)),
81        'd' => zero_padded(days_to_date(days).2, get_length(chars.len(), 2, 2)),
82        'D' => zero_padded(days_to_doy(days), get_length(chars.len(), 1, 3)),
83        'e' => format_wday(chars.len(), days),
84        _ => chars.to_string(),
85    }
86}
87
88/// Formats string parts based on https://www.unicode.org/reports/tr35/tr35-dates.html#table-date-field-symbol-table
89/// This function only formats time parts while ignoring date related parts (E.g. year, day)
90pub(crate) fn format_time_part(chars: &str, nanoseconds: u64, offset: i32) -> String {
91    // Using unwrap because it's safe to assume that chars has a length of at least 1
92    let first_char = chars.chars().next().unwrap();
93    match first_char {
94        'a' => format_period(nanoseconds, get_length(chars.len(), 3, 5), false),
95        'b' => format_period(nanoseconds, get_length(chars.len(), 3, 5), true),
96        'h' => {
97            let hour = if nanos_to_time(nanoseconds).0 % 12 == 0 {
98                12
99            } else {
100                nanos_to_time(nanoseconds).0 % 12
101            };
102            zero_padded(hour, get_length(chars.len(), 2, 2))
103        }
104        'H' => zero_padded(nanos_to_time(nanoseconds).0, get_length(chars.len(), 2, 2)),
105        'K' => zero_padded(
106            nanos_to_time(nanoseconds).0 % 12,
107            get_length(chars.len(), 2, 2),
108        ),
109        'k' => {
110            let hour = if nanos_to_time(nanoseconds).0 == 0 {
111                24
112            } else {
113                nanos_to_time(nanoseconds).0
114            };
115            zero_padded(hour, get_length(chars.len(), 2, 2))
116        }
117        'm' => zero_padded(nanos_to_time(nanoseconds).1, get_length(chars.len(), 2, 2)),
118        's' => zero_padded(nanos_to_time(nanoseconds).2, get_length(chars.len(), 2, 2)),
119        'n' => {
120            let mut length = get_length(chars.len(), 3, 5);
121            if length == 4 {
122                length = 6;
123            } else if length == 5 {
124                length = 9;
125            }
126
127            let subsec_nanos = (nanoseconds % NANOS_PER_SEC) as u32;
128
129            zero_padded(subsec_nanos / 10_u32.pow(9 - length as u32), length)
130        }
131        'X' => format_zone(chars.len(), offset, true),
132        'x' => format_zone(chars.len(), offset, false),
133        _ => chars.to_string(),
134    }
135}
136
137/// Formats the month of a date based on https://www.unicode.org/reports/tr35/tr35-dates.html#dfst-month
138fn format_month(length: usize, days: i32) -> String {
139    let month = days_to_date(days).1;
140
141    match length {
142        1 | 2 => zero_padded(month, length),
143        3 => MONTH_ABBREVIATED
144            .into_iter()
145            .nth((month - 1) as usize)
146            .unwrap()
147            .to_string(),
148        5 => MONTH_NARROW
149            .into_iter()
150            .nth((month - 1) as usize)
151            .unwrap()
152            .to_string(),
153        _ => MONTH_WIDE
154            .into_iter()
155            .nth((month - 1) as usize)
156            .unwrap()
157            .to_string(),
158    }
159}
160
161/// Formats the week day of a date based on https://www.unicode.org/reports/tr35/tr35-dates.html#dfst-month
162fn format_wday(length: usize, days: i32) -> String {
163    match length {
164        1 | 2 => zero_padded(days_to_wday(days, false) + 1, length),
165        3 => WDAY_ABBREVIATED
166            .into_iter()
167            .nth(days_to_wday(days, false) as usize)
168            .unwrap()
169            .to_string(),
170        4 => WDAY_WIDE
171            .into_iter()
172            .nth(days_to_wday(days, false) as usize)
173            .unwrap()
174            .to_string(),
175        5 => WDAY_NARROW
176            .into_iter()
177            .nth(days_to_wday(days, false) as usize)
178            .unwrap()
179            .to_string(),
180        6 => WDAY_SHORT
181            .into_iter()
182            .nth(days_to_wday(days, false) as usize)
183            .unwrap()
184            .to_string(),
185        7 => zero_padded(days_to_wday(days, true) + 1, 1),
186        8 => zero_padded(days_to_wday(days, true) + 1, 2),
187        _ => zero_padded(days_to_wday(days, false) + 1, 1),
188    }
189}
190
191/// Formats the time period
192fn format_period(nanos: u64, length: usize, seperate_12: bool) -> String {
193    const FORMATS: [[&str; 4]; 5] = [
194        ["AM", "PM", "noon", "midnight"],
195        ["AM", "PM", "noon", "midnight"],
196        ["am", "pm", "noon", "midnight"],
197        ["a.m.", "p.m.", "noon", "midnight"],
198        ["a", "p", "n", "mi"],
199    ];
200    let time = (nanos / NANOS_PER_SEC) as u32 % SECS_PER_DAY;
201
202    match time {
203        time if seperate_12 && time == 0 => {
204            FORMATS.into_iter().nth(length - 1).unwrap()[3].to_string()
205        }
206        time if seperate_12 && time == 43200 => {
207            FORMATS.into_iter().nth(length - 1).unwrap()[2].to_string()
208        }
209        time if time < 43200 => FORMATS.into_iter().nth(length - 1).unwrap()[0].to_string(),
210        _ => FORMATS.into_iter().nth(length - 1).unwrap()[1].to_string(),
211    }
212}
213
214/// Formats the time zone
215fn format_zone(length: usize, offset: i32, with_z: bool) -> String {
216    if with_z && offset == 0 {
217        return "Z".to_string();
218    }
219
220    let hour = offset.unsigned_abs() / SECS_PER_HOUR;
221    let minute = offset.unsigned_abs() % SECS_PER_HOUR / SECS_PER_MINUTE;
222    let second = offset.unsigned_abs() % SECS_PER_HOUR % SECS_PER_MINUTE;
223    let prefix = if offset.is_negative() { "-" } else { "+" };
224
225    match length {
226        1 => {
227            format!(
228                "{}{}{}",
229                prefix,
230                zero_padded(hour, 2),
231                if minute != 0 {
232                    zero_padded(minute, 2)
233                } else {
234                    "".to_string()
235                }
236            )
237        }
238        2 => {
239            format!(
240                "{}{}{}",
241                prefix,
242                zero_padded(hour, 2),
243                zero_padded(minute, 2)
244            )
245        }
246        4 => {
247            format!(
248                "{}{}{}{}",
249                prefix,
250                zero_padded(hour, 2),
251                zero_padded(minute, 2),
252                if second != 0 {
253                    zero_padded(second, 2)
254                } else {
255                    "".to_string()
256                }
257            )
258        }
259        5 => {
260            format!(
261                "{}{}:{}{}",
262                prefix,
263                zero_padded(hour, 2),
264                zero_padded(minute, 2),
265                if second != 0 {
266                    format!(":{}", zero_padded(second, 2))
267                } else {
268                    "".to_string()
269                }
270            )
271        }
272        _ => {
273            format!(
274                "{}{}:{}",
275                prefix,
276                zero_padded(hour, 2),
277                zero_padded(minute, 2)
278            )
279        }
280    }
281}
282
283/// Formats a number as a zero padded string
284pub(crate) fn zero_padded_i(number: i32, length: usize) -> String {
285    format!(
286        "{}{}",
287        if number.is_negative() { "-" } else { "" },
288        zero_padded(number.unsigned_abs(), length)
289    )
290}
291
292/// Formats a number as a zero padded string
293pub(crate) fn zero_padded(number: u32, length: usize) -> String {
294    format!("{:0width$}", number, width = length)
295}
296
297/// Determines length of formatting part based on actual, default and max length
298pub(crate) fn get_length(length: usize, default: usize, max: usize) -> usize {
299    if length > max {
300        default
301    } else {
302        length
303    }
304}
305
306/// Formats a number as an ordinal number
307pub(crate) fn add_ordinal_indicator(number: u32) -> String {
308    match number {
309        number if (number - 1) % 10 == 0 && number != 11 => format!("{}st", number),
310        number if (number - 2) % 10 == 0 && number != 12 => format!("{}nd", number),
311        number if (number - 3) % 10 == 0 && number != 13 => format!("{}rd", number),
312        _ => format!("{}th", number),
313    }
314}