Skip to main content

lean_ctx/core/workflow/
engine.rs

1use crate::core::workflow::types::{StateSpec, TransitionSpec, WorkflowSpec};
2
3pub fn validate_spec(spec: &WorkflowSpec) -> Result<(), String> {
4    if spec.states.is_empty() {
5        return Err("WorkflowSpec.states must not be empty".to_string());
6    }
7    if spec.name.trim().is_empty() {
8        return Err("WorkflowSpec.name must not be empty".to_string());
9    }
10    if spec.initial.trim().is_empty() {
11        return Err("WorkflowSpec.initial must not be empty".to_string());
12    }
13
14    let mut seen = std::collections::HashSet::new();
15    for s in &spec.states {
16        if s.name.trim().is_empty() {
17            return Err("StateSpec.name must not be empty".to_string());
18        }
19        if !seen.insert(s.name.clone()) {
20            return Err(format!("Duplicate state name: {}", s.name));
21        }
22        validate_state_tools(s)?;
23    }
24
25    if spec.state(&spec.initial).is_none() {
26        return Err(format!(
27            "WorkflowSpec.initial '{}' is not in states",
28            spec.initial
29        ));
30    }
31
32    for t in &spec.transitions {
33        if spec.state(&t.from).is_none() {
34            return Err(format!("Transition.from '{}' is not a state", t.from));
35        }
36        if spec.state(&t.to).is_none() {
37            return Err(format!("Transition.to '{}' is not a state", t.to));
38        }
39    }
40
41    Ok(())
42}
43
44fn validate_state_tools(state: &StateSpec) -> Result<(), String> {
45    if let Some(ref tools) = state.allowed_tools {
46        if tools.is_empty() {
47            return Err(format!(
48                "State '{}' allowed_tools must not be empty when present",
49                state.name
50            ));
51        }
52        for t in tools {
53            if t.trim().is_empty() {
54                return Err(format!(
55                    "State '{}' has empty allowed_tools entry",
56                    state.name
57                ));
58            }
59        }
60    }
61    Ok(())
62}
63
64pub fn allowed_transitions<'a>(spec: &'a WorkflowSpec, from: &str) -> Vec<&'a TransitionSpec> {
65    spec.transitions.iter().filter(|t| t.from == from).collect()
66}
67
68pub fn find_transition<'a>(
69    spec: &'a WorkflowSpec,
70    from: &str,
71    to: &str,
72) -> Option<&'a TransitionSpec> {
73    spec.transitions
74        .iter()
75        .find(|t| t.from == from && t.to == to)
76}
77
78pub fn missing_evidence_for_state(
79    spec: &WorkflowSpec,
80    to_state: &str,
81    has_evidence: impl Fn(&str) -> bool,
82) -> Vec<String> {
83    let Some(state) = spec.state(to_state) else {
84        return vec![format!("unknown_state:{to_state}")];
85    };
86    let Some(req) = state.requires_evidence.as_ref() else {
87        return Vec::new();
88    };
89    req.iter()
90        .filter(|k| !has_evidence(k.as_str()))
91        .cloned()
92        .collect()
93}
94
95pub fn can_transition(
96    spec: &WorkflowSpec,
97    from: &str,
98    to: &str,
99    has_evidence: impl Fn(&str) -> bool,
100) -> Result<(), String> {
101    if find_transition(spec, from, to).is_none() {
102        return Err(format!("No transition: {from} → {to}"));
103    }
104
105    let missing = missing_evidence_for_state(spec, to, has_evidence);
106    if !missing.is_empty() {
107        return Err(format!(
108            "Missing evidence for '{to}': {}",
109            missing.join(", ")
110        ));
111    }
112
113    Ok(())
114}