Skip to main content

syntax_workout_core/
builder.rs

1use std::collections::BTreeMap;
2
3use crate::execution_mode::ExecutionMode;
4use crate::intensity::Intensity;
5use crate::measure::Measure;
6use crate::node::*;
7use crate::workout::Workout;
8
9pub fn reps(n: u32) -> Measure { Measure::Reps(n) }
10pub fn weight(amount: f64, unit: crate::measure::WeightUnit) -> Measure { Measure::Weight { amount, unit } }
11pub fn distance(amount: f64, unit: crate::measure::DistanceUnit) -> Measure { Measure::Distance { amount, unit } }
12pub fn duration(seconds: f64) -> Measure { Measure::Duration { seconds } }
13pub fn heart_rate(bpm: u32) -> Measure { Measure::HeartRate { bpm } }
14pub fn calories(n: u32) -> Measure { Measure::Calories(n) }
15pub fn rpe(value: f64) -> Intensity { Intensity::RPE(value) }
16pub fn percent_1rm(value: f64) -> Intensity { Intensity::PercentOfMax(value) }
17pub fn rir(n: u32) -> Intensity { Intensity::RIR(n) }
18
19pub fn set(measures: Vec<Measure>, intensity: Option<Intensity>) -> Node {
20    Node {
21        id: NodeId::new(), kind: NodeKind::Set, name: None, children: vec![],
22        payload: NodePayload::Leaf { measures, intensity },
23        metadata: BTreeMap::new(),
24    }
25}
26
27pub fn exercise(name: &str, f: impl FnOnce(&mut ExerciseBuilder)) -> Node {
28    let mut b = ExerciseBuilder { name: name.to_string(), children: vec![], measures: vec![], intensity: None, rest_seconds: None, metadata: BTreeMap::new() };
29    f(&mut b);
30    Node {
31        id: NodeId::new(), kind: NodeKind::Exercise, name: Some(b.name), children: b.children,
32        payload: NodePayload::Exercise { measures: b.measures, intensity: b.intensity, rest_seconds: b.rest_seconds },
33        metadata: b.metadata,
34    }
35}
36
37pub struct ExerciseBuilder {
38    name: String,
39    children: Vec<Node>,
40    measures: Vec<Measure>,
41    intensity: Option<Intensity>,
42    rest_seconds: Option<f64>,
43    metadata: BTreeMap<String, serde_json::Value>,
44}
45
46impl ExerciseBuilder {
47    pub fn set(&mut self, measures: Vec<Measure>, intensity: Option<Intensity>) -> &mut Self {
48        self.children.push(crate::builder::set(measures, intensity));
49        self
50    }
51    pub fn rest(&mut self, seconds: f64) -> &mut Self { self.rest_seconds = Some(seconds); self }
52    pub fn intensity(&mut self, i: Intensity) -> &mut Self { self.intensity = Some(i); self }
53    pub fn meta(&mut self, key: &str, value: serde_json::Value) -> &mut Self { self.metadata.insert(key.into(), value); self }
54}
55
56pub fn block(mode: ExecutionMode, f: impl FnOnce(&mut BlockBuilder)) -> Node {
57    let mut b = BlockBuilder { name: None, children: vec![], rest_seconds: None, metadata: BTreeMap::new() };
58    f(&mut b);
59    Node {
60        id: NodeId::new(), kind: NodeKind::Block, name: b.name, children: b.children,
61        payload: NodePayload::Block { execution_mode: mode, rest_seconds: b.rest_seconds },
62        metadata: b.metadata,
63    }
64}
65
66pub struct BlockBuilder {
67    name: Option<String>,
68    children: Vec<Node>,
69    rest_seconds: Option<f64>,
70    metadata: BTreeMap<String, serde_json::Value>,
71}
72
73impl BlockBuilder {
74    pub fn name(&mut self, name: &str) -> &mut Self { self.name = Some(name.into()); self }
75    pub fn exercise(&mut self, name: &str, f: impl FnOnce(&mut ExerciseBuilder)) -> &mut Self {
76        self.children.push(crate::builder::exercise(name, f));
77        self
78    }
79    pub fn rest(&mut self, seconds: f64) -> &mut Self { self.rest_seconds = Some(seconds); self }
80}
81
82pub fn session(name: &str, f: impl FnOnce(&mut SessionBuilder)) -> Node {
83    let mut b = SessionBuilder { name: name.to_string(), children: vec![], metadata: BTreeMap::new() };
84    f(&mut b);
85    Node {
86        id: NodeId::new(), kind: NodeKind::Session, name: Some(b.name), children: b.children,
87        payload: NodePayload::Temporal { rest_seconds: None },
88        metadata: b.metadata,
89    }
90}
91
92pub struct SessionBuilder {
93    name: String,
94    children: Vec<Node>,
95    metadata: BTreeMap<String, serde_json::Value>,
96}
97
98impl SessionBuilder {
99    pub fn block(&mut self, mode: ExecutionMode, f: impl FnOnce(&mut BlockBuilder)) -> &mut Self {
100        self.children.push(crate::builder::block(mode, f));
101        self
102    }
103    pub fn meta(&mut self, key: &str, value: serde_json::Value) -> &mut Self { self.metadata.insert(key.into(), value); self }
104}
105
106pub struct WorkoutBuilder {
107    id: String,
108    sport: Option<String>,
109    date: Option<String>,
110    root: Option<Node>,
111    metadata: BTreeMap<String, serde_json::Value>,
112}
113
114impl WorkoutBuilder {
115    pub fn new(id: &str) -> Self {
116        Self { id: id.into(), sport: None, date: None, root: None, metadata: BTreeMap::new() }
117    }
118    pub fn sport(mut self, sport: &str) -> Self { self.sport = Some(sport.into()); self }
119    pub fn date(mut self, date: &str) -> Self { self.date = Some(date.into()); self }
120    pub fn root(mut self, node: Node) -> Self { self.root = Some(node); self }
121    pub fn meta(mut self, key: &str, value: serde_json::Value) -> Self { self.metadata.insert(key.into(), value); self }
122    pub fn build(self) -> Workout {
123        Workout {
124            id: self.id,
125            version: Workout::SCHEMA_VERSION.into(),
126            sport: self.sport,
127            date: self.date,
128            root: self.root.expect("WorkoutBuilder requires a root node"),
129            metadata: self.metadata,
130        }
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137    use crate::measure::WeightUnit;
138
139    #[test]
140    fn builder_creates_valid_superset_workout() {
141        let w = WorkoutBuilder::new("test-1")
142            .sport("strength")
143            .date("2026-03-29")
144            .root(session("Upper Hypertrophy", |s| {
145                s.block(ExecutionMode::Parallel, |b| {
146                    b.name("Superset A")
147                     .rest(90.0)
148                     .exercise("DB Row", |e| {
149                         e.rest(0.0)
150                          .set(vec![weight(30.0, WeightUnit::Kg), reps(10)], Some(rpe(8.0)))
151                          .set(vec![weight(30.0, WeightUnit::Kg), reps(10)], Some(rpe(8.0)));
152                     })
153                     .exercise("Incline Press", |e| {
154                         e.rest(0.0)
155                          .set(vec![weight(25.0, WeightUnit::Kg), reps(12)], Some(rpe(7.0)))
156                          .set(vec![weight(25.0, WeightUnit::Kg), reps(12)], Some(rpe(7.0)));
157                     });
158                });
159            }))
160            .build();
161
162        assert_eq!(w.sport, Some("strength".into()));
163        assert_eq!(w.root.children.len(), 1);
164        let blk = &w.root.children[0];
165        assert_eq!(blk.children.len(), 2);
166        assert_eq!(blk.name, Some("Superset A".into()));
167
168        if let NodePayload::Block { execution_mode, rest_seconds } = &blk.payload {
169            assert_eq!(*execution_mode, ExecutionMode::Parallel);
170            assert_eq!(*rest_seconds, Some(90.0));
171        } else {
172            panic!("expected Block payload");
173        }
174
175        assert_eq!(blk.children[0].children.len(), 2);
176        assert_eq!(blk.children[0].name, Some("DB Row".into()));
177
178        let errors = crate::validate::validate(&w.root);
179        assert!(errors.is_empty(), "errors: {:?}", errors);
180    }
181
182    #[test]
183    fn builder_creates_circuit() {
184        let w = WorkoutBuilder::new("circuit-1")
185            .sport("strength")
186            .root(session("Full Body Circuit", |s| {
187                s.block(ExecutionMode::Circuit { rounds: 3 }, |b| {
188                    b.rest(60.0)
189                     .exercise("Squat", |e| {
190                         e.set(vec![weight(60.0, WeightUnit::Kg), reps(15)], None);
191                     })
192                     .exercise("Push-up", |e| {
193                         e.set(vec![reps(20)], Some(Intensity::Bodyweight));
194                     })
195                     .exercise("Plank", |e| {
196                         e.set(vec![duration(60.0)], None);
197                     });
198                });
199            }))
200            .build();
201
202        let blk = &w.root.children[0];
203        if let NodePayload::Block { execution_mode, .. } = &blk.payload {
204            assert_eq!(*execution_mode, ExecutionMode::Circuit { rounds: 3 });
205        } else {
206            panic!("expected Block payload");
207        }
208        assert_eq!(blk.children.len(), 3);
209    }
210}