syntax-workout-core 0.2.0

Workout tree algebra — represent any physical workout as a recursive tree
Documentation
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")));
    }
}