1use serde::{Deserialize, Serialize};
2use ts_rs::TS;
3
4use crate::node::{Node, NodeKind, NodePayload};
5use crate::visit::{Visit, walk_node};
6
7#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
8#[ts(export, export_to = "../../../bindings/napi/generated/")]
9pub struct ValidationError {
10 pub node_id: String,
11 pub message: String,
12}
13
14pub fn validate(root: &Node) -> Vec<ValidationError> {
15 let mut v = Validator { errors: vec![] };
16 v.visit_tree(root);
17 v.errors
18}
19
20struct Validator {
21 errors: Vec<ValidationError>,
22}
23
24impl Visit for Validator {
25 fn visit_node(&mut self, node: &Node, ancestors: &[&Node]) {
26 let kind_payload_ok = matches!(
27 (&node.kind, &node.payload),
28 (NodeKind::Set, NodePayload::Leaf { .. })
29 | (NodeKind::Exercise, NodePayload::Exercise { .. })
30 | (NodeKind::Block, NodePayload::Block { .. })
31 | (NodeKind::Session, NodePayload::Temporal { .. })
32 | (NodeKind::Day, NodePayload::Temporal { .. })
33 | (NodeKind::Week, NodePayload::Temporal { .. })
34 | (NodeKind::Phase, NodePayload::Temporal { .. })
35 | (NodeKind::Program, NodePayload::Temporal { .. })
36 | (NodeKind::Custom(_), NodePayload::Custom { .. })
37 );
38
39 if !kind_payload_ok {
40 self.errors.push(ValidationError {
41 node_id: node.id.0.clone(),
42 message: format!("NodeKind {:?} does not match payload variant", node.kind),
43 });
44 }
45
46 if let NodePayload::Leaf { measures, .. } = &node.payload
47 && measures.is_empty()
48 {
49 self.errors.push(ValidationError {
50 node_id: node.id.0.clone(),
51 message: "Set node must have at least one measure".into(),
52 });
53 }
54
55 if let NodePayload::Block { .. } = &node.payload
56 && node.children.is_empty()
57 {
58 self.errors.push(ValidationError {
59 node_id: node.id.0.clone(),
60 message: "Block node must have at least one child".into(),
61 });
62 }
63
64 if let NodePayload::Block { .. } = &node.payload {
65 for child in &node.children {
66 if matches!(child.kind, NodeKind::Set) {
67 self.errors.push(ValidationError {
68 node_id: child.id.0.clone(),
69 message: "Set should not be a direct child of Block (wrap in Exercise)".into(),
70 });
71 }
72 }
73 }
74
75 if let NodePayload::Exercise { .. } = &node.payload {
76 for child in &node.children {
77 if matches!(child.kind, NodeKind::Block | NodeKind::Session) {
78 self.errors.push(ValidationError {
79 node_id: child.id.0.clone(),
80 message: format!("{:?} should not be a child of Exercise", child.kind),
81 });
82 }
83 }
84 }
85
86 walk_node(self, node, ancestors);
87 }
88}
89
90#[cfg(test)]
91mod tests {
92 use super::*;
93 use crate::execution_mode::ExecutionMode;
94 use crate::measure::Measure;
95 use crate::node::*;
96 use std::collections::BTreeMap;
97
98 fn leaf(id: &str, measures: Vec<Measure>) -> Node {
99 Node {
100 id: NodeId::from_string(id), kind: NodeKind::Set, name: None, children: vec![],
101 payload: NodePayload::Leaf { measures, intensity: None },
102 metadata: BTreeMap::new(),
103 }
104 }
105
106 fn exercise(id: &str, name: &str, children: Vec<Node>) -> Node {
107 Node {
108 id: NodeId::from_string(id), kind: NodeKind::Exercise, name: Some(name.into()),
109 children,
110 payload: NodePayload::Exercise { measures: vec![], intensity: None, rest_seconds: None },
111 metadata: BTreeMap::new(),
112 }
113 }
114
115 fn block(id: &str, mode: ExecutionMode, children: Vec<Node>) -> Node {
116 Node {
117 id: NodeId::from_string(id), kind: NodeKind::Block, name: None,
118 children,
119 payload: NodePayload::Block { execution_mode: mode, rest_seconds: None },
120 metadata: BTreeMap::new(),
121 }
122 }
123
124 #[test]
125 fn valid_tree_has_no_errors() {
126 let s = leaf("s1", vec![Measure::Reps(10)]);
127 let e = exercise("e1", "Squat", vec![s]);
128 let b = block("b1", ExecutionMode::Sequential, vec![e]);
129 let errors = validate(&b);
130 assert!(errors.is_empty(), "expected no errors, got: {:?}", errors);
131 }
132
133 #[test]
134 fn empty_leaf_is_invalid() {
135 let s = leaf("s1", vec![]);
136 let errors = validate(&s);
137 assert_eq!(errors.len(), 1);
138 assert!(errors[0].message.contains("at least one measure"));
139 }
140
141 #[test]
142 fn empty_block_is_invalid() {
143 let b = block("b1", ExecutionMode::Sequential, vec![]);
144 let errors = validate(&b);
145 assert_eq!(errors.len(), 1);
146 assert!(errors[0].message.contains("at least one child"));
147 }
148
149 #[test]
150 fn set_directly_in_block_is_invalid() {
151 let s = leaf("s1", vec![Measure::Reps(10)]);
152 let b = block("b1", ExecutionMode::Sequential, vec![s]);
153 let errors = validate(&b);
154 assert_eq!(errors.len(), 1);
155 assert!(errors[0].message.contains("wrap in Exercise"));
156 }
157
158 #[test]
159 fn mismatched_kind_payload_is_invalid() {
160 let node = Node {
161 id: NodeId::from_string("bad"), kind: NodeKind::Set, name: None, children: vec![],
162 payload: NodePayload::Block { execution_mode: ExecutionMode::Sequential, rest_seconds: None },
163 metadata: BTreeMap::new(),
164 };
165 let errors = validate(&node);
166 assert!(errors.iter().any(|e| e.message.contains("does not match")));
167 }
168}