use serde::{Deserialize, Serialize};
use ts_rs::TS;
use crate::node::{Node, NodeKind, NodePayload};
use crate::visit::{Visit, walk_node};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
#[ts(export, export_to = "../../../bindings/napi/generated/")]
pub struct ValidationError {
pub node_id: String,
pub message: String,
}
pub fn validate(root: &Node) -> Vec<ValidationError> {
let mut v = Validator { errors: vec![] };
v.visit_tree(root);
v.errors
}
struct Validator {
errors: Vec<ValidationError>,
}
impl Visit for Validator {
fn visit_node(&mut self, node: &Node, ancestors: &[&Node]) {
let kind_payload_ok = matches!(
(&node.kind, &node.payload),
(NodeKind::Set, NodePayload::Leaf { .. })
| (NodeKind::Exercise, NodePayload::Exercise { .. })
| (NodeKind::Block, NodePayload::Block { .. })
| (NodeKind::Session, NodePayload::Temporal { .. })
| (NodeKind::Day, NodePayload::Temporal { .. })
| (NodeKind::Week, NodePayload::Temporal { .. })
| (NodeKind::Phase, NodePayload::Temporal { .. })
| (NodeKind::Program, NodePayload::Temporal { .. })
| (NodeKind::Custom(_), NodePayload::Custom { .. })
);
if !kind_payload_ok {
self.errors.push(ValidationError {
node_id: node.id.0.clone(),
message: format!("NodeKind {:?} does not match payload variant", node.kind),
});
}
if let NodePayload::Leaf { measures, .. } = &node.payload
&& measures.is_empty()
{
self.errors.push(ValidationError {
node_id: node.id.0.clone(),
message: "Set node must have at least one measure".into(),
});
}
if let NodePayload::Block { .. } = &node.payload
&& node.children.is_empty()
{
self.errors.push(ValidationError {
node_id: node.id.0.clone(),
message: "Block node must have at least one child".into(),
});
}
if let NodePayload::Block { .. } = &node.payload {
for child in &node.children {
if matches!(child.kind, NodeKind::Set) {
self.errors.push(ValidationError {
node_id: child.id.0.clone(),
message: "Set should not be a direct child of Block (wrap in Exercise)".into(),
});
}
}
}
if let NodePayload::Exercise { .. } = &node.payload {
for child in &node.children {
if matches!(child.kind, NodeKind::Block | NodeKind::Session) {
self.errors.push(ValidationError {
node_id: child.id.0.clone(),
message: format!("{:?} should not be a child of Exercise", child.kind),
});
}
}
}
walk_node(self, node, ancestors);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::execution_mode::ExecutionMode;
use crate::measure::Measure;
use crate::node::*;
use std::collections::BTreeMap;
fn leaf(id: &str, measures: Vec<Measure>) -> Node {
Node {
id: NodeId::from_string(id), kind: NodeKind::Set, name: None, children: vec![],
payload: NodePayload::Leaf { measures, intensity: None },
metadata: BTreeMap::new(),
}
}
fn exercise(id: &str, name: &str, children: Vec<Node>) -> Node {
Node {
id: NodeId::from_string(id), kind: NodeKind::Exercise, name: Some(name.into()),
children,
payload: NodePayload::Exercise { measures: vec![], intensity: None, rest_seconds: None },
metadata: BTreeMap::new(),
}
}
fn block(id: &str, mode: ExecutionMode, children: Vec<Node>) -> Node {
Node {
id: NodeId::from_string(id), kind: NodeKind::Block, name: None,
children,
payload: NodePayload::Block { execution_mode: mode, rest_seconds: None },
metadata: BTreeMap::new(),
}
}
#[test]
fn valid_tree_has_no_errors() {
let s = leaf("s1", vec![Measure::Reps(10)]);
let e = exercise("e1", "Squat", vec![s]);
let b = block("b1", ExecutionMode::Sequential, vec![e]);
let errors = validate(&b);
assert!(errors.is_empty(), "expected no errors, got: {:?}", errors);
}
#[test]
fn empty_leaf_is_invalid() {
let s = leaf("s1", vec![]);
let errors = validate(&s);
assert_eq!(errors.len(), 1);
assert!(errors[0].message.contains("at least one measure"));
}
#[test]
fn empty_block_is_invalid() {
let b = block("b1", ExecutionMode::Sequential, vec![]);
let errors = validate(&b);
assert_eq!(errors.len(), 1);
assert!(errors[0].message.contains("at least one child"));
}
#[test]
fn set_directly_in_block_is_invalid() {
let s = leaf("s1", vec![Measure::Reps(10)]);
let b = block("b1", ExecutionMode::Sequential, vec![s]);
let errors = validate(&b);
assert_eq!(errors.len(), 1);
assert!(errors[0].message.contains("wrap in Exercise"));
}
#[test]
fn mismatched_kind_payload_is_invalid() {
let node = Node {
id: NodeId::from_string("bad"), kind: NodeKind::Set, name: None, children: vec![],
payload: NodePayload::Block { execution_mode: ExecutionMode::Sequential, rest_seconds: None },
metadata: BTreeMap::new(),
};
let errors = validate(&node);
assert!(errors.iter().any(|e| e.message.contains("does not match")));
}
}