syntax-workout-core 0.1.0

Workout tree algebra — represent any physical workout as a recursive tree
Documentation
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use ts_rs::TS;

use crate::node::Node;

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
#[ts(export, export_to = "../../../bindings/napi/generated/")]
pub struct Workout {
    pub id: String,
    pub version: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub sport: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub date: Option<String>,
    pub root: Node,
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub metadata: BTreeMap<String, serde_json::Value>,
}

impl Workout {
    pub const SCHEMA_VERSION: &str = "1.0.0";

    pub fn new(id: impl Into<String>, root: Node) -> Self {
        Self {
            id: id.into(),
            version: Self::SCHEMA_VERSION.into(),
            sport: None,
            date: None,
            root,
            metadata: BTreeMap::new(),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::execution_mode::ExecutionMode;
    use crate::measure::{Measure, WeightUnit};
    use crate::node::*;

    fn make_simple_workout() -> Workout {
        let set = Node {
            id: NodeId::from_string("s1"),
            kind: NodeKind::Set,
            name: None,
            children: vec![],
            payload: NodePayload::Leaf {
                measures: vec![
                    Measure::Weight { amount: 60.0, unit: WeightUnit::Kg },
                    Measure::Reps(10),
                ],
                intensity: None,
            },
            metadata: BTreeMap::new(),
        };

        let exercise = Node {
            id: NodeId::from_string("e1"),
            kind: NodeKind::Exercise,
            name: Some("Squat".into()),
            children: vec![set],
            payload: NodePayload::Exercise {
                measures: vec![],
                intensity: None,
                rest_seconds: Some(120.0),
            },
            metadata: BTreeMap::new(),
        };

        let block = Node {
            id: NodeId::from_string("b1"),
            kind: NodeKind::Block,
            name: None,
            children: vec![exercise],
            payload: NodePayload::Block {
                execution_mode: ExecutionMode::Sequential,
                rest_seconds: None,
            },
            metadata: BTreeMap::new(),
        };

        let session = Node {
            id: NodeId::from_string("sess1"),
            kind: NodeKind::Session,
            name: Some("Leg Day".into()),
            children: vec![block],
            payload: NodePayload::Temporal { rest_seconds: None },
            metadata: BTreeMap::new(),
        };

        let mut w = Workout::new("w1", session);
        w.sport = Some("strength".into());
        w.date = Some("2026-03-29".into());
        w
    }

    #[test]
    fn workout_round_trip() {
        let w = make_simple_workout();
        let json = serde_json::to_string_pretty(&w).unwrap();
        let back: Workout = serde_json::from_str(&json).unwrap();
        assert_eq!(back.id, "w1");
        assert_eq!(back.version, "1.0.0");
        assert_eq!(back.sport, Some("strength".into()));
        assert_eq!(back.root.name, Some("Leg Day".into()));
    }

    #[test]
    fn workout_default_version() {
        let node = Node {
            id: NodeId::from_string("r"),
            kind: NodeKind::Session,
            name: None,
            children: vec![],
            payload: NodePayload::Temporal { rest_seconds: None },
            metadata: BTreeMap::new(),
        };
        let w = Workout::new("test", node);
        assert_eq!(w.version, "1.0.0");
    }
}