Skip to main content

a3s_flow/nodes/
cond.rs

1//! Built-in `"if-else"` node — evaluates an ordered list of named cases and
2//! emits `{ "branch": "<case_id>" }` for the first matching case, or
3//! `{ "branch": "else" }` when none match.
4//!
5//! This mirrors Dify's IF/ELSE node. Downstream nodes use `run_if` on
6//! `branch` to determine which path executes.
7//!
8//! # Config schema
9//!
10//! ```json
11//! {
12//!   "cases": [
13//!     {
14//!       "id": "is_ok",
15//!       "logical_operator": "and",
16//!       "conditions": [
17//!         { "from": "fetch", "path": "status", "op": "eq", "value": 200 }
18//!       ]
19//!     },
20//!     {
21//!       "id": "is_error",
22//!       "conditions": [
23//!         { "from": "fetch", "path": "status", "op": "gte", "value": 400 }
24//!       ]
25//!     }
26//!   ]
27//! }
28//! ```
29//!
30//! | Field | Type | Description |
31//! |-------|------|-------------|
32//! | `cases` | array | Ordered list of named cases; first match wins |
33//! | `cases[].id` | string | Branch identifier (returned as `"branch"`) |
34//! | `cases[].logical_operator` | `"and"` \| `"or"` | How to combine conditions (default: `"and"`) |
35//! | `cases[].conditions` | array | One or more [`Condition`] objects |
36//!
37//! # Output schema
38//!
39//! ```json
40//! { "branch": "is_ok" }
41//! ```
42//!
43//! The implicit ELSE branch is always `"else"`.
44//!
45//! # Routing downstream nodes
46//!
47//! ```json
48//! {
49//!   "id": "notify",
50//!   "type": "http-request",
51//!   "data": { "run_if": { "from": "route", "path": "branch", "op": "eq", "value": "is_ok" } }
52//! }
53//! ```
54//!
55//! [`Condition`]: crate::condition::Condition
56
57use 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
70/// IF/ELSE routing node (Dify-compatible).
71pub 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}