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/// Render an ISO 8601 duration the way it was typed: `PT1H30M` becomes `1h 30m`.
85///
86/// Anything unrecognised is returned as it came. A duration we cannot read is
87/// still a fact about the worklog, and replacing it with a dash would hide it.
88#[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// An `unwrap_err` in a test is the test asserting; a wrong value fails it.
117#[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    /// A caller who already has an ISO duration should not have to translate it
132    /// into our shorthand for us to translate it straight back.
133    #[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    /// The message has to say what was wrong with what they typed. "invalid
140    /// duration" sends someone to the documentation for a missing letter.
141    #[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    /// A duration we cannot read is still a fact about the worklog.
157    #[test]
158    fn something_unreadable_is_passed_through_not_hidden() {
159        assert_eq!(human("nonsense"), "nonsense");
160        assert_eq!(human("P"), "P");
161    }
162}