compact-reltime 0.1.0

Compact relative time formatting
Documentation
#![doc(html_logo_url = "https://codeberg.org/pezcore/compact-reltime/raw/branch/main/icon.svg")]
#![doc = include_str!("../README.md")]
#![doc = include_str!("../usage.md")]

use std::fmt::{Display, Formatter, Result};
use std::io::Write;

/// The maximum duration supported by the crate in seconds; 99 years and 11 months
pub const MAX_DURATION: u64 = 99 * Units::Years as u64 + 11 * Units::Months as u64;

/// A duration that formats with human-friendly units and precision via [`Display`].
///
/// This type represents a duration of time with 1 second resolution, and is primarily useful
/// via its implementation of [`Display`]. The `Display` format is a short string representing the
/// approximate duration in terms of one or two distinct units of time which are automatically
/// selected based on the duration where the longer the duration, the larger the units selected to
/// represent it are. In general the string format is lossy, it approximates the duration by
/// rounding it down to the lowest increment of the minor units selected to represent it.
///
/// # Unit elision
/// In some cases, the quantity of the smallest unit selected to represent the duration is zero, and
/// in these cases the 0-valued unit component is omitted. For example, consider a duration of
/// 605,300 seconds, which is exactly 1 week 8 minutes and 20 seconds: the units selected to
/// represent this duration are weeks and days, therefore the decomposition is 1w + 0d. Since the
/// days component of this decomposition is zero, it is omitted from the formatted string and the
/// final formatting for this duration is just `1w`.
///
/// # Examples
///
/// ```rust
/// use compact_reltime::Reltime;
///
/// let reltime = Reltime::new(135243).unwrap();
/// assert_eq!(reltime.to_string(), "37h34m");
/// ```
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Reltime(u64);

/// Possible units for a component of a relative time.
///
/// Intended to be used with [`Reltime::format`] and [`Reltime::format_with_timetable`] to control
/// units are selected to decompose a [`Reltime`] value for formatting. See those method for
/// details.
#[repr(u64)]
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Units {
    Seconds = 1,
    Minutes = 60,
    Hours = 60 * Self::Minutes as u64,
    Days = 24 * Self::Hours as u64,
    Weeks = 7 * Self::Days as u64,
    Months = 31 * Self::Days as u64,
    Years = 365 * Self::Days as u64,
}

impl From<Units> for char {
    fn from(u: Units) -> Self {
        match u {
            Units::Seconds => 's',
            Units::Minutes => 'm',
            Units::Hours => 'h',
            Units::Days => 'd',
            Units::Weeks => 'w',
            Units::Months => 'M',
            Units::Years => 'y',
        }
    }
}

impl Display for Units {
    fn fmt(&self, f: &mut Formatter<'_>) -> Result {
        write!(f, "{}", char::from(*self))
    }
}

#[derive(Debug, Copy, Clone)]
struct Part {
    amount: u8,
    units: Units,
}

impl Display for Part {
    fn fmt(&self, f: &mut Formatter<'_>) -> Result {
        match self.amount {
            0 => Ok(()),
            n => write!(f, "{}{}", n, self.units),
        }
    }
}

/// A formatted time duration
///
/// This type represents a time duration which has been decomposed into the sum of 2 components of
/// distinct units. The only intended purpose of this type is to be rendered via [`Display`]. Values
/// of this type are created by [`Reltime::format`].
///
/// This type is similar to [`Reltime`] but differs from it in that the specific decomposition of
/// the duration into unit components is already determined in values of this type, but values of
/// [`Reltime`] have not yet been decomposed.
#[derive(Debug, Copy, Clone)]
pub struct FormattedReltime(Part, Part);
impl Display for FormattedReltime {
    fn fmt(&self, f: &mut Formatter<'_>) -> Result {
        let mut buf = [0u8; 8];
        write!(buf.as_mut_slice(), "{}{}", self.0, self.1).unwrap();
        let len = buf.iter().position(|&x| x == 0).unwrap_or(8);
        f.pad(str::from_utf8(&buf[..len]).unwrap())
    }
}

