syntax-workout-core 0.2.0

Workout tree algebra — represent any physical workout as a recursive tree
Documentation
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);
    }

    /// T16: Builder produces valid Workout with streams/laps defaults.
    #[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);
    }
}