syntax-workout-core 0.2.0

Workout tree algebra — represent any physical workout as a recursive tree
Documentation
use crate::execution_mode::ExecutionMode;
use crate::intensity::Intensity;
use crate::measure::Measure;
use crate::node::{Node, NodePayload};

/// Immutable tree visitor with ancestor path context.
///
/// `ancestors` is a slice of parent nodes from root to immediate parent.
/// Empty at the root node, grows as the walk descends.
///
/// Usage:
///   struct MyVisitor;
///   impl Visit for MyVisitor {
///       fn visit_leaf(&mut self, measures: &[Measure], _: Option<&Intensity>, ancestors: &[&Node]) {
///           // ancestors[0] is root, ancestors.last() is the parent Exercise/Block
///       }
///   }
///   let mut v = MyVisitor;
///   v.visit_tree(&workout.root);
pub trait Visit {
    /// Convenience entry point. Calls visit_node with empty ancestors.
    fn visit_tree(&mut self, root: &Node) {
        self.visit_node(root, &[]);
    }

    fn visit_node(&mut self, node: &Node, ancestors: &[&Node]) {
        walk_node(self, node, ancestors);
    }

    fn visit_leaf(&mut self, _measures: &[Measure], _intensity: Option<&Intensity>, _ancestors: &[&Node]) {}

    fn visit_exercise(&mut self, _name: Option<&str>, _measures: &[Measure], _intensity: Option<&Intensity>, _ancestors: &[&Node]) {}

    fn visit_block(&mut self, _mode: &ExecutionMode, _ancestors: &[&Node]) {}
}

pub fn walk_node<V: Visit + ?Sized>(v: &mut V, node: &Node, ancestors: &[&Node]) {
    match &node.payload {
        NodePayload::Leaf { measures, intensity } => {
            v.visit_leaf(measures, intensity.as_ref(), ancestors);
        }
        NodePayload::Exercise { measures, intensity, .. } => {
            v.visit_exercise(node.name.as_deref(), measures, intensity.as_ref(), ancestors);
        }
        NodePayload::Block { execution_mode, .. } => {
            v.visit_block(execution_mode, ancestors);
        }
        NodePayload::Temporal { .. } | NodePayload::Custom { .. } => {}
    }
    let mut child_ancestors = ancestors.to_vec();
    child_ancestors.push(node);
    for child in &node.children {
        v.visit_node(child, &child_ancestors);
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::execution_mode::ExecutionMode;
    use crate::measure::{Measure, WeightUnit};
    use crate::node::*;
    use std::collections::BTreeMap;

    struct TreeStats {
        sets: usize,
        blocks: usize,
        exercises: Vec<String>,
    }

    impl Visit for TreeStats {
        fn visit_leaf(&mut self, _measures: &[Measure], _intensity: Option<&Intensity>, _ancestors: &[&Node]) {
            self.sets += 1;
        }
        fn visit_block(&mut self, _mode: &ExecutionMode, _ancestors: &[&Node]) {
            self.blocks += 1;
        }
        fn visit_exercise(&mut self, name: Option<&str>, _measures: &[Measure], _intensity: Option<&Intensity>, _ancestors: &[&Node]) {
            if let Some(n) = name {
                self.exercises.push(n.to_string());
            }
        }
    }

    fn make_superset_session() -> Node {
        let set_a1 = Node {
            id: NodeId::from_string("sa1"), kind: NodeKind::Set, name: None, children: vec![],
            payload: NodePayload::Leaf { measures: vec![Measure::Weight { amount: 30.0, unit: WeightUnit::Kg }, Measure::Reps(10)], intensity: None },
            metadata: BTreeMap::new(),
        };
        let set_a2 = Node {
            id: NodeId::from_string("sa2"), kind: NodeKind::Set, name: None, children: vec![],
            payload: NodePayload::Leaf { measures: vec![Measure::Weight { amount: 30.0, unit: WeightUnit::Kg }, Measure::Reps(10)], intensity: None },
            metadata: BTreeMap::new(),
        };
        let ex_a = Node {
            id: NodeId::from_string("ea"), kind: NodeKind::Exercise, name: Some("DB Row".into()),
            children: vec![set_a1, set_a2],
            payload: NodePayload::Exercise { measures: vec![], intensity: None, rest_seconds: None },
            metadata: BTreeMap::new(),
        };
        let set_b1 = Node {
            id: NodeId::from_string("sb1"), kind: NodeKind::Set, name: None, children: vec![],
            payload: NodePayload::Leaf { measures: vec![Measure::Weight { amount: 25.0, unit: WeightUnit::Kg }, Measure::Reps(12)], intensity: None },
            metadata: BTreeMap::new(),
        };
        let ex_b = Node {
            id: NodeId::from_string("eb"), kind: NodeKind::Exercise, name: Some("Lateral Raise".into()),
            children: vec![set_b1],
            payload: NodePayload::Exercise { measures: vec![], intensity: None, rest_seconds: None },
            metadata: BTreeMap::new(),
        };
        let block = Node {
            id: NodeId::from_string("blk"), kind: NodeKind::Block, name: Some("Superset A".into()),
            children: vec![ex_a, ex_b],
            payload: NodePayload::Block { execution_mode: ExecutionMode::Parallel, rest_seconds: Some(90.0) },
            metadata: BTreeMap::new(),
        };
        Node {
            id: NodeId::from_string("sess"), kind: NodeKind::Session, name: Some("Upper".into()),
            children: vec![block],
            payload: NodePayload::Temporal { rest_seconds: None },
            metadata: BTreeMap::new(),
        }
    }

    #[test]
    fn visit_counts_sets_and_blocks() {
        let session = make_superset_session();
        let mut stats = TreeStats { sets: 0, blocks: 0, exercises: vec![] };
        stats.visit_tree(&session);
        assert_eq!(stats.sets, 3);
        assert_eq!(stats.blocks, 1);
        assert_eq!(stats.exercises, vec!["DB Row", "Lateral Raise"]);
    }

    /// Demonstrates path-aware search: find which exercise each set belongs to.
    struct SetWithExercise {
        results: Vec<(String, String)>, // (exercise_name, set_id)
    }

    impl Visit for SetWithExercise {
        fn visit_leaf(&mut self, _measures: &[Measure], _: Option<&Intensity>, ancestors: &[&Node]) {
            // Walk ancestors backwards to find the nearest Exercise
            let exercise_name = ancestors.iter().rev()
                .find(|n| matches!(n.kind, NodeKind::Exercise))
                .and_then(|n| n.name.as_deref())
                .unwrap_or("unknown");
            let set_id = ancestors.last()
                .map(|_| "set")
                .unwrap_or("orphan");
            // We don't have the current node in ancestors, but we know it's a Set
            self.results.push((exercise_name.to_string(), set_id.to_string()));
        }
    }

    #[test]
    fn visit_with_path_context() {
        let session = make_superset_session();
        let mut finder = SetWithExercise { results: vec![] };
        finder.visit_tree(&session);

        // 3 sets, each should know its parent exercise
        assert_eq!(finder.results.len(), 3);
        assert_eq!(finder.results[0].0, "DB Row");
        assert_eq!(finder.results[1].0, "DB Row");
        assert_eq!(finder.results[2].0, "Lateral Raise");
    }
}