Skip to main content

max / ripgrow

7.2 KB · 283 lines History Blame Raw
1 //! Validated domain primitives.
2 //!
3 //! Newtype wrappers around the raw numeric types used across ripgrow.
4 //! Their point is not run-time safety (the schema also enforces most of
5 //! these invariants); the point is that a function signature says exactly
6 //! what it works with. `fn append_set(rpe: i32)` will accept `0` or `-1`;
7 //! `fn append_set(rpe: Rpe)` will not.
8 //!
9 //! Every constructor is fallible and returns `crate::Error`. Accessors
10 //! return the raw inner value.
11
12 use std::fmt;
13
14 use crate::error::Error;
15
16 /// Rate of Perceived Exertion on ripgrow's 1-5 scale. RPE is a
17 /// self-report, not a measurement; even a "correct" value is subjective.
18 /// The scale mapping (RPE 5 = maximum effort, RPE 1 = warmup) is documented
19 /// in `heuristics::RIR_FROM_RPE`.
20 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
21 pub struct Rpe(i32);
22
23 impl Rpe {
24 pub const MIN: Rpe = Rpe(1);
25 pub const MAX: Rpe = Rpe(5);
26
27 pub fn new(n: i32) -> Result<Self, Error> {
28 if (1..=5).contains(&n) {
29 Ok(Rpe(n))
30 } else {
31 Err(Error::InvalidRpe(n))
32 }
33 }
34
35 pub fn get(self) -> i32 {
36 self.0
37 }
38 }
39
40 /// Reps performed in a set. A real measurement (someone counted).
41 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
42 pub struct Reps(i32);
43
44 impl Reps {
45 pub fn new(n: i32) -> Result<Self, Error> {
46 if n >= 0 {
47 Ok(Reps(n))
48 } else {
49 Err(Error::InvalidReps(n))
50 }
51 }
52
53 pub fn get(self) -> i32 {
54 self.0
55 }
56 }
57
58 /// Weight moved on a set, in the exercise's [`LoadUnit`]. A real
59 /// measurement (whatever the plate stack or the barbell reads).
60 #[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
61 pub struct Load(f64);
62
63 impl Load {
64 /// Zero load — for bodyweight or before-plates entries.
65 pub const ZERO: Load = Load(0.0);
66
67 pub fn new(v: f64) -> Result<Self, Error> {
68 if v.is_finite() && v >= 0.0 {
69 Ok(Load(v))
70 } else {
71 Err(Error::InvalidLoad(v))
72 }
73 }
74
75 pub fn get(self) -> f64 {
76 self.0
77 }
78 }
79
80 /// A time span, stored as a non-negative integer count of seconds.
81 /// Used by timed and distance efforts (plank hold, run duration).
82 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
83 pub struct Duration(i32);
84
85 impl Duration {
86 pub const ZERO: Duration = Duration(0);
87
88 pub fn from_seconds(n: i32) -> Result<Self, Error> {
89 if n >= 0 {
90 Ok(Duration(n))
91 } else {
92 Err(Error::InvalidDuration(n))
93 }
94 }
95
96 pub fn seconds(self) -> i32 {
97 self.0
98 }
99 }
100
101 impl fmt::Display for Duration {
102 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
103 let total = self.0;
104 let m = total / 60;
105 let s = total % 60;
106 if m > 0 {
107 write!(f, "{m}m{s:02}s")
108 } else {
109 write!(f, "{s}s")
110 }
111 }
112 }
113
114 /// A distance in meters. Always non-negative. Used by cardio distance
115 /// efforts.
116 #[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
117 pub struct Distance(f64);
118
119 impl Distance {
120 pub const ZERO: Distance = Distance(0.0);
121
122 pub fn from_meters(v: f64) -> Result<Self, Error> {
123 if v.is_finite() && v >= 0.0 {
124 Ok(Distance(v))
125 } else {
126 Err(Error::InvalidDistance(v))
127 }
128 }
129
130 pub fn meters(self) -> f64 {
131 self.0
132 }
133 }
134
135 impl fmt::Display for Distance {
136 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
137 write!(f, "{} m", self.0)
138 }
139 }
140
141 /// Smallest legal load change for an exercise. May be zero (bodyweight,
142 /// cardio). A real measurement of the equipment.
143 #[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
144 pub struct Increment(f64);
145
146 impl Increment {
147 pub const ZERO: Increment = Increment(0.0);
148
149 pub fn new(v: f64) -> Result<Self, Error> {
150 if v.is_finite() && v >= 0.0 {
151 Ok(Increment(v))
152 } else {
153 Err(Error::InvalidIncrement(v))
154 }
155 }
156
157 pub fn get(self) -> f64 {
158 self.0
159 }
160 }
161
162 impl fmt::Display for Rpe {
163 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
164 write!(f, "{}", self.0)
165 }
166 }
167
168 impl fmt::Display for Reps {
169 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
170 write!(f, "{}", self.0)
171 }
172 }
173
174 impl fmt::Display for Load {
175 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
176 write!(f, "{}", self.0)
177 }
178 }
179
180 impl fmt::Display for Increment {
181 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
182 write!(f, "{}", self.0)
183 }
184 }
185
186 /// Weight unit for the load column. Both the profile's default unit and
187 /// each exercise's `load_unit` use this type. Timed and distance efforts
188 /// have implicit units (seconds, meters) and do not carry a `LoadUnit`.
189 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
190 pub enum LoadUnit {
191 Kg,
192 Lb,
193 }
194
195 impl LoadUnit {
196 pub fn as_str(&self) -> &'static str {
197 match self {
198 LoadUnit::Kg => "kg",
199 LoadUnit::Lb => "lb",
200 }
201 }
202
203 pub fn parse(s: &str) -> Result<Self, Error> {
204 match s {
205 "kg" => Ok(LoadUnit::Kg),
206 "lb" => Ok(LoadUnit::Lb),
207 other => Err(Error::InvalidUnit(other.to_string())),
208 }
209 }
210 }
211
212 impl fmt::Display for LoadUnit {
213 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
214 f.write_str(self.as_str())
215 }
216 }
217
218 /// Wrapper around a value derived from a formula rather than measured.
219 /// The `method` field carries a short human-readable label naming the
220 /// formula so that UI code can render "e1RM (rpe-adjusted Epley)" instead
221 /// of a bare number that pretends to be an observation.
222 ///
223 /// This exists so the type system pushes back on treating estimates as
224 /// ground truth. Anywhere a function returns `Estimate<T>` instead of
225 /// `T`, that is a load-bearing signal that the caller is looking at a
226 /// heuristic.
227 #[derive(Debug, Clone, Copy, PartialEq)]
228 pub struct Estimate<T> {
229 pub value: T,
230 pub method: &'static str,
231 }
232
233 impl<T> Estimate<T> {
234 pub fn new(value: T, method: &'static str) -> Self {
235 Estimate { value, method }
236 }
237 }
238
239 #[cfg(test)]
240 mod tests {
241 use super::*;
242
243 #[test]
244 fn rpe_validates_range() {
245 assert!(Rpe::new(0).is_err());
246 assert!(Rpe::new(6).is_err());
247 assert_eq!(Rpe::new(3).unwrap().get(), 3);
248 }
249
250 #[test]
251 fn reps_rejects_negative() {
252 assert!(Reps::new(-1).is_err());
253 assert_eq!(Reps::new(0).unwrap().get(), 0);
254 }
255
256 #[test]
257 fn load_rejects_negative_and_nan() {
258 assert!(Load::new(-0.1).is_err());
259 assert!(Load::new(f64::NAN).is_err());
260 assert!(Load::new(f64::INFINITY).is_err());
261 assert_eq!(Load::new(100.0).unwrap().get(), 100.0);
262 }
263
264 #[test]
265 fn increment_rejects_negative() {
266 assert!(Increment::new(-1.0).is_err());
267 assert_eq!(Increment::ZERO.get(), 0.0);
268 }
269
270 #[test]
271 fn load_unit_round_trip() {
272 assert_eq!(LoadUnit::parse("kg").unwrap(), LoadUnit::Kg);
273 assert_eq!(LoadUnit::Lb.as_str(), "lb");
274 assert!(LoadUnit::parse("stone").is_err());
275 }
276
277 #[test]
278 fn estimate_carries_method_label() {
279 let e = Estimate::new(150.0, "epley");
280 assert_eq!(e.method, "epley");
281 }
282 }
283