1pub 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
34pub const NEW_ISSUE_LINK: &str =
36 "https://github.com/LucasPickering/slumber/issues/new";
37
38pub struct Mapping<'a, T: Copy>(&'a [(T, &'a [&'a str])]);
41
42impl<'a, T: Copy> Mapping<'a, T> {
43 pub const fn new(mapping: &'a [(T, &'a [&'a str])]) -> Self {
45 Self(mapping)
46 }
47
48 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 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 pub fn all_strings(&self) -> impl Iterator<Item = &str> {
72 self.0
73 .iter()
74 .flat_map(|(_, strings)| strings.iter().copied())
75 }
76}
77
78pub trait ResultTraced<T, E>: Sized {
80 #[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
91pub trait ResultTracedAnyhow<T, E>: Sized {
95 #[must_use]
97 fn traced(self) -> Self;
98}
99
100impl<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
112pub 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
131pub 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#[derive(Copy, Clone, Debug, Eq, derive_more::From, PartialEq)]
145pub struct TimeSpan(Duration);
146
147impl TimeSpan {
148 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 let mut remaining = self.0.as_secs();
158
159 if remaining == 0 {
161 return write!(f, "0s");
162 }
163
164 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 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#[derive(Debug)]
223pub struct TimeSpanParseError;
224
225impl Display for TimeSpanParseError {
226 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
227 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#[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 #[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}