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]
89pub fn human(iso: &str) -> String {
90 let Some(rest) = iso.strip_prefix('P') else {
91 return iso.to_owned();
92 };
93
94 let mut out = Vec::new();
95 let mut number = String::new();
96 for character in rest.chars() {
97 match character {
98 'T' => {}
99 digit if digit.is_ascii_digit() => number.push(digit),
100 unit if !number.is_empty() => {
101 out.push(format!("{number}{}", unit.to_ascii_lowercase()));
102 number.clear();
103 }
104 _ => return iso.to_owned(),
105 }
106 }
107
108 if out.is_empty() {
109 iso.to_owned()
110 } else {
111 out.join(" ")
112 }
113}
114
115#[cfg(test)]
116#[allow(clippy::unwrap_used)]
118mod tests {
119 use super::*;
120
121 #[test]
122 fn what_people_type_becomes_what_tracker_takes() {
123 assert_eq!(to_iso8601("1h30m"), Ok("PT1H30M".to_owned()));
124 assert_eq!(to_iso8601("45m"), Ok("PT45M".to_owned()));
125 assert_eq!(to_iso8601("2h"), Ok("PT2H".to_owned()));
126 assert_eq!(to_iso8601("1d"), Ok("P1D".to_owned()));
127 assert_eq!(to_iso8601("1w2d"), Ok("P1W2D".to_owned()));
128 assert_eq!(to_iso8601("1d 4h"), Ok("P1DT4H".to_owned()));
129 }
130
131 #[test]
134 fn an_iso_duration_passes_through() {
135 assert_eq!(to_iso8601("PT1H30M"), Ok("PT1H30M".to_owned()));
136 assert_eq!(to_iso8601("pt30m"), Ok("PT30M".to_owned()));
137 }
138
139 #[test]
142 fn a_bad_duration_says_what_is_wrong_with_it() {
143 assert!(to_iso8601("90").unwrap_err().contains("no unit"));
144 assert!(to_iso8601("h").unwrap_err().contains("no number"));
145 assert!(to_iso8601("1y").unwrap_err().contains("unknown unit"));
146 assert!(to_iso8601("").is_err());
147 }
148
149 #[test]
150 fn iso_durations_are_read_back_as_they_were_typed() {
151 assert_eq!(human("PT1H30M"), "1h 30m");
152 assert_eq!(human("P1DT4H"), "1d 4h");
153 assert_eq!(human("PT45M"), "45m");
154 }
155
156 #[test]
158 fn something_unreadable_is_passed_through_not_hidden() {
159 assert_eq!(human("nonsense"), "nonsense");
160 assert_eq!(human("P"), "P");
161 }
162}