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}