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 ulid::Ulid;

use crate::execution_mode::ExecutionMode;
use crate::intensity::Intensity;
use crate::measure::Measure;

#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, TS)]
#[ts(export, export_to = "../../../bindings/napi/generated/")]
pub struct NodeId(pub String);

impl NodeId {
    pub fn new() -> Self {
        Self(Ulid::new().to_string())
    }

    pub fn from_string(s: impl Into<String>) -> Self {
        Self(s.into())
    }
}

impl Default for NodeId {
    fn default() -> Self {
        Self::new()
    }
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
#[ts(export, export_to = "../../../bindings/napi/generated/")]
#[serde(tag = "type", content = "value")]
pub enum NodeKind {
    Set,
    Exercise,
    Block,
    Session,
    Day,
    Week,
    Phase,
    Program,
    Custom(String),
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
#[ts(export, export_to = "../../../bindings/napi/generated/")]
#[serde(tag = "type")]
pub enum NodePayload {
    Leaf {
        measures: Vec<Measure>,
        #[serde(skip_serializing_if = "Option::is_none")]
        intensity: Option<Intensity>,
    },
    Exercise {
        #[serde(default, skip_serializing_if = "Vec::is_empty")]
        measures: Vec<Measure>,
        #[serde(skip_serializing_if = "Option::is_none")]
        intensity: Option<Intensity>,
        #[serde(skip_serializing_if = "Option::is_none")]
        rest_seconds: Option<f64>,
    },
    Block {
        execution_mode: ExecutionMode,
        #[serde(skip_serializing_if = "Option::is_none")]
        rest_seconds: Option<f64>,
    },
    Temporal {
        #[serde(skip_serializing_if = "Option::is_none")]
        rest_seconds: Option<f64>,
    },
    Custom {
        #[serde(flatten)]
        data: BTreeMap<String, serde_json::Value>,
    },
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
#[ts(export, export_to = "../../../bindings/napi/generated/")]
pub struct Node {
    pub id: NodeId,
    pub kind: NodeKind,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub name: Option<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub children: Vec<Node>,
    pub payload: NodePayload,
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub metadata: BTreeMap<String, serde_json::Value>,
}

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

    #[test]
    fn leaf_node_round_trip() {
        let node = Node {
            id: NodeId::from_string("test-set-1"),
            kind: NodeKind::Set,
            name: None,
            children: vec![],
            payload: NodePayload::Leaf {
                measures: vec![
                    Measure::Weight { amount: 80.0, unit: WeightUnit::Kg },
                    Measure::Reps(8),
                ],
                intensity: Some(Intensity::RPE(8.0)),
            },
            metadata: BTreeMap::new(),
        };

        let json = serde_json::to_string_pretty(&node).unwrap();
        let back: Node = serde_json::from_str(&json).unwrap();
        assert_eq!(back, node);
    }

    #[test]
    fn exercise_with_sets_round_trip() {
        let set1 = Node {
            id: NodeId::from_string("set-1"),
            kind: NodeKind::Set,
            name: None,
            children: vec![],
            payload: NodePayload::Leaf {
                measures: vec![
                    Measure::Weight { amount: 100.0, unit: WeightUnit::Kg },
                    Measure::Reps(5),
                ],
                intensity: None,
            },
            metadata: BTreeMap::new(),
        };

        let set2 = Node {
            id: NodeId::from_string("set-2"),
            kind: NodeKind::Set,
            name: None,
            children: vec![],
            payload: NodePayload::Leaf {
                measures: vec![
                    Measure::Weight { amount: 100.0, unit: WeightUnit::Kg },
                    Measure::Reps(5),
                ],
                intensity: None,
            },
            metadata: BTreeMap::new(),
        };

        let exercise = Node {
            id: NodeId::from_string("bench-press"),
            kind: NodeKind::Exercise,
            name: Some("Bench Press".into()),
            children: vec![set1, set2],
            payload: NodePayload::Exercise {
                measures: vec![],
                intensity: None,
                rest_seconds: Some(180.0),
            },
            metadata: BTreeMap::new(),
        };

        let json = serde_json::to_string_pretty(&exercise).unwrap();
        let back: Node = serde_json::from_str(&json).unwrap();
        assert_eq!(back.children.len(), 2);
        assert_eq!(back.name, Some("Bench Press".into()));
    }

    #[test]
    fn block_with_superset_round_trip() {
        let ex1 = Node {
            id: NodeId::from_string("ex-1"),
            kind: NodeKind::Exercise,
            name: Some("DB Row".into()),
            children: vec![],
            payload: NodePayload::Exercise {
                measures: vec![],
                intensity: Some(Intensity::RPE(8.0)),
                rest_seconds: None,
            },
            metadata: BTreeMap::new(),
        };

        let ex2 = Node {
            id: NodeId::from_string("ex-2"),
            kind: NodeKind::Exercise,
            name: Some("Incline Press".into()),
            children: vec![],
            payload: NodePayload::Exercise {
                measures: vec![],
                intensity: Some(Intensity::RPE(7.0)),
                rest_seconds: None,
            },
            metadata: BTreeMap::new(),
        };

        let block = Node {
            id: NodeId::from_string("block-b"),
            kind: NodeKind::Block,
            name: Some("Superset B".into()),
            children: vec![ex1, ex2],
            payload: NodePayload::Block {
                execution_mode: ExecutionMode::Parallel,
                rest_seconds: Some(90.0),
            },
            metadata: BTreeMap::new(),
        };

        let json = serde_json::to_string_pretty(&block).unwrap();
        let back: Node = serde_json::from_str(&json).unwrap();
        assert_eq!(back.children.len(), 2);

        if let NodePayload::Block { execution_mode, .. } = &back.payload {
            assert_eq!(*execution_mode, ExecutionMode::Parallel);
        } else {
            panic!("expected Block payload");
        }
    }

    #[test]
    fn metadata_round_trip() {
        let mut metadata = BTreeMap::new();
        metadata.insert("warmup".into(), serde_json::json!(true));

        let node = Node {
            id: NodeId::from_string("test"),
            kind: NodeKind::Set,
            name: None,
            children: vec![],
            payload: NodePayload::Leaf {
                measures: vec![Measure::Reps(10)],
                intensity: None,
            },
            metadata,
        };

        let json = serde_json::to_string(&node).unwrap();
        let back: Node = serde_json::from_str(&json).unwrap();
        assert_eq!(back.metadata.get("warmup"), Some(&serde_json::json!(true)));
    }
}