Skip to main content

syntax_workout_core/
node.rs

1use serde::{Deserialize, Serialize};
2use std::collections::BTreeMap;
3use ts_rs::TS;
4use ulid::Ulid;
5
6use crate::execution_mode::ExecutionMode;
7use crate::intensity::Intensity;
8use crate::measure::Measure;
9
10#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, TS)]
11#[ts(export, export_to = "../../../bindings/napi/generated/")]
12pub struct NodeId(pub String);
13
14impl NodeId {
15    pub fn new() -> Self {
16        Self(Ulid::new().to_string())
17    }
18
19    pub fn from_string(s: impl Into<String>) -> Self {
20        Self(s.into())
21    }
22}
23
24impl Default for NodeId {
25    fn default() -> Self {
26        Self::new()
27    }
28}
29
30#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
31#[ts(export, export_to = "../../../bindings/napi/generated/")]
32#[serde(tag = "type", content = "value")]
33pub enum NodeKind {
34    Set,
35    Exercise,
36    Block,
37    Session,
38    Day,
39    Week,
40    Phase,
41    Program,
42    Custom(String),
43}
44
45#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
46#[ts(export, export_to = "../../../bindings/napi/generated/")]
47#[serde(tag = "type")]
48pub enum NodePayload {
49    Leaf {
50        measures: Vec<Measure>,
51        #[serde(skip_serializing_if = "Option::is_none")]
52        intensity: Option<Intensity>,
53    },
54    Exercise {
55        #[serde(default, skip_serializing_if = "Vec::is_empty")]
56        measures: Vec<Measure>,
57        #[serde(skip_serializing_if = "Option::is_none")]
58        intensity: Option<Intensity>,
59        #[serde(skip_serializing_if = "Option::is_none")]
60        rest_seconds: Option<f64>,
61    },
62    Block {
63        execution_mode: ExecutionMode,
64        #[serde(skip_serializing_if = "Option::is_none")]
65        rest_seconds: Option<f64>,
66    },
67    Temporal {
68        #[serde(skip_serializing_if = "Option::is_none")]
69        rest_seconds: Option<f64>,
70    },
71    Custom {
72        #[serde(flatten)]
73        data: BTreeMap<String, serde_json::Value>,
74    },
75}
76
77#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
78#[ts(export, export_to = "../../../bindings/napi/generated/")]
79pub struct Node {
80    pub id: NodeId,
81    pub kind: NodeKind,
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub name: Option<String>,
84    #[serde(default, skip_serializing_if = "Vec::is_empty")]
85    pub children: Vec<Node>,
86    pub payload: NodePayload,
87    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
88    pub metadata: BTreeMap<String, serde_json::Value>,
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94    use crate::measure::{Measure, WeightUnit};
95
96    #[test]
97    fn leaf_node_round_trip() {
98        let node = Node {
99            id: NodeId::from_string("test-set-1"),
100            kind: NodeKind::Set,
101            name: None,
102            children: vec![],
103            payload: NodePayload::Leaf {
104                measures: vec![
105                    Measure::Weight { amount: 80.0, unit: WeightUnit::Kg },
106                    Measure::Reps(8),
107                ],
108                intensity: Some(Intensity::RPE(8.0)),
109            },
110            metadata: BTreeMap::new(),
111        };
112
113        let json = serde_json::to_string_pretty(&node).unwrap();
114        let back: Node = serde_json::from_str(&json).unwrap();
115        assert_eq!(back, node);
116    }
117
118    #[test]
119    fn exercise_with_sets_round_trip() {
120        let set1 = Node {
121            id: NodeId::from_string("set-1"),
122            kind: NodeKind::Set,
123            name: None,
124            children: vec![],
125            payload: NodePayload::Leaf {
126                measures: vec![
127                    Measure::Weight { amount: 100.0, unit: WeightUnit::Kg },
128                    Measure::Reps(5),
129                ],
130                intensity: None,
131            },
132            metadata: BTreeMap::new(),
133        };
134
135        let set2 = Node {
136            id: NodeId::from_string("set-2"),
137            kind: NodeKind::Set,
138            name: None,
139            children: vec![],
140            payload: NodePayload::Leaf {
141                measures: vec![
142                    Measure::Weight { amount: 100.0, unit: WeightUnit::Kg },
143                    Measure::Reps(5),
144                ],
145                intensity: None,
146            },
147            metadata: BTreeMap::new(),
148        };
149
150        let exercise = Node {
151            id: NodeId::from_string("bench-press"),
152            kind: NodeKind::Exercise,
153            name: Some("Bench Press".into()),
154            children: vec![set1, set2],
155            payload: NodePayload::Exercise {
156                measures: vec![],
157                intensity: None,
158                rest_seconds: Some(180.0),
159            },
160            metadata: BTreeMap::new(),
161        };
162
163        let json = serde_json::to_string_pretty(&exercise).unwrap();
164        let back: Node = serde_json::from_str(&json).unwrap();
165        assert_eq!(back.children.len(), 2);
166        assert_eq!(back.name, Some("Bench Press".into()));
167    }
168
169    #[test]
170    fn block_with_superset_round_trip() {
171        let ex1 = Node {
172            id: NodeId::from_string("ex-1"),
173            kind: NodeKind::Exercise,
174            name: Some("DB Row".into()),
175            children: vec![],
176            payload: NodePayload::Exercise {
177                measures: vec![],
178                intensity: Some(Intensity::RPE(8.0)),
179                rest_seconds: None,
180            },
181            metadata: BTreeMap::new(),
182        };
183
184        let ex2 = Node {
185            id: NodeId::from_string("ex-2"),
186            kind: NodeKind::Exercise,
187            name: Some("Incline Press".into()),
188            children: vec![],
189            payload: NodePayload::Exercise {
190                measures: vec![],
191                intensity: Some(Intensity::RPE(7.0)),
192                rest_seconds: None,
193            },
194            metadata: BTreeMap::new(),
195        };
196
197        let block = Node {
198            id: NodeId::from_string("block-b"),
199            kind: NodeKind::Block,
200            name: Some("Superset B".into()),
201            children: vec![ex1, ex2],
202            payload: NodePayload::Block {
203                execution_mode: ExecutionMode::Parallel,
204                rest_seconds: Some(90.0),
205            },
206            metadata: BTreeMap::new(),
207        };
208
209        let json = serde_json::to_string_pretty(&block).unwrap();
210        let back: Node = serde_json::from_str(&json).unwrap();
211        assert_eq!(back.children.len(), 2);
212
213        if let NodePayload::Block { execution_mode, .. } = &back.payload {
214            assert_eq!(*execution_mode, ExecutionMode::Parallel);
215        } else {
216            panic!("expected Block payload");
217        }
218    }
219
220    #[test]
221    fn metadata_round_trip() {
222        let mut metadata = BTreeMap::new();
223        metadata.insert("warmup".into(), serde_json::json!(true));
224
225        let node = Node {
226            id: NodeId::from_string("test"),
227            kind: NodeKind::Set,
228            name: None,
229            children: vec![],
230            payload: NodePayload::Leaf {
231                measures: vec![Measure::Reps(10)],
232                intensity: None,
233            },
234            metadata,
235        };
236
237        let json = serde_json::to_string(&node).unwrap();
238        let back: Node = serde_json::from_str(&json).unwrap();
239        assert_eq!(back.metadata.get("warmup"), Some(&serde_json::json!(true)));
240    }
241}