Skip to main content

ytcli/api/
duration.rs

1//! Durations, in the two forms this tool has to speak.
2//!
3//! Tracker takes and returns ISO 8601 durations — `PT1H30M`. Nobody types that,
4//! and nobody wants to read it either, so `1h30m` goes in and `1h 30m` comes
5//! out. The ISO form is still accepted on input: a caller who already has one,
6//! or who is scripting against the API's own vocabulary, should not have to
7//! translate it into ours and back.
8
9/// Parse `1h30m`, `45m`, `2d`, `1w`, or an ISO 8601 duration, into ISO 8601.
10///
11/// Weeks and days are what people log time in; Tracker counts a working day as
12/// 8 hours and a working week as 5 days, and it does that conversion itself, so
13/// `P1D` is passed through as a day rather than turned into 24 hours here.
14///
15/// # Errors
16///
17/// Returns a message naming what was not understood.
18pub 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    // Already ISO: hand it back untouched rather than parsing and re-emitting,
27    // which could only lose information.
28    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/// Whole minutes as the ISO 8601 duration Tracker takes.
85///
86/// Rounded to the minute, because that is the resolution a worklog is read at,
87/// and never to zero: a timer that ran for forty seconds recorded nothing at
88/// all would be a surprise, and Tracker has no use for `PT0M`.
89#[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/// Render an ISO 8601 duration the way it was typed: `PT1H30M` becomes `1h 30m`.
101///
102/// Anything unrecognised is returned as it came. A duration we cannot read is
103/// still a fact about the worklog, and replacing it with a dash would hide it.
104#[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// An `unwrap_err` in a test is the test asserting; a wrong value fails it.
133#[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    /// A caller who already has an ISO duration should not have to translate it
148    /// into our shorthand for us to translate it straight back.
149    #[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    /// The message has to say what was wrong with what they typed. "invalid
156    /// duration" sends someone to the documentation for a missing letter.
157    #[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    /// A duration we cannot read is still a fact about the worklog.
173    #[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    /// A short timer records something rather than nothing.
192    #[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}