//! Exercise templates and their tag associations. //! //! Exercises carry a name, resistance type, load unit, per-exercise //! increment, and a set of muscle-agnostic tag ids. No target sets or reps; //! prescription is computed at generation time from history. use rusqlite::{OptionalExtension, params}; use crate::db::Db; use crate::effort::EffortKind; use crate::error::Error; use crate::values::LoadUnit; /// Resistance type is a UI hint. It determines how the log screen renders /// input, not how the schema stores rows. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ResistanceType { Bodyweight, Machine, Freeweight, CardioTime, CardioDistance, } impl ResistanceType { pub const ALL: [ResistanceType; 5] = [ ResistanceType::Bodyweight, ResistanceType::Machine, ResistanceType::Freeweight, ResistanceType::CardioTime, ResistanceType::CardioDistance, ]; pub fn as_str(&self) -> &'static str { match self { ResistanceType::Bodyweight => "bodyweight", ResistanceType::Machine => "machine", ResistanceType::Freeweight => "freeweight", ResistanceType::CardioTime => "cardio_time", ResistanceType::CardioDistance => "cardio_distance", } } pub fn parse(s: &str) -> Result { match s { "bodyweight" => Ok(ResistanceType::Bodyweight), "machine" => Ok(ResistanceType::Machine), "freeweight" => Ok(ResistanceType::Freeweight), "cardio_time" => Ok(ResistanceType::CardioTime), "cardio_distance" => Ok(ResistanceType::CardioDistance), other => Err(Error::InvalidResistanceType(other.to_string())), } } } /// Which effort kind a new exercise inherits from its UI-side resistance /// type. Explicit override via `create_exercise_with_kind` will land when /// the template form learns to prompt for it in a later phase-4 slice. pub fn default_effort_kind(rt: ResistanceType) -> EffortKind { match rt { ResistanceType::Bodyweight | ResistanceType::Machine | ResistanceType::Freeweight => EffortKind::Reps, ResistanceType::CardioTime => EffortKind::Timed, ResistanceType::CardioDistance => EffortKind::Distance, } } #[derive(Debug, Clone, PartialEq)] pub struct Tag { pub id: i64, pub name: String, } #[derive(Debug, Clone, PartialEq)] pub struct Exercise { pub id: i64, pub name: String, /// UI hint for the log-screen input shape (dumbbell vs machine vs /// bodyweight, etc.). Orthogonal to the data model, which routes /// through [`EffortKind`]. pub resistance_type: ResistanceType, /// Discriminant on which per-kind sets table holds this exercise's /// history. Every exercise created before phase 4 is `Reps`. pub effort_kind: EffortKind, pub load_unit: LoadUnit, pub increment: f64, pub notes: String, pub tag_ids: Vec, } impl Db { // ---- tags ----------------------------------------------------------- pub fn list_tags(&self) -> Result, Error> { let mut stmt = self .conn() .prepare("SELECT id, name FROM tags ORDER BY name")?; let rows = stmt.query_map([], |row| { Ok(Tag { id: row.get(0)?, name: row.get(1)?, }) })?; Ok(rows.collect::, _>>()?) } /// Insert a tag, or return the existing row if the name already exists. pub fn upsert_tag(&self, name: &str) -> Result { let name = name.trim(); if name.is_empty() { return Err(Error::InvalidTagName); } if let Some(existing) = self .conn() .query_row( "SELECT id, name FROM tags WHERE name = ?1", params![name], |row| { Ok(Tag { id: row.get(0)?, name: row.get(1)?, }) }, ) .optional()? { return Ok(existing); } self.conn() .execute("INSERT INTO tags (name) VALUES (?1)", params![name])?; let id = self.conn().last_insert_rowid(); Ok(Tag { id, name: name.to_string(), }) } // ---- exercises ------------------------------------------------------ pub fn list_exercises(&self) -> Result, Error> { let mut stmt = self.conn().prepare( "SELECT id, name, resistance_type, effort_kind, load_unit, increment, notes \ FROM exercises ORDER BY name", )?; let rows = stmt.query_map([], |row| { Ok(( row.get::<_, i64>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?, row.get::<_, String>(3)?, row.get::<_, String>(4)?, row.get::<_, f64>(5)?, row.get::<_, String>(6)?, )) })?; let mut out = Vec::new(); for row in rows { let (id, name, rt, ek, load_unit, increment, notes) = row?; out.push(Exercise { id, name, resistance_type: ResistanceType::parse(&rt)?, effort_kind: EffortKind::parse(&ek)?, load_unit: LoadUnit::parse(&load_unit)?, increment, notes, tag_ids: self.tag_ids_for(id)?, }); } Ok(out) } fn tag_ids_for(&self, exercise_id: i64) -> Result, Error> { let mut stmt = self.conn().prepare( "SELECT tag_id FROM exercise_tags WHERE exercise_id = ?1 ORDER BY tag_id", )?; let rows = stmt.query_map(params![exercise_id], |row| row.get(0))?; Ok(rows.collect::, _>>()?) } pub fn create_exercise( &self, name: &str, resistance_type: ResistanceType, load_unit: LoadUnit, increment: f64, tag_ids: &[i64], ) -> Result { let name = name.trim(); if name.is_empty() { return Err(Error::InvalidExerciseName); } let effort_kind = default_effort_kind(resistance_type); self.conn().execute( "INSERT INTO exercises \ (name, resistance_type, effort_kind, load_unit, increment) \ VALUES (?1, ?2, ?3, ?4, ?5)", params![ name, resistance_type.as_str(), effort_kind.as_str(), load_unit.as_str(), increment ], )?; let id = self.conn().last_insert_rowid(); self.write_tag_ids(id, tag_ids)?; Ok(id) } pub fn update_exercise( &self, id: i64, name: &str, resistance_type: ResistanceType, load_unit: LoadUnit, increment: f64, tag_ids: &[i64], ) -> Result<(), Error> { let name = name.trim(); if name.is_empty() { return Err(Error::InvalidExerciseName); } let effort_kind = default_effort_kind(resistance_type); let n = self.conn().execute( "UPDATE exercises SET name = ?1, resistance_type = ?2, \ effort_kind = ?3, load_unit = ?4, increment = ?5 WHERE id = ?6", params![ name, resistance_type.as_str(), effort_kind.as_str(), load_unit.as_str(), increment, id ], )?; if n == 0 { return Err(Error::NotFound(format!("exercise {id}"))); } self.write_tag_ids(id, tag_ids)?; Ok(()) } /// Delete an exercise. Errors if any set references it (FK RESTRICT on /// `sets.exercise_id`); the caller should surface that as "log /// history exists; delete blocked". pub fn delete_exercise(&self, id: i64) -> Result<(), Error> { let n = self .conn() .execute("DELETE FROM exercises WHERE id = ?1", params![id])?; if n == 0 { return Err(Error::NotFound(format!("exercise {id}"))); } Ok(()) } fn write_tag_ids(&self, exercise_id: i64, tag_ids: &[i64]) -> Result<(), Error> { self.conn().execute( "DELETE FROM exercise_tags WHERE exercise_id = ?1", params![exercise_id], )?; for tid in tag_ids { self.conn().execute( "INSERT INTO exercise_tags (exercise_id, tag_id) VALUES (?1, ?2)", params![exercise_id, tid], )?; } Ok(()) } } #[cfg(test)] mod tests { use super::*; use crate::values::LoadUnit; fn setup() -> Db { let db = Db::open_in_memory().unwrap(); db.init_profile("self", LoadUnit::Kg).unwrap(); db } #[test] fn tag_upsert_idempotent_and_trims() { let db = setup(); let a = db.upsert_tag("chest").unwrap(); let b = db.upsert_tag(" chest ").unwrap(); assert_eq!(a.id, b.id); assert_eq!(a.name, "chest"); assert_eq!(db.list_tags().unwrap().len(), 1); } #[test] fn tag_upsert_rejects_empty() { let db = setup(); assert!(db.upsert_tag(" ").is_err()); } #[test] fn default_effort_kind_maps_from_resistance_type() { assert_eq!( default_effort_kind(ResistanceType::Freeweight), EffortKind::Reps ); assert_eq!( default_effort_kind(ResistanceType::Machine), EffortKind::Reps ); assert_eq!( default_effort_kind(ResistanceType::Bodyweight), EffortKind::Reps ); assert_eq!( default_effort_kind(ResistanceType::CardioTime), EffortKind::Timed ); assert_eq!( default_effort_kind(ResistanceType::CardioDistance), EffortKind::Distance ); } #[test] fn newly_created_exercise_carries_effort_kind() { let db = setup(); let id = db .create_exercise( "row", ResistanceType::CardioDistance, LoadUnit::Kg, 0.0, &[], ) .unwrap(); let ex = db .list_exercises() .unwrap() .into_iter() .find(|e| e.id == id) .unwrap(); assert_eq!(ex.effort_kind, EffortKind::Distance); } #[test] fn exercise_round_trip_with_tags() { let db = setup(); let t1 = db.upsert_tag("chest").unwrap(); let t2 = db.upsert_tag("triceps").unwrap(); let id = db .create_exercise("bench", ResistanceType::Freeweight, LoadUnit::Kg, 2.5, &[t1.id, t2.id]) .unwrap(); let list = db.list_exercises().unwrap(); assert_eq!(list.len(), 1); let ex = &list[0]; assert_eq!(ex.id, id); assert_eq!(ex.name, "bench"); assert_eq!(ex.resistance_type, ResistanceType::Freeweight); assert_eq!(ex.increment, 2.5); assert_eq!(ex.tag_ids, vec![t1.id, t2.id]); } #[test] fn update_replaces_tag_set() { let db = setup(); let t1 = db.upsert_tag("a").unwrap(); let t2 = db.upsert_tag("b").unwrap(); let t3 = db.upsert_tag("c").unwrap(); let id = db .create_exercise("x", ResistanceType::Machine, LoadUnit::Kg, 5.0, &[t1.id, t2.id]) .unwrap(); db.update_exercise(id, "x", ResistanceType::Machine, LoadUnit::Kg, 5.0, &[t3.id]) .unwrap(); let list = db.list_exercises().unwrap(); assert_eq!(list[0].tag_ids, vec![t3.id]); } #[test] fn delete_removes_exercise_and_tag_links() { let db = setup(); let t = db.upsert_tag("t").unwrap(); let id = db .create_exercise("y", ResistanceType::Bodyweight, LoadUnit::Kg, 0.0, &[t.id]) .unwrap(); db.delete_exercise(id).unwrap(); assert!(db.list_exercises().unwrap().is_empty()); // exercise_tags row should have cascaded away. let n: i64 = db .conn() .query_row("SELECT COUNT(*) FROM exercise_tags", [], |row| row.get(0)) .unwrap(); assert_eq!(n, 0); } #[test] fn delete_blocked_when_sets_exist() { let db = setup(); let id = db .create_exercise("z", ResistanceType::Freeweight, LoadUnit::Kg, 2.5, &[]) .unwrap(); db.conn() .execute( "INSERT INTO reps_sets (session_date, exercise_id, set_index, load, reps, rpe) \ VALUES ('2026-07-18', ?1, 1, 100.0, 5, 3)", params![id], ) .unwrap(); assert!(db.delete_exercise(id).is_err(), "FK RESTRICT should block"); } #[test] fn create_rejects_empty_name_and_duplicate() { let db = setup(); assert!( db.create_exercise(" ", ResistanceType::Machine, LoadUnit::Kg, 5.0, &[]) .is_err() ); db.create_exercise("dup", ResistanceType::Machine, LoadUnit::Kg, 5.0, &[]) .unwrap(); assert!( db.create_exercise("dup", ResistanceType::Machine, LoadUnit::Kg, 5.0, &[]) .is_err(), "UNIQUE(name) should block" ); } }