slumber_util/
lib.rs

1//! Common utilities that aren't specific to one other subcrate and are unlikely
2//! to change frequently. The main purpose of this is to pull logic out of the
3//! core crate, because that one changes a lot and requires constant
4//! recompilation.
5//!
6//! **This crate is not semver compliant**. The version is locked to the root
7//! `slumber` crate version. If you choose to depend directly on this crate, you
8//! do so at your own risk of breakage.
9
10pub mod paths;
11#[cfg(any(test, feature = "test"))]
12mod test_util;
13pub mod yaml;
14
15#[cfg(any(test, feature = "test"))]
16pub use test_util::*;
17
18use itertools::Itertools;
19use serde::{Deserialize, de::Error as _};
20use std::{
21    error::Error,
22    fmt::{self, Debug, Display},
23    ops::Deref,
24    str::FromStr,
25    time::Duration,
26};
27use tracing::error;
28use winnow::{
29    ModalResult, Parser,
30    ascii::digit1,
31    combinator::{alt, repeat},
32};
33
34/// Link to the GitHub New Issue form
35pub const NEW_ISSUE_LINK: &str =
36    "https://github.com/LucasPickering/slumber/issues/new";
37
38/// A static mapping between values (of type `T`) and labels (strings). Used to
39/// both stringify from and parse to `T`.
40pub struct Mapping<'a, T: Copy>(&'a [(T, &'a [&'a str])]);
41
42impl<'a, T: Copy> Mapping<'a, T> {
43    /// Construct a new mapping
44    pub const fn new(mapping: &'a [(T, &'a [&'a str])]) -> Self {
45        Self(mapping)
46    }
47
48    /// Get a value by one of its labels
49    pub fn get(&self, s: &str) -> Option<T> {
50        for (value, strs) in self.0 {
51            for other_string in *strs {
52                if *other_string == s {
53                    return Some(*value);
54                }
55            }
56        }
57        None
58    }
59
60    /// Get the label mapped to a value. If it has multiple labels, use the
61    /// first. Return `None` if the value isn't in the map or has no labels
62    pub fn get_label(&self, value: T) -> Option<&str>
63    where
64        T: Debug + PartialEq,
65    {
66        let (_, strings) = self.0.iter().find(|(v, _)| v == &value)?;
67        strings.first().copied()
68    }
69
70    /// Get all available mapped strings
71    pub fn all_strings(&self) -> impl Iterator<Item = &str> {
72        self.0
73            .iter()
74            .flat_map(|(_, strings)| strings.iter().copied())
75    }
76}
77
78/// Extension trait for [Result]
79pub trait ResultTraced<T, E>: Sized {
80    /// If this is an error, trace it. Return the same result.
81    #[must_use]
82    fn traced(self) -> Self;
83}
84
85impl<T, E: 'static + Error> ResultTraced<T, E> for Result<T, E> {
86    fn traced(self) -> Self {
87        self.inspect_err(|err| error!(error = err as &dyn Error))
88    }
89}
90
91/// [ResultTraced] but for the `anyhow` result. This has to be a separate trait
92/// because we can't put a blanket impl on std `Error` *and* `anyhow::Result`,
93/// as the two "could" conflict in the future.
94pub trait ResultTracedAnyhow<T, E>: Sized {
95    /// If this is an error, trace it. Return the same result.
96    #[must_use]
97    fn traced(self) -> Self;
98}
99
100// A blanket impl that covers `anyhow::Error` without actually referring to it.
101// This allows us to omit anyhow as a dependency, so downstream consumers don't
102// pull it in unless they need it.
103impl<T, E> ResultTracedAnyhow<T, E> for Result<T, E>
104where
105    E: Deref<Target = dyn Error + Send + Sync>,
106{
107    fn traced(self) -> Self {
108        self.inspect_err(|err| error!(error = err.deref()))
109    }
110}
111
112/// Get a link to a page on the doc website. This will append the doc prefix,
113/// as well as the suffix.
114///
115/// ```
116/// use slumber_util::doc_link;
117/// assert_eq!(
118///     doc_link("api/chain"),
119///     "https://slumber.lucaspickering.me/api/chain.html",
120/// );
121/// ```
122pub fn doc_link(path: &str) -> String {
123    const ROOT: &str = "https://slumber.lucaspickering.me/";
124    if path.is_empty() {
125        ROOT.into()
126    } else {
127        format!("{ROOT}{path}.html")
128    }
129}
130
131/// Get a link to a file in the remote git repo. This is the raw link, not the
132/// fancy UI link. It will be pinned to tag of the current crate version.
133pub fn git_link(path: &str) -> String {
134    format!(
135        "https://raw.githubusercontent.com\
136        /LucasPickering/slumber/refs/tags/v{version}/{path}",
137        version = env!("CARGO_PKG_VERSION"),
138    )
139}
140
141/// A newtype for [Duration] that provides formatting, parsing, and
142/// deserialization. The name is meant to make it harder to confuse with
143/// [Duration].
144#[derive(Copy, Clone, Debug, Eq, derive_more::From, PartialEq)]
145pub struct TimeSpan(Duration);
146
147impl TimeSpan {
148    /// Get the inner [Duration]
149    pub fn inner(self) -> Duration {
150        self.0
151    }
152}
153
154impl Display for TimeSpan {
155    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
156        // Use the largest units possible
157        let mut remaining = self.0.as_secs();
158
159        // Make sure 0 doesn't give us an empty string
160        if remaining == 0 {
161            return write!(f, "0s");
162        }
163
164        // Start with the biggest units
165        let units = DurationUnit::ALL
166            .iter()
167            .sorted_by_key(|unit| unit.seconds())
168            .rev();
169        for unit in units {
170            let quantity = remaining / unit.seconds();
171            if quantity > 0 {
172                remaining %= unit.seconds();
173                write!(f, "{quantity}{unit}")?;
174            }
175        }
176        Ok(())
177    }
178}
179
180impl FromStr for TimeSpan {
181    type Err = TimeSpanParseError;
182
183    fn from_str(s: &str) -> Result<Self, Self::Err> {
184        fn quantity(input: &mut &str) -> ModalResult<u64> {
185            digit1.parse_to().parse_next(input)
186        }
187
188        fn unit(input: &mut &str) -> ModalResult<DurationUnit> {
189            alt((
190                "s".map(|_| DurationUnit::Second),
191                "m".map(|_| DurationUnit::Minute),
192                "h".map(|_| DurationUnit::Hour),
193                "d".map(|_| DurationUnit::Day),
194            ))
195            .parse_next(input)
196        }
197
198        // Parse one or more quantity-unit pairs and sum them all up
199        let seconds = repeat(1.., (quantity, unit))
200            .fold(
201                || 0,
202                |acc, (quantity, unit)| acc + (quantity * unit.seconds()),
203            )
204            .parse(s)
205            .map_err(|_| TimeSpanParseError)?;
206
207        Ok(Self(Duration::from_secs(seconds)))
208    }
209}
210
211impl<'de> Deserialize<'de> for TimeSpan {
212    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
213    where
214        D: serde::Deserializer<'de>,
215    {
216        let s = String::deserialize(deserializer)?;
217        s.parse().map_err(D::Error::custom)
218    }
219}
220
221/// Error for [TimeSpan]'s `FromStr` impl
222#[derive(Debug)]
223pub struct TimeSpanParseError;
224
225impl Display for TimeSpanParseError {
226    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
227        // The format is so simple there isn't much value in spitting out a
228        // specific parsing error, just use a canned one
229        write!(
230            f,
231            "Invalid duration, must be `(<quantity><unit>)+` \
232                (e.g. `12d` or `1h30m`). Units are {}",
233            DurationUnit::ALL
234                .iter()
235                .format_with(", ", |unit, f| f(&format_args!("`{unit}`")))
236        )
237    }
238}
239
240impl Error for TimeSpanParseError {}
241
242/// Supported units for duration parsing/formatting
243#[derive(Debug)]
244enum DurationUnit {
245    Second,
246    Minute,
247    Hour,
248    Day,
249}
250
251impl DurationUnit {
252    const ALL: &[Self] = &[Self::Second, Self::Minute, Self::Hour, Self::Day];
253
254    fn seconds(&self) -> u64 {
255        match self {
256            DurationUnit::Second => 1,
257            DurationUnit::Minute => 60,
258            DurationUnit::Hour => 60 * 60,
259            DurationUnit::Day => 60 * 60 * 24,
260        }
261    }
262}
263
264impl Display for DurationUnit {
265    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
266        match self {
267            Self::Second => write!(f, "s"),
268            Self::Minute => write!(f, "m"),
269            Self::Hour => write!(f, "h"),
270            Self::Day => write!(f, "d"),
271        }
272    }
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278    use crate::assert_err;
279    use rstest::rstest;
280    use serde::Deserialize;
281
282    #[derive(Debug, PartialEq, Deserialize)]
283    #[serde(deny_unknown_fields)]
284    struct Data {
285        data: Inner,
286    }
287
288    #[derive(Debug, PartialEq, Deserialize)]
289    #[serde(deny_unknown_fields)]
290    struct Inner {
291        i: i32,
292        b: bool,
293        s: String,
294    }
295
296    #[rstest]
297    #[case::zero(Duration::from_secs(0), "0s")]
298    #[case::seconds_short(Duration::from_secs(3), "3s")]
299    #[case::seconds_hour(Duration::from_secs(3600), "1h")]
300    #[case::seconds_composite(Duration::from_secs(3690), "1h1m30s")]
301    // Subsecond precision is lost
302    #[case::seconds_subsecond_lost(Duration::from_millis(400), "0s")]
303    #[case::seconds_subsecond_round_down(Duration::from_millis(1999), "1s")]
304    fn test_time_span_to_string(
305        #[case] duration: Duration,
306        #[case] expected: &'static str,
307    ) {
308        assert_eq!(&TimeSpan(duration).to_string(), expected);
309    }
310
311    #[rstest]
312    #[case::seconds_zero("0s", Duration::from_secs(0))]
313    #[case::seconds_short("1s", Duration::from_secs(1))]
314    #[case::seconds_longer("100s", Duration::from_secs(100))]
315    #[case::minutes("3m", Duration::from_secs(180))]
316    #[case::hours("3h", Duration::from_secs(10_800))]
317    #[case::days("2d", Duration::from_secs(172_800))]
318    #[case::composite("2d3h10m17s", Duration::from_secs(
319        2 * 86400 + 3 * 3600 + 10 * 60 + 17
320    ))]
321    fn test_time_span_parse(
322        #[case] s: &'static str,
323        #[case] expected: Duration,
324    ) {
325        assert_eq!(s.parse::<TimeSpan>().unwrap(), TimeSpan(expected));
326    }
327
328    #[rstest]
329    #[case::negative("-1s", "Invalid duration")]
330    #[case::whitespace(" 1s ", "Invalid duration")]
331    #[case::trailing_whitespace("1s ", "Invalid duration")]
332    #[case::decimal("3.5s", "Invalid duration")]
333    #[case::invalid_unit("3hr", "Units are `s`, `m`, `h`, `d`")]
334    fn test_time_span_parse_error(
335        #[case] s: &'static str,
336        #[case] expected_error: &str,
337    ) {
338        assert_err(s.parse::<TimeSpan>(), expected_error);
339    }
340}