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 #[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}