lean_ctx/core/workflow/
engine.rs1use 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}