/// Format using human-friendly units and precision.
///
/// This implementation uses a built-in default time table to determine the units used to represent
/// the duration. For direct control over how the duration is decomposed into units, see
/// [`Reltime::format`].
impl Display for Reltime {
    fn fmt(&self, f: &mut Formatter<'_>) -> Result {
        const MAP: [(u64, Units, Units); 6] = [
            (90, Units::Seconds, Units::Seconds),
            (90 * Units::Minutes as u64, Units::Minutes, Units::Seconds),
            (72 * Units::Hours as u64, Units::Hours, Units::Minutes),
            (10 * Units::Days as u64, Units::Days, Units::Hours),
            (8 * Units::Weeks as u64, Units::Weeks, Units::Days),
            (18 * Units::Months as u64, Units::Months, Units::Weeks),
        ];
        self.format_with_timetable(&MAP).fmt(f)
    }
}

/// A type for constructing threshold tables for [`Reltime::format_with_timetable`]. A value of this
/// type, `x` means that a duration less than `x.0` seconds will be docomposed into components with
/// major units `x.1` and minor units `x.2`, unless another `ThreshSpec` value with a lower `.0`
/// component is also greater than the duration.
pub type ThreshSpec = (u64, Units, Units);

/// Error type signaling that a [`Reltime`] which exceeds the supported duration was attempted to be
/// initialized.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TooLong;

impl Reltime {
    /// Create a new value from a duration of `seconds` seconds. Duration in seconds must be less
    /// than or equal to [`MAX_DURATION`] seconds
    pub fn new(seconds: u64) -> std::result::Result<Self, TooLong> {
        if seconds > MAX_DURATION { Err(TooLong) } else { Ok(Self(seconds)) }
    }

    /// get the underlying duration in seconds
    pub fn seconds(self) -> u64 {
        self.0
    }

    #[inline]
    fn decomp(self, u_big: Units, u_smol: Units) -> FormattedReltime {
        let (q_big, rem) = (self.0 / u_big as u64, self.0 % u_big as u64);
        let q_smol = rem / u_smol as u64;
        FormattedReltime(
            Part { amount: q_big as _, units: u_big },
            Part { amount: q_smol as _, units: u_smol },
        )
    }

    /// Format a duration using a custom decomposition
    ///
    /// Duration is formatted as a sum of terms with units defined by `f` which takes the duration
    /// in seconds and returns the major and minor units to decompose it into.
    pub fn format(self, f: impl FnOnce(u64) -> (Units, Units)) -> FormattedReltime {
        let (u_big, u_smol) = f(self.0);
        self.decomp(u_big, u_smol)
    }

    /// Create a formatted relative time representation of this duration using a custom time table
    /// to determine the units used for decomposition.
    ///
    /// This method allows callers more fine-grained control over the units selected for decomposing
    /// the duration into its unit components. The two units used to decompose the duration are
    /// those in the element of `threshmap` whose first member is the lowest such among all those
    /// which have a first element higher than the this duration in seconds.
    ///
    /// note: `threshmap` must uphold the following conditions otherwise the return value of this
    /// method is meaninless:
    ///
    /// 1. `x.1 >= x.2` for all `x` in `threshmap`
    /// 2. `threshmap[0].0`, `threshmap[1].0`, `threshmap[2].0`... must be monotonically increasing
    pub fn format_with_timetable(self, threshmap: &[ThreshSpec]) -> FormattedReltime {
        const HIGH_UNITS: (u64, Units, Units) = (0, Units::Years, Units::Months);
        let (Ok(idx) | Err(idx)) = threshmap.binary_search_by(|x| x.0.cmp(&self.0));
        let &(_, u_big, u_smol) = threshmap.get(idx).unwrap_or(&HIGH_UNITS);
        self.decomp(u_big, u_smol)
    }
}

impl TryFrom<std::time::Duration> for Reltime {
    type Error = TooLong;

    fn try_from(value: std::time::Duration) -> std::result::Result<Self, Self::Error> {
        Self::new(value.as_secs())
    }
}

impl From<Reltime> for std::time::Duration {
    fn from(r: Reltime) -> Self {
        std::time::Duration::from_secs(r.seconds())
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn one() {
        let reltime = Reltime(135243);
        assert_eq!(reltime.to_string(), "37h34m");
        let reltime = Reltime(3204898);
        assert_eq!(reltime.to_string(), "5w2d");
    }

    #[test]
    fn error_too_long() {
        let reltime = Reltime::new(999_999_999_999);
        assert_eq!(reltime, Err(TooLong));
    }
}