1use async_trait::async_trait;
58use serde::{Deserialize, Serialize};
59use serde_json::{json, Value};
60
61use crate::condition::Case;
62use crate::error::{FlowError, Result};
63use crate::node::{ExecContext, Node};
64
65#[derive(Debug, Deserialize, Serialize)]
66struct IfElseConfig {
67 cases: Vec<Case>,
68}
69
70pub struct IfElseNode;
72
73#[async_trait]
74impl Node for IfElseNode {
75 fn node_type(&self) -> &str {
76 "if-else"
77 }
78
79 async fn execute(&self, ctx: ExecContext) -> Result<Value> {
80 let config: IfElseConfig = serde_json::from_value(ctx.data.clone())
81 .map_err(|e| FlowError::InvalidDefinition(format!("if-else: invalid data: {e}")))?;
82
83 if config.cases.is_empty() {
84 return Err(FlowError::InvalidDefinition(
85 "if-else: at least one case is required".into(),
86 ));
87 }
88
89 for case in &config.cases {
90 if case.evaluate(&ctx.inputs) {
91 return Ok(json!({ "branch": case.id }));
92 }
93 }
94
95 Ok(json!({ "branch": "else" }))
96 }
97}
98
99#[cfg(test)]
100mod tests {
101 use super::*;
102 use std::collections::HashMap;
103
104 fn ctx(inputs: HashMap<String, Value>, data: Value) -> ExecContext {
105 ExecContext {
106 data,
107 inputs,
108 variables: HashMap::new(),
109 ..Default::default()
110 }
111 }
112
113 #[tokio::test]
114 async fn first_matching_case_wins() {
115 let node = IfElseNode;
116 let c = ctx(
117 HashMap::from([("fetch".into(), json!({ "status": 200 }))]),
118 json!({
119 "cases": [
120 { "id": "is_ok", "conditions": [{ "from": "fetch", "path": "status", "op": "eq", "value": 200 }] },
121 { "id": "is_error", "conditions": [{ "from": "fetch", "path": "status", "op": "gte", "value": 400 }] }
122 ]
123 }),
124 );
125 let out = node.execute(c).await.unwrap();
126 assert_eq!(out["branch"], "is_ok");
127 }
128
129 #[tokio::test]
130 async fn falls_through_to_else() {
131 let node = IfElseNode;
132 let c = ctx(
133 HashMap::from([("fetch".into(), json!({ "status": 302 }))]),
134 json!({
135 "cases": [
136 { "id": "is_ok", "conditions": [{ "from": "fetch", "path": "status", "op": "eq", "value": 200 }] },
137 { "id": "is_error", "conditions": [{ "from": "fetch", "path": "status", "op": "gte", "value": 500 }] }
138 ]
139 }),
140 );
141 let out = node.execute(c).await.unwrap();
142 assert_eq!(out["branch"], "else");
143 }
144
145 #[tokio::test]
146 async fn or_logical_operator() {
147 let node = IfElseNode;
148 let c = ctx(
149 HashMap::from([("a".into(), json!({ "x": 1, "y": 99 }))]),
150 json!({
151 "cases": [{
152 "id": "hit",
153 "logical_operator": "or",
154 "conditions": [
155 { "from": "a", "path": "x", "op": "eq", "value": 1 },
156 { "from": "a", "path": "y", "op": "eq", "value": 2 }
157 ]
158 }]
159 }),
160 );
161 let out = node.execute(c).await.unwrap();
162 assert_eq!(out["branch"], "hit");
163 }
164
165 #[tokio::test]
166 async fn and_logical_operator_all_must_pass() {
167 let node = IfElseNode;
168 let c = ctx(
169 HashMap::from([("a".into(), json!({ "x": 1, "y": 99 }))]),
170 json!({
171 "cases": [{
172 "id": "hit",
173 "logical_operator": "and",
174 "conditions": [
175 { "from": "a", "path": "x", "op": "eq", "value": 1 },
176 { "from": "a", "path": "y", "op": "eq", "value": 2 }
177 ]
178 }]
179 }),
180 );
181 let out = node.execute(c).await.unwrap();
182 assert_eq!(out["branch"], "else");
183 }
184
185 #[tokio::test]
186 async fn rejects_empty_cases() {
187 let node = IfElseNode;
188 let c = ctx(HashMap::new(), json!({ "cases": [] }));
189 assert!(matches!(
190 node.execute(c).await,
191 Err(FlowError::InvalidDefinition(_))
192 ));
193 }
194
195 #[tokio::test]
196 async fn rejects_invalid_config() {
197 let node = IfElseNode;
198 let c = ctx(HashMap::new(), json!("not an object"));
199 assert!(matches!(
200 node.execute(c).await,
201 Err(FlowError::InvalidDefinition(_))
202 ));
203 }
204}