Skip to main content

syntax_workout_core/
validate.rs

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}