pub mod paths;
#[cfg(any(test, feature = "test"))]
mod test_util;
pub mod yaml;
#[cfg(any(test, feature = "test"))]
pub use test_util::*;
use itertools::Itertools;
use serde::{
Deserialize, Serialize, Serializer, de::Error as _, ser::SerializeMap,
};
use std::{
error::Error,
fmt::{self, Debug, Display},
fs::{File, OpenOptions},
io,
ops::Deref,
str::FromStr,
time::Duration,
};
use tracing::{error, level_filters::LevelFilter};
use tracing_subscriber::{
Layer, fmt::format::FmtSpan, layer::SubscriberExt, util::SubscriberInitExt,
};
use winnow::{
ModalResult, Parser,
ascii::digit1,
combinator::{alt, repeat},
};
pub const NEW_ISSUE_LINK: &str =
"https://github.com/LucasPickering/slumber/issues/new";
pub struct Mapping<'a, T: Copy>(&'a [(T, &'a [&'a str])]);
impl<'a, T: Copy> Mapping<'a, T> {
pub const fn new(mapping: &'a [(T, &'a [&'a str])]) -> Self {
Self(mapping)
}
pub fn get(&self, s: &str) -> Option<T> {
for (value, strs) in self.0 {
for other_string in *strs {
if *other_string == s {
return Some(*value);
}
}
}
None
}
pub fn get_label(&self, value: T) -> Option<&str>
where
T: Debug + PartialEq,
{
let (_, strings) = self.0.iter().find(|(v, _)| v == &value)?;
strings.first().copied()
}
pub fn all_strings(&self) -> impl Iterator<Item = &str> {
self.0
.iter()
.flat_map(|(_, strings)| strings.iter().copied())
}
}
pub trait OptionExt<T> {
fn try_map<U, E>(
self,
f: impl FnOnce(T) -> Result<U, E>,
) -> Result<Option<U>, E>;
}
impl<T> OptionExt<T> for Option<T> {
fn try_map<U, E>(
self,
f: impl FnOnce(T) -> Result<U, E>,
) -> Result<Option<U>, E> {
self.map(f).transpose()
}
}
pub trait ResultTraced<T, E>: Sized {
#[must_use]
fn traced(self) -> Self;
}
impl<T, E: 'static + Error> ResultTraced<T, E> for Result<T, E> {
fn traced(self) -> Self {
self.inspect_err(|err| error!(error = err as &dyn Error))
}
}
pub trait ResultTracedAnyhow<T, E>: Sized {
#[must_use]
fn traced(self) -> Self;
}
impl<T, E> ResultTracedAnyhow<T, E> for Result<T, E>
where
E: Deref<Target = dyn Error + Send + Sync>,
{
fn traced(self) -> Self {
self.inspect_err(|err| error!(error = err.deref()))
}
}
pub fn doc_link(path: &str) -> String {
const ROOT: &str = "https://slumber.lucaspickering.me/";
if path.is_empty() {
ROOT.into()
} else {
format!("{ROOT}{path}.html")
}
}
pub fn git_link(path: &str) -> String {
format!(
"https://raw.githubusercontent.com\
/LucasPickering/slumber/refs/tags/v{version}/{path}",
version = env!("CARGO_PKG_VERSION"),
)
}
#[derive(Copy, Clone, Debug, Eq, derive_more::From, PartialEq)]
pub struct TimeSpan(Duration);
impl TimeSpan {
pub fn inner(self) -> Duration {
self.0
}
}
impl Display for TimeSpan {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut remaining = self.0.as_secs();
if remaining == 0 {
return write!(f, "0s");
}
let units = DurationUnit::ALL
.iter()
.sorted_by_key(|unit| unit.seconds())
.rev();
for unit in units {
let quantity = remaining / unit.seconds();
if quantity > 0 {
remaining %= unit.seconds();
write!(f, "{quantity}{unit}")?;
}
}
Ok(())
}
}
impl FromStr for TimeSpan {
type Err = TimeSpanParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
fn quantity(input: &mut &str) -> ModalResult<u64> {
digit1.parse_to().parse_next(input)
}
fn unit(input: &mut &str) -> ModalResult<DurationUnit> {
alt((
"s".map(|_| DurationUnit::Second),
"m".map(|_| DurationUnit::Minute),
"h".map(|_| DurationUnit::Hour),
"d".map(|_| DurationUnit::Day),
))
.parse_next(input)
}
let seconds = repeat(1.., (quantity, unit))
.fold(
|| 0,
|acc, (quantity, unit)| acc + (quantity * unit.seconds()),
)
.parse(s)
.map_err(|_| TimeSpanParseError)?;
Ok(Self(Duration::from_secs(seconds)))
}
}
impl<'de> Deserialize<'de> for TimeSpan {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
s.parse().map_err(D::Error::custom)
}
}
#[derive(Debug)]
pub struct TimeSpanParseError;
impl Display for TimeSpanParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"Invalid duration, must be `(<quantity><unit>)+` \
(e.g. `12d` or `1h30m`). Units are {}",
DurationUnit::ALL
.iter()
.format_with(", ", |unit, f| f(&format_args!("`{unit}`")))
)
}
}
impl Error for TimeSpanParseError {}
#[derive(Debug)]
enum DurationUnit {
Second,
Minute,
Hour,
Day,
}
impl DurationUnit {
const ALL: &[Self] = &[Self::Second, Self::Minute, Self::Hour, Self::Day];
fn seconds(&self) -> u64 {
match self {
DurationUnit::Second => 1,
DurationUnit::Minute => 60,
DurationUnit::Hour => 60 * 60,
DurationUnit::Day => 60 * 60 * 24,
}
}
}
impl Display for DurationUnit {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Second => write!(f, "s"),
Self::Minute => write!(f, "m"),
Self::Hour => write!(f, "h"),
Self::Day => write!(f, "d"),
}
}
}
pub fn initialize_tracing(level_filter: LevelFilter, has_stderr: bool) {
fn initialize_log_file() -> io::Result<File> {
let path = paths::log_file();
paths::create_parent(&path)?;
let log_file = OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.open(path)?;
Ok(log_file)
}
let log_file = initialize_log_file().traced().ok();
let file_subscriber = log_file.map(|log_file| {
tracing_subscriber::fmt::layer()
.with_file(true)
.with_line_number(true)
.with_writer(log_file)
.with_target(false)
.with_ansi(false)
.with_span_events(FmtSpan::NONE)
.with_filter(level_filter.max(LevelFilter::WARN))
});
let stderr_subscriber = tracing_subscriber::fmt::layer()
.with_writer(io::stderr)
.with_target(false)
.with_span_events(FmtSpan::NONE)
.without_time()
.with_filter(if has_stderr {
level_filter
} else {
LevelFilter::OFF
});
#[cfg(feature = "tokio_tracing")]
{
tracing_subscriber::registry()
.with(file_subscriber)
.with(stderr_subscriber)
.with(console_subscriber::spawn())
.init();
}
#[cfg(not(feature = "tokio_tracing"))]
{
tracing_subscriber::registry()
.with(file_subscriber)
.with(stderr_subscriber)
.init();
}
}
pub fn serialize_mapping<S, K, V>(
input: &Vec<(K, V)>,
serializer: S,
) -> Result<S::Ok, S::Error>
where
S: Serializer,
K: Serialize,
V: Serialize,
{
let mut map = serializer.serialize_map(Some(input.len()))?;
for (k, v) in input {
map.serialize_entry(k, v)?;
}
map.end()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::assert_err;
use rstest::rstest;
#[rstest]
#[case::zero(Duration::from_secs(0), "0s")]
#[case::seconds_short(Duration::from_secs(3), "3s")]
#[case::seconds_hour(Duration::from_secs(3600), "1h")]
#[case::seconds_composite(Duration::from_secs(3690), "1h1m30s")]
#[case::seconds_subsecond_lost(Duration::from_millis(400), "0s")]
#[case::seconds_subsecond_round_down(Duration::from_millis(1999), "1s")]
fn test_time_span_to_string(
#[case] duration: Duration,
#[case] expected: &'static str,
) {
assert_eq!(&TimeSpan(duration).to_string(), expected);
}
#[rstest]
#[case::seconds_zero("0s", Duration::from_secs(0))]
#[case::seconds_short("1s", Duration::from_secs(1))]
#[case::seconds_longer("100s", Duration::from_secs(100))]
#[case::minutes("3m", Duration::from_secs(180))]
#[case::hours("3h", Duration::from_secs(10_800))]
#[case::days("2d", Duration::from_secs(172_800))]
#[case::composite("2d3h10m17s", Duration::from_secs(
2 * 86400 + 3 * 3600 + 10 * 60 + 17
))]
fn test_time_span_parse(
#[case] s: &'static str,
#[case] expected: Duration,
) {
assert_eq!(s.parse::<TimeSpan>().unwrap(), TimeSpan(expected));
}
#[rstest]
#[case::negative("-1s", "Invalid duration")]
#[case::whitespace(" 1s ", "Invalid duration")]
#[case::trailing_whitespace("1s ", "Invalid duration")]
#[case::decimal("3.5s", "Invalid duration")]
#[case::invalid_unit("3hr", "Units are `s`, `m`, `h`, `d`")]
fn test_time_span_parse_error(
#[case] s: &'static str,
#[case] expected_error: &str,
) {
assert_err(s.parse::<TimeSpan>(), expected_error);
}
}