xerv_core/flow/
edge.rs

1//! Edge definition from YAML.
2
3use serde::{Deserialize, Serialize};
4
5/// An edge definition connecting nodes in a flow.
6///
7/// Edges can be specified in several formats:
8///
9/// # Simple format (default ports)
10/// ```yaml
11/// edges:
12///   - from: node_a
13///     to: node_b
14/// ```
15///
16/// # With explicit ports
17/// ```yaml
18/// edges:
19///   - from: switch_node.true
20///     to: handler_node.in
21///   - from: switch_node.false
22///     to: error_handler.in
23/// ```
24///
25/// # With conditions
26/// ```yaml
27/// edges:
28///   - from: node_a.out
29///     to: node_b.in
30///     condition: "${node_a.status} == 'success'"
31/// ```
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct EdgeDefinition {
34    /// Source node and optional port (format: "node_id" or "node_id.port").
35    pub from: String,
36
37    /// Target node and optional port (format: "node_id" or "node_id.port").
38    pub to: String,
39
40    /// Optional condition for this edge (selector expression).
41    #[serde(default)]
42    pub condition: Option<String>,
43
44    /// Whether this edge represents a loop back-edge.
45    #[serde(default)]
46    pub loop_back: bool,
47
48    /// Optional description.
49    #[serde(default)]
50    pub description: Option<String>,
51}
52
53impl EdgeDefinition {
54    /// Create a new edge definition.
55    pub fn new(from: impl Into<String>, to: impl Into<String>) -> Self {
56        Self {
57            from: from.into(),
58            to: to.into(),
59            condition: None,
60            loop_back: false,
61            description: None,
62        }
63    }
64
65    /// Set a condition.
66    pub fn with_condition(mut self, condition: impl Into<String>) -> Self {
67        self.condition = Some(condition.into());
68        self
69    }
70
71    /// Mark as a loop back-edge.
72    pub fn as_loop_back(mut self) -> Self {
73        self.loop_back = true;
74        self
75    }
76
77    /// Set description.
78    pub fn with_description(mut self, desc: impl Into<String>) -> Self {
79        self.description = Some(desc.into());
80        self
81    }
82
83    /// Parse the source into (node_id, port).
84    ///
85    /// Returns (node_id, port) where port defaults to "out" if not specified.
86    pub fn parse_from(&self) -> (&str, &str) {
87        parse_node_port(&self.from, "out")
88    }
89
90    /// Parse the target into (node_id, port).
91    ///
92    /// Returns (node_id, port) where port defaults to "in" if not specified.
93    pub fn parse_to(&self) -> (&str, &str) {
94        parse_node_port(&self.to, "in")
95    }
96
97    /// Get the source node ID.
98    pub fn from_node(&self) -> &str {
99        self.parse_from().0
100    }
101
102    /// Get the source port.
103    pub fn from_port(&self) -> &str {
104        self.parse_from().1
105    }
106
107    /// Get the target node ID.
108    pub fn to_node(&self) -> &str {
109        self.parse_to().0
110    }
111
112    /// Get the target port.
113    pub fn to_port(&self) -> &str {
114        self.parse_to().1
115    }
116}
117
118/// Parse a "node.port" or "node" string into (node, port).
119fn parse_node_port<'a>(s: &'a str, default_port: &'static str) -> (&'a str, &'a str) {
120    if let Some(dot_pos) = s.rfind('.') {
121        // Check if what's after the dot looks like a port name
122        let after_dot = &s[dot_pos + 1..];
123        // Port names are typically short identifiers like "in", "out", "true", "false", "error"
124        // If it looks like a selector or contains special chars, don't split
125        if !after_dot.is_empty()
126            && !after_dot.contains('$')
127            && !after_dot.contains('{')
128            && after_dot.len() < 20
129            && after_dot.chars().all(|c| c.is_alphanumeric() || c == '_')
130        {
131            return (&s[..dot_pos], after_dot);
132        }
133    }
134    (s, default_port)
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    #[test]
142    fn deserialize_simple_edge() {
143        let yaml = r#"
144from: node_a
145to: node_b
146"#;
147        let edge: EdgeDefinition = serde_yaml::from_str(yaml).unwrap();
148        assert_eq!(edge.from, "node_a");
149        assert_eq!(edge.to, "node_b");
150        assert_eq!(edge.from_node(), "node_a");
151        assert_eq!(edge.from_port(), "out");
152        assert_eq!(edge.to_node(), "node_b");
153        assert_eq!(edge.to_port(), "in");
154    }
155
156    #[test]
157    fn deserialize_edge_with_ports() {
158        let yaml = r#"
159from: switch.true
160to: handler.input
161"#;
162        let edge: EdgeDefinition = serde_yaml::from_str(yaml).unwrap();
163        assert_eq!(edge.from_node(), "switch");
164        assert_eq!(edge.from_port(), "true");
165        assert_eq!(edge.to_node(), "handler");
166        assert_eq!(edge.to_port(), "input");
167    }
168
169    #[test]
170    fn deserialize_edge_with_condition() {
171        let yaml = r#"
172from: validator.out
173to: processor.in
174condition: "${validator.is_valid} == true"
175"#;
176        let edge: EdgeDefinition = serde_yaml::from_str(yaml).unwrap();
177        assert_eq!(
178            edge.condition,
179            Some("${validator.is_valid} == true".to_string())
180        );
181    }
182
183    #[test]
184    fn deserialize_loop_back_edge() {
185        let yaml = r#"
186from: processor.out
187to: loop_controller.in
188loop_back: true
189description: "Return to loop controller"
190"#;
191        let edge: EdgeDefinition = serde_yaml::from_str(yaml).unwrap();
192        assert!(edge.loop_back);
193        assert_eq!(
194            edge.description,
195            Some("Return to loop controller".to_string())
196        );
197    }
198
199    #[test]
200    fn edge_builder() {
201        let edge = EdgeDefinition::new("source.out", "target.in")
202            .with_condition("${source.success}")
203            .with_description("Main flow");
204
205        assert_eq!(edge.from_node(), "source");
206        assert_eq!(edge.from_port(), "out");
207        assert_eq!(edge.condition, Some("${source.success}".to_string()));
208    }
209
210    #[test]
211    fn parse_node_without_port() {
212        let edge = EdgeDefinition::new("simple_node", "another_node");
213        assert_eq!(edge.from_node(), "simple_node");
214        assert_eq!(edge.from_port(), "out");
215        assert_eq!(edge.to_node(), "another_node");
216        assert_eq!(edge.to_port(), "in");
217    }
218
219    #[test]
220    fn parse_special_ports() {
221        // Switch node with boolean ports
222        let edge1 = EdgeDefinition::new("switch.true", "handler");
223        assert_eq!(edge1.from_node(), "switch");
224        assert_eq!(edge1.from_port(), "true");
225
226        let edge2 = EdgeDefinition::new("switch.false", "error_handler");
227        assert_eq!(edge2.from_node(), "switch");
228        assert_eq!(edge2.from_port(), "false");
229
230        // Error port
231        let edge3 = EdgeDefinition::new("processor.error", "error_log");
232        assert_eq!(edge3.from_node(), "processor");
233        assert_eq!(edge3.from_port(), "error");
234    }
235}