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