Skip to main content

scope/watch/
time.rs

1//! Intervals and durations, shared by `market summary --every` and
2//! watchlists, so both read `30s` the same way and have the same defaults
3//! (#47, Q7).
4
5/// Default interval between runs (60 seconds).
6pub const DEFAULT_INTERVAL_SECS: u64 = 60;
7
8/// Default total duration of a repeat (1 hour).
9pub const DEFAULT_DURATION_SECS: u64 = 3600;
10
11/// Duration words that mean "until the user stops it" (#96).
12pub const UNTIL_CANCELLED: [&str; 2] = ["forever", "inf"];
13
14/// `forever` or `inf`, in any case.
15pub fn is_until_cancelled(s: &str) -> bool {
16    UNTIL_CANCELLED.contains(&s.trim().to_ascii_lowercase().as_str())
17}
18
19/// Parses `30s`, `5m`, `1h`, `2d` or a bare number of seconds into whole
20/// seconds. Zero and negative values are errors.
21pub fn parse_duration(s: &str) -> Result<u64, String> {
22    let s = s.trim();
23    if s.is_empty() {
24        return Err("Empty duration".to_string());
25    }
26    let (num_str, unit) = s
27        .char_indices()
28        .find(|(_, c)| !c.is_ascii_digit() && *c != '.')
29        .map(|(i, _)| (&s[..i], s[i..].trim()))
30        .unwrap_or((s, "s"));
31
32    let num: f64 = num_str
33        .parse()
34        .map_err(|_| format!("Invalid duration number: {}", num_str))?;
35
36    if num <= 0.0 {
37        return Err("Duration must be positive".to_string());
38    }
39
40    let secs = match unit.to_lowercase().as_str() {
41        "s" | "sec" | "secs" | "second" | "seconds" => num,
42        "m" | "min" | "mins" | "minute" | "minutes" => num * 60.0,
43        "h" | "hr" | "hrs" | "hour" | "hours" => num * 3600.0,
44        "d" | "day" | "days" => num * 86400.0,
45        _ => return Err(format!("Unknown duration unit: {}", unit)),
46    };
47
48    Ok(secs as u64)
49}
50
51/// The shortest text that [`parse_duration`] reads back as `secs`.
52pub fn format_duration(secs: u64) -> String {
53    match secs {
54        s if s > 0 && s % 86400 == 0 => format!("{}d", s / 86400),
55        s if s > 0 && s % 3600 == 0 => format!("{}h", s / 3600),
56        s if s > 0 && s % 60 == 0 => format!("{}m", s / 60),
57        s => format!("{s}s"),
58    }
59}
60
61#[cfg(test)]
62mod tests {
63    use super::*;
64
65    #[test]
66    fn units_parse() {
67        assert_eq!(parse_duration("30s"), Ok(30));
68        assert_eq!(parse_duration("5m"), Ok(300));
69        assert_eq!(parse_duration("1h"), Ok(3600));
70        assert_eq!(parse_duration("2d"), Ok(172_800));
71        assert_eq!(parse_duration("45"), Ok(45));
72        assert_eq!(parse_duration("1.5m"), Ok(90));
73        assert!(parse_duration("0s").is_err());
74        assert!(parse_duration("").is_err());
75        assert!(
76            parse_duration("3w")
77                .unwrap_err()
78                .contains("Unknown duration unit")
79        );
80    }
81
82    #[test]
83    fn format_reads_back() {
84        for secs in [1, 30, 60, 90, 3600, 5400, 86400] {
85            assert_eq!(parse_duration(&format_duration(secs)), Ok(secs));
86        }
87        assert_eq!(format_duration(3600), "1h");
88        assert_eq!(format_duration(90), "90s");
89    }
90
91    #[test]
92    fn until_cancelled_words() {
93        assert!(is_until_cancelled("forever"));
94        assert!(is_until_cancelled(" INF "));
95        assert!(!is_until_cancelled("1h"));
96    }
97}