//! Validated domain primitives. //! //! Newtype wrappers around the raw numeric types used across ripgrow. //! Their point is not run-time safety (the schema also enforces most of //! these invariants); the point is that a function signature says exactly //! what it works with. `fn append_set(rpe: i32)` will accept `0` or `-1`; //! `fn append_set(rpe: Rpe)` will not. //! //! Every constructor is fallible and returns `crate::Error`. Accessors //! return the raw inner value. use std::fmt; use crate::error::Error; /// Rate of Perceived Exertion on ripgrow's 1-5 scale. RPE is a /// self-report, not a measurement; even a "correct" value is subjective. /// The scale mapping (RPE 5 = maximum effort, RPE 1 = warmup) is documented /// in `heuristics::RIR_FROM_RPE`. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct Rpe(i32); impl Rpe { pub const MIN: Rpe = Rpe(1); pub const MAX: Rpe = Rpe(5); pub fn new(n: i32) -> Result { if (1..=5).contains(&n) { Ok(Rpe(n)) } else { Err(Error::InvalidRpe(n)) } } pub fn get(self) -> i32 { self.0 } } /// Reps performed in a set. A real measurement (someone counted). #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct Reps(i32); impl Reps { pub fn new(n: i32) -> Result { if n >= 0 { Ok(Reps(n)) } else { Err(Error::InvalidReps(n)) } } pub fn get(self) -> i32 { self.0 } } /// Weight moved on a set, in the exercise's [`LoadUnit`]. A real /// measurement (whatever the plate stack or the barbell reads). #[derive(Debug, Clone, Copy, PartialEq, PartialOrd)] pub struct Load(f64); impl Load { /// Zero load — for bodyweight or before-plates entries. pub const ZERO: Load = Load(0.0); pub fn new(v: f64) -> Result { if v.is_finite() && v >= 0.0 { Ok(Load(v)) } else { Err(Error::InvalidLoad(v)) } } pub fn get(self) -> f64 { self.0 } } /// A time span, stored as a non-negative integer count of seconds. /// Used by timed and distance efforts (plank hold, run duration). #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct Duration(i32); impl Duration { pub const ZERO: Duration = Duration(0); pub fn from_seconds(n: i32) -> Result { if n >= 0 { Ok(Duration(n)) } else { Err(Error::InvalidDuration(n)) } } pub fn seconds(self) -> i32 { self.0 } } impl fmt::Display for Duration { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let total = self.0; let m = total / 60; let s = total % 60; if m > 0 { write!(f, "{m}m{s:02}s") } else { write!(f, "{s}s") } } } /// A distance in meters. Always non-negative. Used by cardio distance /// efforts. #[derive(Debug, Clone, Copy, PartialEq, PartialOrd)] pub struct Distance(f64); impl Distance { pub const ZERO: Distance = Distance(0.0); pub fn from_meters(v: f64) -> Result { if v.is_finite() && v >= 0.0 { Ok(Distance(v)) } else { Err(Error::InvalidDistance(v)) } } pub fn meters(self) -> f64 { self.0 } } impl fmt::Display for Distance { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{} m", self.0) } } /// Smallest legal load change for an exercise. May be zero (bodyweight, /// cardio). A real measurement of the equipment. #[derive(Debug, Clone, Copy, PartialEq, PartialOrd)] pub struct Increment(f64); impl Increment { pub const ZERO: Increment = Increment(0.0); pub fn new(v: f64) -> Result { if v.is_finite() && v >= 0.0 { Ok(Increment(v)) } else { Err(Error::InvalidIncrement(v)) } } pub fn get(self) -> f64 { self.0 } } impl fmt::Display for Rpe { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.0) } } impl fmt::Display for Reps { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.0) } } impl fmt::Display for Load { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.0) } } impl fmt::Display for Increment { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.0) } } /// Weight unit for the load column. Both the profile's default unit and /// each exercise's `load_unit` use this type. Timed and distance efforts /// have implicit units (seconds, meters) and do not carry a `LoadUnit`. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum LoadUnit { Kg, Lb, } impl LoadUnit { pub fn as_str(&self) -> &'static str { match self { LoadUnit::Kg => "kg", LoadUnit::Lb => "lb", } } pub fn parse(s: &str) -> Result { match s { "kg" => Ok(LoadUnit::Kg), "lb" => Ok(LoadUnit::Lb), other => Err(Error::InvalidUnit(other.to_string())), } } } impl fmt::Display for LoadUnit { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(self.as_str()) } } /// Wrapper around a value derived from a formula rather than measured. /// The `method` field carries a short human-readable label naming the /// formula so that UI code can render "e1RM (rpe-adjusted Epley)" instead /// of a bare number that pretends to be an observation. /// /// This exists so the type system pushes back on treating estimates as /// ground truth. Anywhere a function returns `Estimate` instead of /// `T`, that is a load-bearing signal that the caller is looking at a /// heuristic. #[derive(Debug, Clone, Copy, PartialEq)] pub struct Estimate { pub value: T, pub method: &'static str, } impl Estimate { pub fn new(value: T, method: &'static str) -> Self { Estimate { value, method } } } #[cfg(test)] mod tests { use super::*; #[test] fn rpe_validates_range() { assert!(Rpe::new(0).is_err()); assert!(Rpe::new(6).is_err()); assert_eq!(Rpe::new(3).unwrap().get(), 3); } #[test] fn reps_rejects_negative() { assert!(Reps::new(-1).is_err()); assert_eq!(Reps::new(0).unwrap().get(), 0); } #[test] fn load_rejects_negative_and_nan() { assert!(Load::new(-0.1).is_err()); assert!(Load::new(f64::NAN).is_err()); assert!(Load::new(f64::INFINITY).is_err()); assert_eq!(Load::new(100.0).unwrap().get(), 100.0); } #[test] fn increment_rejects_negative() { assert!(Increment::new(-1.0).is_err()); assert_eq!(Increment::ZERO.get(), 0.0); } #[test] fn load_unit_round_trip() { assert_eq!(LoadUnit::parse("kg").unwrap(), LoadUnit::Kg); assert_eq!(LoadUnit::Lb.as_str(), "lb"); assert!(LoadUnit::parse("stone").is_err()); } #[test] fn estimate_carries_method_label() { let e = Estimate::new(150.0, "epley"); assert_eq!(e.method, "epley"); } }