use std::collections::BTreeMap;
use crate::execution_mode::ExecutionMode;
use crate::intensity::Intensity;
use crate::lap::Lap;
use crate::measure::Measure;
use crate::node::*;
use crate::stream::Streams;
use crate::workout::Workout;
pub fn reps(n: u32) -> Measure { Measure::Reps(n) }
pub fn weight(amount: f64, unit: crate::measure::WeightUnit) -> Measure { Measure::Weight { amount, unit } }
pub fn distance(amount: f64, unit: crate::measure::DistanceUnit) -> Measure { Measure::Distance { amount, unit } }
pub fn duration(seconds: f64) -> Measure { Measure::Duration { seconds } }
pub fn heart_rate(bpm: u32) -> Measure { Measure::HeartRate { bpm } }
pub fn calories(n: u32) -> Measure { Measure::Calories(n) }
pub fn rpe(value: f64) -> Intensity { Intensity::RPE(value) }
pub fn percent_1rm(value: f64) -> Intensity { Intensity::PercentOfMax(value) }
pub fn rir(n: u32) -> Intensity { Intensity::RIR(n) }
pub fn set(measures: Vec<Measure>, intensity: Option<Intensity>) -> Node {
Node {
id: NodeId::new(), kind: NodeKind::Set, name: None, children: vec![],
payload: NodePayload::Leaf { measures, intensity },
metadata: BTreeMap::new(),
}
}
pub fn exercise(name: &str, f: impl FnOnce(&mut ExerciseBuilder)) -> Node {
let mut b = ExerciseBuilder { name: name.to_string(), children: vec![], measures: vec![], intensity: None, rest_seconds: None, metadata: BTreeMap::new() };
f(&mut b);
Node {
id: NodeId::new(), kind: NodeKind::Exercise, name: Some(b.name), children: b.children,
payload: NodePayload::Exercise { measures: b.measures, intensity: b.intensity, rest_seconds: b.rest_seconds },
metadata: b.metadata,
}
}
pub struct ExerciseBuilder {
name: String,
children: Vec<Node>,
measures: Vec<Measure>,
intensity: Option<Intensity>,
rest_seconds: Option<f64>,
metadata: BTreeMap<String, serde_json::Value>,
}
impl ExerciseBuilder {
pub fn set(&mut self, measures: Vec<Measure>, intensity: Option<Intensity>) -> &mut Self {
self.children.push(crate::builder::set(measures, intensity));
self
}
pub fn rest(&mut self, seconds: f64) -> &mut Self { self.rest_seconds = Some(seconds); self }
pub fn intensity(&mut self, i: Intensity) -> &mut Self { self.intensity = Some(i); self }
pub fn meta(&mut self, key: &str, value: serde_json::Value) -> &mut Self { self.metadata.insert(key.into(), value); self }
}
pub fn block(mode: ExecutionMode, f: impl FnOnce(&mut BlockBuilder)) -> Node {
let mut b = BlockBuilder { name: None, children: vec![], rest_seconds: None, metadata: BTreeMap::new() };
f(&mut b);
Node {
id: NodeId::new(), kind: NodeKind::Block, name: b.name, children: b.children,
payload: NodePayload::Block { execution_mode: mode, rest_seconds: b.rest_seconds },
metadata: b.metadata,
}
}
pub struct BlockBuilder {
name: Option<String>,
children: Vec<Node>,
rest_seconds: Option<f64>,
metadata: BTreeMap<String, serde_json::Value>,
}
impl BlockBuilder {
pub fn name(&mut self, name: &str) -> &mut Self { self.name = Some(name.into()); self }
pub fn exercise(&mut self, name: &str, f: impl FnOnce(&mut ExerciseBuilder)) -> &mut Self {
self.children.push(crate::builder::exercise(name, f));
self
}
pub fn rest(&mut self, seconds: f64) -> &mut Self { self.rest_seconds = Some(seconds); self }
}
pub fn session(name: &str, f: impl FnOnce(&mut SessionBuilder)) -> Node {
let mut b = SessionBuilder { name: name.to_string(), children: vec![], metadata: BTreeMap::new() };
f(&mut b);
Node {
id: NodeId::new(), kind: NodeKind::Session, name: Some(b.name), children: b.children,
payload: NodePayload::Temporal { rest_seconds: None },
metadata: b.metadata,
}
}
pub struct SessionBuilder {
name: String,
children: Vec<Node>,
metadata: BTreeMap<String, serde_json::Value>,
}
impl SessionBuilder {
pub fn block(&mut self, mode: ExecutionMode, f: impl FnOnce(&mut BlockBuilder)) -> &mut Self {
self.children.push(crate::builder::block(mode, f));
self
}
pub fn meta(&mut self, key: &str, value: serde_json::Value) -> &mut Self { self.metadata.insert(key.into(), value); self }
}
pub struct WorkoutBuilder {
id: String,
sport: Option<String>,
date: Option<String>,
root: Option<Node>,
streams: Option<Streams>,
laps: Vec<Lap>,
metadata: BTreeMap<String, serde_json::Value>,
}
impl WorkoutBuilder {
pub fn new(id: &str) -> Self {
Self { id: id.into(), sport: None, date: None, root: None, streams: None, laps: vec![], metadata: BTreeMap::new() }
}
pub fn sport(mut self, sport: &str) -> Self { self.sport = Some(sport.into()); self }
pub fn date(mut self, date: &str) -> Self { self.date = Some(date.into()); self }
pub fn root(mut self, node: Node) -> Self { self.root = Some(node); self }
pub fn streams(mut self, streams: Streams) -> Self { self.streams = Some(streams); self }
pub fn laps(mut self, laps: Vec<Lap>) -> Self { self.laps = laps; self }
pub fn meta(mut self, key: &str, value: serde_json::Value) -> Self { self.metadata.insert(key.into(), value); self }
pub fn build(self) -> Workout {
Workout {
id: self.id,
version: Workout::SCHEMA_VERSION.into(),
sport: self.sport,
date: self.date,
root: self.root.expect("WorkoutBuilder requires a root node"),
streams: self.streams,
laps: self.laps,
metadata: self.metadata,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::measure::WeightUnit;
#[test]
fn builder_creates_valid_superset_workout() {
let w = WorkoutBuilder::new("test-1")
.sport("strength")
.date("2026-03-29")
.root(session("Upper Hypertrophy", |s| {
s.block(ExecutionMode::Parallel, |b| {
b.name("Superset A")
.rest(90.0)
.exercise("DB Row", |e| {
e.rest(0.0)
.set(vec![weight(30.0, WeightUnit::Kg), reps(10)], Some(rpe(8.0)))
.set(vec![weight(30.0, WeightUnit::Kg), reps(10)], Some(rpe(8.0)));
})
.exercise("Incline Press", |e| {
e.rest(0.0)
.set(vec![weight(25.0, WeightUnit::Kg), reps(12)], Some(rpe(7.0)))
.set(vec![weight(25.0, WeightUnit::Kg), reps(12)], Some(rpe(7.0)));
});
});
}))
.build();
assert_eq!(w.sport, Some("strength".into()));
assert_eq!(w.root.children.len(), 1);
let blk = &w.root.children[0];
assert_eq!(blk.children.len(), 2);
assert_eq!(blk.name, Some("Superset A".into()));
if let NodePayload::Block { execution_mode, rest_seconds } = &blk.payload {
assert_eq!(*execution_mode, ExecutionMode::Parallel);
assert_eq!(*rest_seconds, Some(90.0));
} else {
panic!("expected Block payload");
}
assert_eq!(blk.children[0].children.len(), 2);
assert_eq!(blk.children[0].name, Some("DB Row".into()));
let errors = crate::validate::validate(&w.root);
assert!(errors.is_empty(), "errors: {:?}", errors);
}
#[test]
fn builder_defaults_streams_and_laps() {
let w = WorkoutBuilder::new("test-defaults")
.sport("running")
.root(session("Easy Run", |s| {
s.block(ExecutionMode::Sequential, |b| {
b.exercise("run", |e| {
e.set(vec![distance(5.0, crate::measure::DistanceUnit::Kilometers)], None);
});
});
}))
.build();
assert!(w.streams.is_none());
assert!(w.laps.is_empty());
assert_eq!(w.version, "1.1.0");
}
#[test]
fn builder_creates_circuit() {
let w = WorkoutBuilder::new("circuit-1")
.sport("strength")
.root(session("Full Body Circuit", |s| {
s.block(ExecutionMode::Circuit { rounds: 3 }, |b| {
b.rest(60.0)
.exercise("Squat", |e| {
e.set(vec![weight(60.0, WeightUnit::Kg), reps(15)], None);
})
.exercise("Push-up", |e| {
e.set(vec![reps(20)], Some(Intensity::Bodyweight));
})
.exercise("Plank", |e| {
e.set(vec![duration(60.0)], None);
});
});
}))
.build();
let blk = &w.root.children[0];
if let NodePayload::Block { execution_mode, .. } = &blk.payload {
assert_eq!(*execution_mode, ExecutionMode::Circuit { rounds: 3 });
} else {
panic!("expected Block payload");
}
assert_eq!(blk.children.len(), 3);
}
}