1pub fn to_iso8601(input: &str) -> Result<String, String> {
19 use std::fmt::Write as _;
20
21 let trimmed = input.trim();
22 if trimmed.is_empty() {
23 return Err("empty duration".to_owned());
24 }
25
26 let upper = trimmed.to_uppercase();
29 if upper.starts_with('P') {
30 return Ok(upper);
31 }
32
33 let mut date = String::new();
34 let mut time = String::new();
35 let mut number = String::new();
36 let mut seen = false;
37
38 for character in trimmed.chars() {
39 if character.is_ascii_digit() {
40 number.push(character);
41 continue;
42 }
43 if character.is_whitespace() {
44 continue;
45 }
46 if number.is_empty() {
47 return Err(format!("`{input}`: `{character}` has no number before it"));
48 }
49
50 let unit = character.to_ascii_lowercase();
51 match unit {
52 'w' | 'd' => {
53 let _ = write!(date, "{number}{}", unit.to_ascii_uppercase());
54 }
55 'h' | 'm' | 's' => {
56 let _ = write!(time, "{number}{}", unit.to_ascii_uppercase());
57 }
58 other => {
59 return Err(format!(
60 "`{input}`: unknown unit `{other}` (use w, d, h, m or s)"
61 ));
62 }
63 }
64 number.clear();
65 seen = true;
66 }
67
68 if !number.is_empty() {
69 return Err(format!(
70 "`{input}`: `{number}` has no unit (w, d, h, m or s)"
71 ));
72 }
73 if !seen {
74 return Err(format!("`{input}`: no duration in it"));
75 }
76
77 if time.is_empty() {
78 Ok(format!("P{date}"))
79 } else {
80 Ok(format!("P{date}T{time}"))
81 }
82}
83
84#[must_use]
90pub fn from_minutes(minutes: i64) -> String {
91 let minutes = minutes.max(1);
92 let (hours, rest) = (minutes / 60, minutes % 60);
93 match (hours, rest) {
94 (0, minutes) => format!("PT{minutes}M"),
95 (hours, 0) => format!("PT{hours}H"),
96 (hours, minutes) => format!("PT{hours}H{minutes}M"),
97 }
98}
99
100#[must_use]
105pub fn human(iso: &str) -> String {
106 let Some(rest) = iso.strip_prefix('P') else {
107 return iso.to_owned();
108 };
109
110 let mut out = Vec::new();
111 let mut number = String::new();
112 for character in rest.chars() {
113 match character {
114 'T' => {}
115 digit if digit.is_ascii_digit() => number.push(digit),
116 unit if !number.is_empty() => {
117 out.push(format!("{number}{}", unit.to_ascii_lowercase()));
118 number.clear();
119 }
120 _ => return iso.to_owned(),
121 }
122 }
123
124 if out.is_empty() {
125 iso.to_owned()
126 } else {
127 out.join(" ")
128 }
129}
130
131#[cfg(test)]
132#[allow(clippy::unwrap_used)]
134mod tests {
135 use super::*;
136
137 #[test]
138 fn what_people_type_becomes_what_tracker_takes() {
139 assert_eq!(to_iso8601("1h30m"), Ok("PT1H30M".to_owned()));
140 assert_eq!(to_iso8601("45m"), Ok("PT45M".to_owned()));
141 assert_eq!(to_iso8601("2h"), Ok("PT2H".to_owned()));
142 assert_eq!(to_iso8601("1d"), Ok("P1D".to_owned()));
143 assert_eq!(to_iso8601("1w2d"), Ok("P1W2D".to_owned()));
144 assert_eq!(to_iso8601("1d 4h"), Ok("P1DT4H".to_owned()));
145 }
146
147 #[test]
150 fn an_iso_duration_passes_through() {
151 assert_eq!(to_iso8601("PT1H30M"), Ok("PT1H30M".to_owned()));
152 assert_eq!(to_iso8601("pt30m"), Ok("PT30M".to_owned()));
153 }
154
155 #[test]
158 fn a_bad_duration_says_what_is_wrong_with_it() {
159 assert!(to_iso8601("90").unwrap_err().contains("no unit"));
160 assert!(to_iso8601("h").unwrap_err().contains("no number"));
161 assert!(to_iso8601("1y").unwrap_err().contains("unknown unit"));
162 assert!(to_iso8601("").is_err());
163 }
164
165 #[test]
166 fn iso_durations_are_read_back_as_they_were_typed() {
167 assert_eq!(human("PT1H30M"), "1h 30m");
168 assert_eq!(human("P1DT4H"), "1d 4h");
169 assert_eq!(human("PT45M"), "45m");
170 }
171
172 #[test]
174 fn something_unreadable_is_passed_through_not_hidden() {
175 assert_eq!(human("nonsense"), "nonsense");
176 assert_eq!(human("P"), "P");
177 }
178}
179
180#[cfg(test)]
181mod minutes {
182 use super::*;
183
184 #[test]
185 fn minutes_become_hours_and_minutes() {
186 assert_eq!(from_minutes(90), "PT1H30M");
187 assert_eq!(from_minutes(45), "PT45M");
188 assert_eq!(from_minutes(120), "PT2H");
189 }
190
191 #[test]
193 fn nothing_at_all_is_still_a_minute() {
194 assert_eq!(from_minutes(0), "PT1M");
195 assert_eq!(from_minutes(-5), "PT1M");
196 }
197}