syntax-workout-core 0.2.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::lap::Lap;
use crate::node::Node;
use crate::stream::Streams;

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

    /// Time-series sensor data (HR, GPS, pace, power, etc.).
    /// Optional — strength-only workouts have no streams.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub streams: Option<Streams>,

    /// Laps / splits / intervals with summary statistics.
    /// Can exist with or without streams — summary-only laps are valid.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub laps: Vec<Lap>,

    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub metadata: BTreeMap<String, serde_json::Value>,
}

impl Workout {
    pub const SCHEMA_VERSION: &str = "1.1.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,
            streams: None,
            laps: vec![],
            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.1.0");
        assert_eq!(back.sport, Some("strength".into()));
        assert_eq!(back.root.name, Some("Leg Day".into()));
        assert!(back.streams.is_none());
        assert!(back.laps.is_empty());
    }

    #[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.1.0");
        assert!(w.streams.is_none());
        assert!(w.laps.is_empty());
    }

    /// T12: v1.0 JSON without streams/laps fields must still deserialize.
    #[test]
    fn v1_json_backwards_compat() {
        let v1_json = r#"{
            "id": "old-workout",
            "version": "1.0.0",
            "sport": "strength",
            "date": "2026-01-01",
            "root": {
                "id": "sess-1",
                "kind": { "type": "Session" },
                "name": "Legacy Workout",
                "children": [],
                "payload": { "type": "Temporal" }
            }
        }"#;
        let w: Workout = serde_json::from_str(v1_json).unwrap();
        assert_eq!(w.id, "old-workout");
        assert_eq!(w.version, "1.0.0");
        assert!(w.streams.is_none());
        assert!(w.laps.is_empty());
    }

    /// T11: Full workout with streams and laps round-trips correctly.
    #[test]
    fn workout_with_streams_and_laps_round_trip() {
        use crate::stream::*;
        use crate::lap::*;

        let mut w = make_simple_workout();
        w.streams = Some(Streams {
            timestamps: vec![0.0, 5.0, 10.0],
            channels: vec![
                Stream {
                    metric: StreamMetric::HeartRate,
                    data: StreamData::Scalar(vec![85.0, 120.0, 135.0]),
                },
            ],
        });

        let mut summary = BTreeMap::new();
        summary.insert(summary_keys::AVG_HR.into(), 113.0);
        w.laps = vec![Lap {
            start_index: Some(0),
            end_index: Some(2),
            summary,
            trigger: LapTrigger::Auto,
            name: Some("Full session".into()),
        }];

        let json = serde_json::to_string_pretty(&w).unwrap();
        let back: Workout = serde_json::from_str(&json).unwrap();
        assert!(back.streams.is_some());
        assert_eq!(back.laps.len(), 1);
        assert_eq!(back.laps[0].start_index, Some(0));
        assert_eq!(back.laps[0].name, Some("Full session".into()));
    }
}