Skip to main content

max / ripgrow

sets: SessionSet fields are Load, Reps, Rpe The read side of the sets table now returns validated newtypes. Bad rows (RPE 7, negative load) fail at decode time via the row_to_session_set constructor rather than propagating garbage through the state machine. Callers read .load.get() / .reps.get() / .rpe.get() where a raw number is actually needed; Display impls on the newtypes cover format!() usage. Write-path (append_set / append_diagnostic_set) still takes primitives for now and validates through the same newtype constructors before handing off to append_set_inner, which takes newtypes. Lifting the public write API to newtypes is the next commit; keeping this diff scoped to the read side. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-18 18:13 UTC
Commit: 61bb1a88d368b2cfd0028ab611ba4dc06c437769
Parent: baaf895
7 files changed, +101 insertions, -50 deletions
@@ -60,8 +60,8 @@ pub fn evaluate_session(sets: &[SessionSet], target_reps: i32) -> SessionOutcome
60 60 if sets.is_empty() {
61 61 return SessionOutcome::Miss;
62 62 }
63 - let all_hit_target = sets.iter().all(|s| s.reps >= target_reps);
64 - let max_rpe = sets.iter().map(|s| s.rpe).max().unwrap_or(1);
63 + let all_hit_target = sets.iter().all(|s| s.reps.get() >= target_reps);
64 + let max_rpe = sets.iter().map(|s| s.rpe.get()).max().unwrap_or(1);
65 65 if !all_hit_target || max_rpe >= 5 {
66 66 SessionOutcome::Miss
67 67 } else if max_rpe == 4 {
@@ -249,12 +249,12 @@ fn round_to_increment(value: f64, increment: f64) -> f64 {
249 249 fn top_load(session: &[SessionSet]) -> f64 {
250 250 session
251 251 .iter()
252 - .map(|s| s.load)
252 + .map(|s| s.load.get())
253 253 .fold(f64::NEG_INFINITY, f64::max)
254 254 }
255 255
256 256 fn top_reps(session: &[SessionSet]) -> i32 {
257 - session.iter().map(|s| s.reps).max().unwrap_or(0)
257 + session.iter().map(|s| s.reps.get()).max().unwrap_or(0)
258 258 }
259 259
260 260 /// Walk the exercise's session history and produce the next prescription.
@@ -462,9 +462,9 @@ mod tests {
462 462 session_date: date,
463 463 exercise_id: 1,
464 464 set_index,
465 - load,
466 - reps,
467 - rpe,
465 + load: crate::values::Load::new(load).unwrap(),
466 + reps: crate::values::Reps::new(reps).unwrap(),
467 + rpe: crate::values::Rpe::new(rpe).unwrap(),
468 468 is_diagnostic: false,
469 469 }
470 470 }
@@ -483,15 +483,15 @@ mod tests {
483 483 assert_eq!(evaluate_session(&sets, 5), SessionOutcome::CleanHit);
484 484
485 485 let mut mixed = session(day(1), 100.0, 5, 3, 3);
486 - mixed[2].rpe = 4;
486 + mixed[2].rpe = crate::values::Rpe::new(4).unwrap();
487 487 assert_eq!(evaluate_session(&mixed, 5), SessionOutcome::Hold);
488 488
489 489 let mut missed = session(day(1), 100.0, 5, 3, 3);
490 - missed[2].reps = 4;
490 + missed[2].reps = crate::values::Reps::new(4).unwrap();
491 491 assert_eq!(evaluate_session(&missed, 5), SessionOutcome::Miss);
492 492
493 493 let mut hard = session(day(1), 100.0, 5, 3, 3);
494 - hard[0].rpe = 5;
494 + hard[0].rpe = crate::values::Rpe::new(5).unwrap();
495 495 assert_eq!(evaluate_session(&hard, 5), SessionOutcome::Miss);
496 496 }
497 497
@@ -532,7 +532,7 @@ mod tests {
532 532 session(day(1), 100.0, 5, 3, 3),
533 533 {
534 534 let mut s = session(day(2), 102.5, 5, 3, 3);
535 - s[2].reps = 4;
535 + s[2].reps = crate::values::Reps::new(4).unwrap();
536 536 s
537 537 },
538 538 ];
@@ -549,7 +549,7 @@ mod tests {
549 549 session(day(1), 100.0, 5, 3, 3),
550 550 {
551 551 let mut s = session(day(2), 102.5, 5, 3, 3);
552 - s[2].reps = 4;
552 + s[2].reps = crate::values::Reps::new(4).unwrap();
553 553 s
554 554 },
555 555 session(day(3), 102.5, 5, 3, 3),
@@ -567,12 +567,12 @@ mod tests {
567 567 session(day(1), 100.0, 5, 3, 3),
568 568 {
569 569 let mut s = session(day(2), 100.0, 5, 3, 3);
570 - s[2].reps = 4;
570 + s[2].reps = crate::values::Reps::new(4).unwrap();
571 571 s
572 572 },
573 573 {
574 574 let mut s = session(day(3), 100.0, 5, 3, 3);
575 - s[2].reps = 4;
575 + s[2].reps = crate::values::Reps::new(4).unwrap();
576 576 s
577 577 },
578 578 ];
@@ -594,12 +594,12 @@ mod tests {
594 594 session(day(1), 100.0, 5, 3, 3),
595 595 {
596 596 let mut s = session(day(2), 100.0, 5, 3, 3);
597 - s[2].reps = 4;
597 + s[2].reps = crate::values::Reps::new(4).unwrap();
598 598 s
599 599 },
600 600 {
601 601 let mut s = session(day(3), 100.0, 5, 3, 3);
602 - s[2].reps = 4;
602 + s[2].reps = crate::values::Reps::new(4).unwrap();
603 603 s
604 604 },
605 605 session(day(4), 90.0, 5, 3, 3),
@@ -619,12 +619,12 @@ mod tests {
619 619 session(day(1), 100.0, 5, 3, 3),
620 620 {
621 621 let mut s = session(day(2), 100.0, 5, 3, 3);
622 - s[2].reps = 4;
622 + s[2].reps = crate::values::Reps::new(4).unwrap();
623 623 s
624 624 },
625 625 {
626 626 let mut s = session(day(3), 100.0, 5, 3, 3);
627 - s[2].reps = 4;
627 + s[2].reps = crate::values::Reps::new(4).unwrap();
628 628 s
629 629 },
630 630 session(day(4), 90.0, 5, 3, 3),
@@ -651,12 +651,12 @@ mod tests {
651 651 session(day(1), 82.5, 5, 3, 3),
652 652 {
653 653 let mut s = session(day(2), 82.5, 5, 3, 3);
654 - s[2].reps = 4;
654 + s[2].reps = crate::values::Reps::new(4).unwrap();
655 655 s
656 656 },
657 657 {
658 658 let mut s = session(day(3), 82.5, 5, 3, 3);
659 - s[2].reps = 4;
659 + s[2].reps = crate::values::Reps::new(4).unwrap();
660 660 s
661 661 },
662 662 ];
@@ -10,23 +10,37 @@ use rusqlite::{OptionalExtension, Row, params};
10 10 use crate::db::Db;
11 11 use crate::error::Error;
12 12 use crate::estimator::estimate_e1rm;
13 + use crate::values::{Load, Reps, Rpe};
14 +
15 + fn bad_column(field: &'static str, msg: String) -> rusqlite::Error {
16 + rusqlite::Error::FromSqlConversionFailure(
17 + 0,
18 + rusqlite::types::Type::Real,
19 + Box::new(std::io::Error::other(format!("{field}: {msg}"))),
20 + )
21 + }
13 22
14 23 /// Row -> SessionSet decoder shared by every set-fetching query. Column
15 - /// order must match the callers' SELECT lists.
24 + /// order must match the callers' SELECT lists. Validation happens here
25 + /// so a bad row (RPE 7, negative load) errors at read time instead of
26 + /// silently propagating through the progression walk.
16 27 fn row_to_session_set(row: &Row<'_>) -> rusqlite::Result<SessionSet> {
17 28 let date_str: String = row.get(1)?;
18 29 let parsed = NaiveDate::parse_from_str(&date_str, "%Y-%m-%d").map_err(|e| {
19 30 rusqlite::Error::FromSqlConversionFailure(1, rusqlite::types::Type::Text, Box::new(e))
20 31 })?;
21 32 let flag: i64 = row.get(7)?;
33 + let load = Load::new(row.get(4)?).map_err(|e| bad_column("load", e.to_string()))?;
34 + let reps = Reps::new(row.get(5)?).map_err(|e| bad_column("reps", e.to_string()))?;
35 + let rpe = Rpe::new(row.get(6)?).map_err(|e| bad_column("rpe", e.to_string()))?;
22 36 Ok(SessionSet {
23 37 id: row.get(0)?,
24 38 session_date: parsed,
25 39 exercise_id: row.get(2)?,
26 40 set_index: row.get(3)?,
27 - load: row.get(4)?,
28 - reps: row.get(5)?,
29 - rpe: row.get(6)?,
41 + load,
42 + reps,
43 + rpe,
30 44 is_diagnostic: flag != 0,
31 45 })
32 46 }
@@ -37,9 +51,9 @@ pub struct SessionSet {
37 51 pub session_date: NaiveDate,
38 52 pub exercise_id: i64,
39 53 pub set_index: i32,
40 - pub load: f64,
41 - pub reps: i32,
42 - pub rpe: i32,
54 + pub load: Load,
55 + pub reps: Reps,
56 + pub rpe: Rpe,
43 57 pub is_diagnostic: bool,
44 58 }
45 59
@@ -67,6 +81,11 @@ impl Db {
67 81 /// Append a normal (non-diagnostic) working set. `set_index` is
68 82 /// auto-assigned as one past the current max for this (date, exercise).
69 83 /// Returns the new row id.
84 + ///
85 + /// Primitive inputs are validated through the newtype constructors
86 + /// before any SQL runs. A follow-up commit will lift the signature to
87 + /// take `Load`/`Reps`/`Rpe` directly so parsing lives at the TUI
88 + /// boundary.
70 89 pub fn append_set(
71 90 &self,
72 91 exercise_id: i64,
@@ -75,6 +94,9 @@ impl Db {
75 94 reps: i32,
76 95 rpe: i32,
77 96 ) -> Result<i64, Error> {
97 + let load = Load::new(load)?;
98 + let reps = Reps::new(reps)?;
99 + let rpe = Rpe::new(rpe)?;
78 100 self.append_set_inner(exercise_id, date, load, reps, rpe, false)
79 101 }
80 102
@@ -89,6 +111,9 @@ impl Db {
89 111 reps: i32,
90 112 rpe: i32,
91 113 ) -> Result<i64, Error> {
114 + let load = Load::new(load)?;
115 + let reps = Reps::new(reps)?;
116 + let rpe = Rpe::new(rpe)?;
92 117 self.append_set_inner(exercise_id, date, load, reps, rpe, true)
93 118 }
94 119
@@ -96,17 +121,11 @@ impl Db {
96 121 &self,
97 122 exercise_id: i64,
98 123 date: NaiveDate,
99 - load: f64,
100 - reps: i32,
101 - rpe: i32,
124 + load: Load,
125 + reps: Reps,
126 + rpe: Rpe,
102 127 is_diagnostic: bool,
103 128 ) -> Result<i64, Error> {
104 - if !(1..=5).contains(&rpe) {
105 - return Err(Error::InvalidRpe(rpe));
106 - }
107 - if reps < 0 {
108 - return Err(Error::InvalidReps(reps));
109 - }
110 129 let next_index: i32 = self
111 130 .conn()
112 131 .query_row(
@@ -122,9 +141,9 @@ impl Db {
122 141 date.to_string(),
123 142 exercise_id,
124 143 next_index,
125 - load,
126 - reps,
127 - rpe,
144 + load.get(),
145 + reps.get(),
146 + rpe.get(),
128 147 if is_diagnostic { 1 } else { 0 },
129 148 ],
130 149 )?;
@@ -268,7 +287,7 @@ mod tests {
268 287 assert_eq!(list.len(), 3);
269 288 assert_eq!(list[0].set_index, 1);
270 289 assert_eq!(list[1].set_index, 2);
271 - assert_eq!(list[2].rpe, 4);
290 + assert_eq!(list[2].rpe.get(), 4);
272 291 }
273 292
274 293 #[test]
@@ -9,6 +9,8 @@
9 9 //! Every constructor is fallible and returns `crate::Error`. Accessors
10 10 //! return the raw inner value.
11 11
12 + use std::fmt;
13 +
12 14 use crate::error::Error;
13 15
14 16 /// Rate of Perceived Exertion on ripgrow's 1-5 scale. RPE is a
@@ -96,6 +98,30 @@ impl Increment {
96 98 }
97 99 }
98 100
101 + impl fmt::Display for Rpe {
102 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
103 + write!(f, "{}", self.0)
104 + }
105 + }
106 +
107 + impl fmt::Display for Reps {
108 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
109 + write!(f, "{}", self.0)
110 + }
111 + }
112 +
113 + impl fmt::Display for Load {
114 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
115 + write!(f, "{}", self.0)
116 + }
117 + }
118 +
119 + impl fmt::Display for Increment {
120 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
121 + write!(f, "{}", self.0)
122 + }
123 + }
124 +
99 125 /// Weight unit for the load column. Both the profile's default unit and
100 126 /// each exercise's `load_unit` use this type. Timed and distance efforts
101 127 /// have implicit units (seconds, meters) and do not carry a `LoadUnit`.
@@ -122,6 +148,12 @@ impl LoadUnit {
122 148 }
123 149 }
124 150
151 + impl fmt::Display for LoadUnit {
152 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
153 + f.write_str(self.as_str())
154 + }
155 + }
156 +
125 157 /// Wrapper around a value derived from a formula rather than measured.
126 158 /// The `method` field carries a short human-readable label naming the
127 159 /// formula so that UI code can render "e1RM (rpe-adjusted Epley)" instead
@@ -431,9 +431,9 @@ fn load_prior_diagnostic_today(
431 431 list.into_iter()
432 432 .filter(|s| s.is_diagnostic)
433 433 .map(|s: SessionSet| DiagnosticSet {
434 - load: s.load,
435 - reps: s.reps,
436 - rpe: s.rpe,
434 + load: s.load.get(),
435 + reps: s.reps.get(),
436 + rpe: s.rpe.get(),
437 437 })
438 438 .collect()
439 439 }
@@ -505,7 +505,7 @@ fn format_prescription(p: &Prescription, unit: &str) -> String {
505 505 fn direction_glyph(db: &Db, ex: &Exercise, next_load: f64) -> &'static str {
506 506 let sessions = db.list_sessions_for_exercise(ex.id).unwrap_or_default();
507 507 let Some(last) = sessions.last() else { return " " };
508 - let last_top = last.iter().map(|s| s.load).fold(f64::NEG_INFINITY, f64::max);
508 + let last_top = last.iter().map(|s| s.load.get()).fold(f64::NEG_INFINITY, f64::max);
509 509 if next_load > last_top {
510 510 "up"
511 511 } else if next_load < last_top {
@@ -201,8 +201,8 @@ fn render_recent_table(frame: &mut Frame, area: Rect, db: &Db, ex: &Exercise) {
201 201 for session in &recent {
202 202 let date = session[0].session_date;
203 203 let top_load = top_load(session);
204 - let top_reps = session.iter().map(|s| s.reps).max().unwrap_or(0);
205 - let max_rpe = session.iter().map(|s| s.rpe).max().unwrap_or(0);
204 + let top_reps = session.iter().map(|s| s.reps.get()).max().unwrap_or(0);
205 + let max_rpe = session.iter().map(|s| s.rpe.get()).max().unwrap_or(0);
206 206 lines.push(Line::from(format!(
207 207 " {:<10} {:>4} {:>8} {:>4} {:>3}",
208 208 date.format("%Y-%m-%d"),
@@ -228,7 +228,7 @@ fn render_recent_table(frame: &mut Frame, area: Rect, db: &Db, ex: &Exercise) {
228 228 fn top_load(session: &[SessionSet]) -> f64 {
229 229 session
230 230 .iter()
231 - .map(|s| s.load)
231 + .map(|s| s.load.get())
232 232 .fold(f64::NEG_INFINITY, f64::max)
233 233 }
234 234
@@ -445,9 +445,9 @@ mod tests {
445 445 let today = chrono::Local::now().date_naive();
446 446 let list = db.list_sets_for_session(sq, today).unwrap();
447 447 assert_eq!(list.len(), 1);
448 - assert_eq!(list[0].load, 100.0);
449 - assert_eq!(list[0].reps, 5);
450 - assert_eq!(list[0].rpe, 3);
448 + assert_eq!(list[0].load.get(), 100.0);
449 + assert_eq!(list[0].reps.get(), 5);
450 + assert_eq!(list[0].rpe.get(), 3);
451 451 // Pending row should be cleared and focus back on Load.
452 452 let g = screen.grid.as_ref().unwrap();
453 453 assert!(g.pending.load.is_empty());