Skip to main content

max / ripgrow

13.4 KB · 428 lines History Blame Raw
1 //! Exercise templates and their tag associations.
2 //!
3 //! Exercises carry a name, resistance type, load unit, per-exercise
4 //! increment, and a set of muscle-agnostic tag ids. No target sets or reps;
5 //! prescription is computed at generation time from history.
6
7 use rusqlite::{OptionalExtension, params};
8
9 use crate::db::Db;
10 use crate::effort::EffortKind;
11 use crate::error::Error;
12 use crate::values::LoadUnit;
13
14 /// Resistance type is a UI hint. It determines how the log screen renders
15 /// input, not how the schema stores rows.
16 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
17 pub enum ResistanceType {
18 Bodyweight,
19 Machine,
20 Freeweight,
21 CardioTime,
22 CardioDistance,
23 }
24
25 impl ResistanceType {
26 pub const ALL: [ResistanceType; 5] = [
27 ResistanceType::Bodyweight,
28 ResistanceType::Machine,
29 ResistanceType::Freeweight,
30 ResistanceType::CardioTime,
31 ResistanceType::CardioDistance,
32 ];
33
34 pub fn as_str(&self) -> &'static str {
35 match self {
36 ResistanceType::Bodyweight => "bodyweight",
37 ResistanceType::Machine => "machine",
38 ResistanceType::Freeweight => "freeweight",
39 ResistanceType::CardioTime => "cardio_time",
40 ResistanceType::CardioDistance => "cardio_distance",
41 }
42 }
43
44 pub fn parse(s: &str) -> Result<Self, Error> {
45 match s {
46 "bodyweight" => Ok(ResistanceType::Bodyweight),
47 "machine" => Ok(ResistanceType::Machine),
48 "freeweight" => Ok(ResistanceType::Freeweight),
49 "cardio_time" => Ok(ResistanceType::CardioTime),
50 "cardio_distance" => Ok(ResistanceType::CardioDistance),
51 other => Err(Error::InvalidResistanceType(other.to_string())),
52 }
53 }
54 }
55
56 /// Which effort kind a new exercise inherits from its UI-side resistance
57 /// type. Explicit override via `create_exercise_with_kind` will land when
58 /// the template form learns to prompt for it in a later phase-4 slice.
59 pub fn default_effort_kind(rt: ResistanceType) -> EffortKind {
60 match rt {
61 ResistanceType::Bodyweight
62 | ResistanceType::Machine
63 | ResistanceType::Freeweight => EffortKind::Reps,
64 ResistanceType::CardioTime => EffortKind::Timed,
65 ResistanceType::CardioDistance => EffortKind::Distance,
66 }
67 }
68
69 #[derive(Debug, Clone, PartialEq)]
70 pub struct Tag {
71 pub id: i64,
72 pub name: String,
73 }
74
75 #[derive(Debug, Clone, PartialEq)]
76 pub struct Exercise {
77 pub id: i64,
78 pub name: String,
79 /// UI hint for the log-screen input shape (dumbbell vs machine vs
80 /// bodyweight, etc.). Orthogonal to the data model, which routes
81 /// through [`EffortKind`].
82 pub resistance_type: ResistanceType,
83 /// Discriminant on which per-kind sets table holds this exercise's
84 /// history. Every exercise created before phase 4 is `Reps`.
85 pub effort_kind: EffortKind,
86 pub load_unit: LoadUnit,
87 pub increment: f64,
88 pub notes: String,
89 pub tag_ids: Vec<i64>,
90 }
91
92 impl Db {
93 // ---- tags -----------------------------------------------------------
94
95 pub fn list_tags(&self) -> Result<Vec<Tag>, Error> {
96 let mut stmt = self
97 .conn()
98 .prepare("SELECT id, name FROM tags ORDER BY name")?;
99 let rows = stmt.query_map([], |row| {
100 Ok(Tag {
101 id: row.get(0)?,
102 name: row.get(1)?,
103 })
104 })?;
105 Ok(rows.collect::<Result<Vec<_>, _>>()?)
106 }
107
108 /// Insert a tag, or return the existing row if the name already exists.
109 pub fn upsert_tag(&self, name: &str) -> Result<Tag, Error> {
110 let name = name.trim();
111 if name.is_empty() {
112 return Err(Error::InvalidTagName);
113 }
114 if let Some(existing) = self
115 .conn()
116 .query_row(
117 "SELECT id, name FROM tags WHERE name = ?1",
118 params![name],
119 |row| {
120 Ok(Tag {
121 id: row.get(0)?,
122 name: row.get(1)?,
123 })
124 },
125 )
126 .optional()?
127 {
128 return Ok(existing);
129 }
130 self.conn()
131 .execute("INSERT INTO tags (name) VALUES (?1)", params![name])?;
132 let id = self.conn().last_insert_rowid();
133 Ok(Tag {
134 id,
135 name: name.to_string(),
136 })
137 }
138
139 // ---- exercises ------------------------------------------------------
140
141 pub fn list_exercises(&self) -> Result<Vec<Exercise>, Error> {
142 let mut stmt = self.conn().prepare(
143 "SELECT id, name, resistance_type, effort_kind, load_unit, increment, notes \
144 FROM exercises ORDER BY name",
145 )?;
146 let rows = stmt.query_map([], |row| {
147 Ok((
148 row.get::<_, i64>(0)?,
149 row.get::<_, String>(1)?,
150 row.get::<_, String>(2)?,
151 row.get::<_, String>(3)?,
152 row.get::<_, String>(4)?,
153 row.get::<_, f64>(5)?,
154 row.get::<_, String>(6)?,
155 ))
156 })?;
157
158 let mut out = Vec::new();
159 for row in rows {
160 let (id, name, rt, ek, load_unit, increment, notes) = row?;
161 out.push(Exercise {
162 id,
163 name,
164 resistance_type: ResistanceType::parse(&rt)?,
165 effort_kind: EffortKind::parse(&ek)?,
166 load_unit: LoadUnit::parse(&load_unit)?,
167 increment,
168 notes,
169 tag_ids: self.tag_ids_for(id)?,
170 });
171 }
172 Ok(out)
173 }
174
175 fn tag_ids_for(&self, exercise_id: i64) -> Result<Vec<i64>, Error> {
176 let mut stmt = self.conn().prepare(
177 "SELECT tag_id FROM exercise_tags WHERE exercise_id = ?1 ORDER BY tag_id",
178 )?;
179 let rows = stmt.query_map(params![exercise_id], |row| row.get(0))?;
180 Ok(rows.collect::<Result<Vec<_>, _>>()?)
181 }
182
183 pub fn create_exercise(
184 &self,
185 name: &str,
186 resistance_type: ResistanceType,
187 load_unit: LoadUnit,
188 increment: f64,
189 tag_ids: &[i64],
190 ) -> Result<i64, Error> {
191 let name = name.trim();
192 if name.is_empty() {
193 return Err(Error::InvalidExerciseName);
194 }
195 let effort_kind = default_effort_kind(resistance_type);
196 self.conn().execute(
197 "INSERT INTO exercises \
198 (name, resistance_type, effort_kind, load_unit, increment) \
199 VALUES (?1, ?2, ?3, ?4, ?5)",
200 params![
201 name,
202 resistance_type.as_str(),
203 effort_kind.as_str(),
204 load_unit.as_str(),
205 increment
206 ],
207 )?;
208 let id = self.conn().last_insert_rowid();
209 self.write_tag_ids(id, tag_ids)?;
210 Ok(id)
211 }
212
213 pub fn update_exercise(
214 &self,
215 id: i64,
216 name: &str,
217 resistance_type: ResistanceType,
218 load_unit: LoadUnit,
219 increment: f64,
220 tag_ids: &[i64],
221 ) -> Result<(), Error> {
222 let name = name.trim();
223 if name.is_empty() {
224 return Err(Error::InvalidExerciseName);
225 }
226 let effort_kind = default_effort_kind(resistance_type);
227 let n = self.conn().execute(
228 "UPDATE exercises SET name = ?1, resistance_type = ?2, \
229 effort_kind = ?3, load_unit = ?4, increment = ?5 WHERE id = ?6",
230 params![
231 name,
232 resistance_type.as_str(),
233 effort_kind.as_str(),
234 load_unit.as_str(),
235 increment,
236 id
237 ],
238 )?;
239 if n == 0 {
240 return Err(Error::NotFound(format!("exercise {id}")));
241 }
242 self.write_tag_ids(id, tag_ids)?;
243 Ok(())
244 }
245
246 /// Delete an exercise. Errors if any set references it (FK RESTRICT on
247 /// `sets.exercise_id`); the caller should surface that as "log
248 /// history exists; delete blocked".
249 pub fn delete_exercise(&self, id: i64) -> Result<(), Error> {
250 let n = self
251 .conn()
252 .execute("DELETE FROM exercises WHERE id = ?1", params![id])?;
253 if n == 0 {
254 return Err(Error::NotFound(format!("exercise {id}")));
255 }
256 Ok(())
257 }
258
259 fn write_tag_ids(&self, exercise_id: i64, tag_ids: &[i64]) -> Result<(), Error> {
260 self.conn().execute(
261 "DELETE FROM exercise_tags WHERE exercise_id = ?1",
262 params![exercise_id],
263 )?;
264 for tid in tag_ids {
265 self.conn().execute(
266 "INSERT INTO exercise_tags (exercise_id, tag_id) VALUES (?1, ?2)",
267 params![exercise_id, tid],
268 )?;
269 }
270 Ok(())
271 }
272 }
273
274 #[cfg(test)]
275 mod tests {
276 use super::*;
277 use crate::values::LoadUnit;
278
279 fn setup() -> Db {
280 let db = Db::open_in_memory().unwrap();
281 db.init_profile("self", LoadUnit::Kg).unwrap();
282 db
283 }
284
285 #[test]
286 fn tag_upsert_idempotent_and_trims() {
287 let db = setup();
288 let a = db.upsert_tag("chest").unwrap();
289 let b = db.upsert_tag(" chest ").unwrap();
290 assert_eq!(a.id, b.id);
291 assert_eq!(a.name, "chest");
292 assert_eq!(db.list_tags().unwrap().len(), 1);
293 }
294
295 #[test]
296 fn tag_upsert_rejects_empty() {
297 let db = setup();
298 assert!(db.upsert_tag(" ").is_err());
299 }
300
301 #[test]
302 fn default_effort_kind_maps_from_resistance_type() {
303 assert_eq!(
304 default_effort_kind(ResistanceType::Freeweight),
305 EffortKind::Reps
306 );
307 assert_eq!(
308 default_effort_kind(ResistanceType::Machine),
309 EffortKind::Reps
310 );
311 assert_eq!(
312 default_effort_kind(ResistanceType::Bodyweight),
313 EffortKind::Reps
314 );
315 assert_eq!(
316 default_effort_kind(ResistanceType::CardioTime),
317 EffortKind::Timed
318 );
319 assert_eq!(
320 default_effort_kind(ResistanceType::CardioDistance),
321 EffortKind::Distance
322 );
323 }
324
325 #[test]
326 fn newly_created_exercise_carries_effort_kind() {
327 let db = setup();
328 let id = db
329 .create_exercise(
330 "row",
331 ResistanceType::CardioDistance,
332 LoadUnit::Kg,
333 0.0,
334 &[],
335 )
336 .unwrap();
337 let ex = db
338 .list_exercises()
339 .unwrap()
340 .into_iter()
341 .find(|e| e.id == id)
342 .unwrap();
343 assert_eq!(ex.effort_kind, EffortKind::Distance);
344 }
345
346 #[test]
347 fn exercise_round_trip_with_tags() {
348 let db = setup();
349 let t1 = db.upsert_tag("chest").unwrap();
350 let t2 = db.upsert_tag("triceps").unwrap();
351 let id = db
352 .create_exercise("bench", ResistanceType::Freeweight, LoadUnit::Kg, 2.5, &[t1.id, t2.id])
353 .unwrap();
354 let list = db.list_exercises().unwrap();
355 assert_eq!(list.len(), 1);
356 let ex = &list[0];
357 assert_eq!(ex.id, id);
358 assert_eq!(ex.name, "bench");
359 assert_eq!(ex.resistance_type, ResistanceType::Freeweight);
360 assert_eq!(ex.increment, 2.5);
361 assert_eq!(ex.tag_ids, vec![t1.id, t2.id]);
362 }
363
364 #[test]
365 fn update_replaces_tag_set() {
366 let db = setup();
367 let t1 = db.upsert_tag("a").unwrap();
368 let t2 = db.upsert_tag("b").unwrap();
369 let t3 = db.upsert_tag("c").unwrap();
370 let id = db
371 .create_exercise("x", ResistanceType::Machine, LoadUnit::Kg, 5.0, &[t1.id, t2.id])
372 .unwrap();
373 db.update_exercise(id, "x", ResistanceType::Machine, LoadUnit::Kg, 5.0, &[t3.id])
374 .unwrap();
375 let list = db.list_exercises().unwrap();
376 assert_eq!(list[0].tag_ids, vec![t3.id]);
377 }
378
379 #[test]
380 fn delete_removes_exercise_and_tag_links() {
381 let db = setup();
382 let t = db.upsert_tag("t").unwrap();
383 let id = db
384 .create_exercise("y", ResistanceType::Bodyweight, LoadUnit::Kg, 0.0, &[t.id])
385 .unwrap();
386 db.delete_exercise(id).unwrap();
387 assert!(db.list_exercises().unwrap().is_empty());
388 // exercise_tags row should have cascaded away.
389 let n: i64 = db
390 .conn()
391 .query_row("SELECT COUNT(*) FROM exercise_tags", [], |row| row.get(0))
392 .unwrap();
393 assert_eq!(n, 0);
394 }
395
396 #[test]
397 fn delete_blocked_when_sets_exist() {
398 let db = setup();
399 let id = db
400 .create_exercise("z", ResistanceType::Freeweight, LoadUnit::Kg, 2.5, &[])
401 .unwrap();
402 db.conn()
403 .execute(
404 "INSERT INTO reps_sets (session_date, exercise_id, set_index, load, reps, rpe) \
405 VALUES ('2026-07-18', ?1, 1, 100.0, 5, 3)",
406 params![id],
407 )
408 .unwrap();
409 assert!(db.delete_exercise(id).is_err(), "FK RESTRICT should block");
410 }
411
412 #[test]
413 fn create_rejects_empty_name_and_duplicate() {
414 let db = setup();
415 assert!(
416 db.create_exercise(" ", ResistanceType::Machine, LoadUnit::Kg, 5.0, &[])
417 .is_err()
418 );
419 db.create_exercise("dup", ResistanceType::Machine, LoadUnit::Kg, 5.0, &[])
420 .unwrap();
421 assert!(
422 db.create_exercise("dup", ResistanceType::Machine, LoadUnit::Kg, 5.0, &[])
423 .is_err(),
424 "UNIQUE(name) should block"
425 );
426 }
427 }
428