rtimelog 1.1.1

System for tracking time in a text-log-based format.
Documentation
//! Data struct representing a label and percentage.
//!
//! # Examples
//!
//! ```rust
//! use timelog::chart::TagPercent;
//!
//! # fn main() {
//! let tp = TagPercent::new("One tenth", 10.0).unwrap();
//!
//! if tp.percent_val() < 50.0 {
//!     println!("'{}' is less than half", tp.label());
//! }
//!
//! println!("{}", tp.display_label());
//! # }
//! ```

use std::cmp::Ordering;
use std::fmt::{self, Display};

/// Floating point percentage from 0.0 to 100.0
#[allow(clippy::derive_ord_xor_partial_ord, reason = "force edge case to equal")]
#[derive(Clone, Copy, Debug, Default, PartialEq, PartialOrd)]
pub struct Percent(f32);

impl Percent {
    #[rustfmt::skip]
    pub fn try_new(val: f32) -> Option<Self> {
        (0.0 < val && val <= 100.0).then_some(Percent(val))
    }

    pub fn val(&self) -> f32 { self.0 }
}

impl Eq for Percent {}

impl Ord for Percent {
    /// Compare two [`Percent`] values, returning appropriate Ordering variant
    fn cmp(&self, right: &Self) -> Ordering {
        // Safe to convert unwrap_or because of the data limits and no non-numbers
        self.partial_cmp(right).unwrap_or(Ordering::Equal)
    }
}

impl Display for Percent {
    /// Format the percent, assuming a positive floating point number less than or equal to 100.0.
    /// If you use a number outside that range, the formatting may not be appropriate.
    ///
    /// - Numbers less than 1 will be formatted with one decimal place.
    /// - Numbers greater than or equal to 1 will be formatted as whole numbers.
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        if self.0 < 1.0 {
            write!(f, "{:.1}%", self.0)
        }
        else {
            write!(f, "{:.0}%", self.0)
        }
    }
}

/// Structure holding a percent value with a label
#[derive(Debug, Clone)]
pub struct TagPercent {
    percent: Percent,
    label:   String
}

impl TagPercent {
    /// Create a new [`TagPercent`] object if the percentage is between 0 and 100
    /// inclusive, otherwise `None`.
    #[rustfmt::skip]
    pub fn new(label: &str, percent: f32) -> Option<Self> {
        Some(
            Self { percent: Percent::try_new(percent)?, label: label.to_string() }
        )
    }

    /// Return a reference to the label.
    pub fn label(&self) -> &str { &self.label }

    /// Return a display label
    pub fn display_label(&self) -> String { format!("{} - {}", self.percent, self.label) }

    /// Return the percentage value.
    pub fn percent(&self) -> &Percent { &self.percent }

    /// Return the percentage value.
    pub fn percent_val(&self) -> f32 { self.percent.val() }
}

impl PartialOrd for TagPercent {
    /// Compare two [`TagPercent`] values, returning appropriate Ordering variant.
    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
        Some(
            self.percent
                .cmp(&other.percent)
                .reverse()
                .then_with(|| self.label.cmp(&other.label))
        )
    }
}

impl PartialEq for TagPercent {
    /// Return `true` if two [`TagPercent`] values are equal.
    fn eq(&self, other: &Self) -> bool {
        (self.percent == other.percent) && (self.label == other.label)
    }
}

impl Eq for TagPercent {}

#[cfg(test)]
mod tests {
    use assert2::{assert, let_assert};
    use rstest::rstest;

    use super::*;

    #[test]
    fn test_new() {
        let_assert!(Some(tagper) = TagPercent::new("foo", 50.0));
        assert!(tagper == TagPercent { percent: Percent(50.0), label: "foo".to_string() });
    }

    #[rstest]
    #[case("foo", 100.0, "100% - foo")]
    #[case("foo", 50.0,  "50% - foo")]
    #[case("foo", 4.0,   "4% - foo")]
    fn test_display_label(#[case]label: &str, #[case]pcnt: f32, #[case]expect: &str) {
        let_assert!(Some(tagper) = TagPercent::new(label, pcnt));
        assert!(tagper.display_label() == expect.to_string());
    }

    #[rstest]
    #[case("foo", 0.0, "zero")]
    #[case("foo", -10.0, "negative")]
    #[case("foo", 100.1, "too large")]
    #[rustfmt::skip]
    fn test_new_zero(#[case]label: &str, #[case]pcnt: f32, #[case]msg: &str) {
        assert!(TagPercent::new(label, pcnt).is_none(), "{msg}");
    }

    #[test]
    fn test_accessors() {
        let_assert!(Some(val) = TagPercent::new("bar", 37.5));
        assert!(val.label() == "bar");
        assert!(val.percent_val() == 37.5);
    }

    #[test]
    fn test_order_by_percentage_same_label() {
        let_assert!(Some(val1) = TagPercent::new("bar", 10.0));
        let_assert!(Some(val2) = TagPercent::new("bar", 15.0));
        assert!(Some(Ordering::Greater) == val1.partial_cmp(&val2));
        assert!(Some(Ordering::Less) == val2.partial_cmp(&val1));
    }

    #[test]
    fn test_order_by_percentage_diff_label() {
        let_assert!(Some(val1) = TagPercent::new("bar", 10.0));
        let_assert!(Some(val2) = TagPercent::new("foo", 15.0));
        assert!(Some(Ordering::Greater) == val1.partial_cmp(&val2));
        assert!(Some(Ordering::Less) == val2.partial_cmp(&val1));
    }

    #[test]
    fn test_order_by_label_same_percentage() {
        let_assert!(Some(val1) = TagPercent::new("bar", 10.0));
        let_assert!(Some(val2) = TagPercent::new("foo", 10.0));
        assert!(Some(Ordering::Less) == val1.partial_cmp(&val2));
        assert!(Some(Ordering::Greater) == val2.partial_cmp(&val1));
    }
}