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}