Skip to main content

a3s_flow/
condition.rs

1//! Condition types shared by `run_if` (in [`NodeDef`]) and the `"if-else"` built-in node.
2//!
3//! - [`Condition`] — single comparison (`from`, `path`, `op`, `value`)
4//! - [`Case`] — named branch with one or more conditions joined by [`LogicalOp`]
5//!
6//! The evaluation logic lives here once and is reused by the runner and nodes.
7//!
8//! [`NodeDef`]: crate::graph::NodeDef
9
10use std::cmp::Ordering;
11use std::collections::{HashMap, HashSet};
12
13use serde::{Deserialize, Serialize};
14use serde_json::Value;
15
16// ── Operators ──────────────────────────────────────────────────────────────
17
18/// Comparison operator for a [`Condition`].
19#[derive(Debug, Clone, Serialize, Deserialize)]
20#[serde(rename_all = "snake_case")]
21pub enum CondOp {
22    /// Equal (`==`)
23    Eq,
24    /// Not equal (`!=`)
25    Ne,
26    /// Greater than (`>`, numbers only)
27    Gt,
28    /// Less than (`<`, numbers only)
29    Lt,
30    /// Greater than or equal (`>=`, numbers only)
31    Gte,
32    /// Less than or equal (`<=`, numbers only)
33    Lte,
34    /// Substring (string) or element membership (array)
35    Contains,
36}
37
38/// Logical operator combining multiple conditions inside a [`Case`].
39#[derive(Debug, Clone, Default, Serialize, Deserialize)]
40#[serde(rename_all = "snake_case")]
41pub enum LogicalOp {
42    /// All conditions must be true (default, matches Dify default).
43    #[default]
44    And,
45    /// At least one condition must be true.
46    Or,
47}
48
49// ── Condition ──────────────────────────────────────────────────────────────
50
51/// A single comparison against an upstream node's output.
52///
53/// Used in two places:
54/// - `run_if` on a [`NodeDef`] — guard that skips the node when false
55/// - inside a [`Case`] in the `"if-else"` node config
56///
57/// [`NodeDef`]: crate::graph::NodeDef
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct Condition {
60    /// ID of the upstream node whose output to inspect.
61    pub from: String,
62    /// Dot-separated path into the upstream output (e.g. `"status"`, `"data.count"`).
63    /// Use `""` to compare the root value.
64    pub path: String,
65    /// Comparison operator.
66    pub op: CondOp,
67    /// Right-hand side value.
68    pub value: Value,
69}
70
71impl Condition {
72    /// Evaluate against the full output map, respecting the skipped set.
73    ///
74    /// Returns `false` when the source node was skipped, is missing, the path
75    /// does not resolve, or the comparison fails.
76    pub fn evaluate(&self, outputs: &HashMap<String, Value>, skipped: &HashSet<String>) -> bool {
77        if skipped.contains(&self.from) {
78            return false;
79        }
80        let from_output = match outputs.get(&self.from) {
81            Some(v) => v,
82            None => return false,
83        };
84        self.evaluate_on_value(from_output)
85    }
86
87    /// Evaluate directly against a single upstream output value.
88    ///
89    /// Used by the `"if-else"` node, which already has the value in `ctx.inputs`.
90    pub fn evaluate_on_value(&self, from_output: &Value) -> bool {
91        if from_output.is_null() {
92            return false;
93        }
94        let actual = match get_path(from_output, &self.path) {
95            Some(v) => v,
96            None => return false,
97        };
98        compare(actual, &self.op, &self.value)
99    }
100}
101
102// ── Case ───────────────────────────────────────────────────────────────────
103
104/// A named branch inside an `"if-else"` node.
105///
106/// A case is satisfied when its conditions evaluate to true according to
107/// `logical_operator`. Cases are tested in order; the first match wins.
108///
109/// # Example
110///
111/// ```json
112/// {
113///   "id": "is_ok",
114///   "logical_operator": "and",
115///   "conditions": [
116///     { "from": "fetch", "path": "status", "op": "eq", "value": 200 }
117///   ]
118/// }
119/// ```
120#[derive(Debug, Clone, Serialize, Deserialize)]
121pub struct Case {
122    /// Branch identifier — returned as `"branch"` in the node output.
123    pub id: String,
124    /// How multiple conditions are combined (default: `"and"`).
125    #[serde(default)]
126    pub logical_operator: LogicalOp,
127    /// Conditions that must hold for this case to match.
128    pub conditions: Vec<Condition>,
129}
130
131impl Case {
132    /// Evaluate all conditions against `inputs`, combining with `logical_operator`.
133    ///
134    /// Returns `false` for an empty conditions list.
135    pub fn evaluate(&self, inputs: &HashMap<String, Value>) -> bool {
136        if self.conditions.is_empty() {
137            return false;
138        }
139        let results = self.conditions.iter().map(|cond| {
140            inputs
141                .get(&cond.from)
142                .map(|v| cond.evaluate_on_value(v))
143                .unwrap_or(false)
144        });
145        match self.logical_operator {
146            LogicalOp::And => results.into_iter().all(|b| b),
147            LogicalOp::Or => results.into_iter().any(|b| b),
148        }
149    }
150}
151
152// ── Helpers ──────��─────────────────────────────────────────────────────────
153
154/// Navigate a dot-separated path into a JSON value.
155///
156/// Returns `None` if any key is missing. Empty path returns the root.
157pub(crate) fn get_path<'a>(value: &'a Value, path: &str) -> Option<&'a Value> {
158    if path.is_empty() {
159        return Some(value);
160    }
161    let mut current = value;
162    for key in path.split('.') {
163        current = current.get(key)?;
164    }
165    Some(current)
166}
167
168fn compare(actual: &Value, op: &CondOp, expected: &Value) -> bool {
169    match op {
170        CondOp::Eq => actual == expected,
171        CondOp::Ne => actual != expected,
172        CondOp::Gt => numeric_cmp(actual, expected) == Some(Ordering::Greater),
173        CondOp::Lt => numeric_cmp(actual, expected) == Some(Ordering::Less),
174        CondOp::Gte => matches!(
175            numeric_cmp(actual, expected),
176            Some(Ordering::Greater | Ordering::Equal)
177        ),
178        CondOp::Lte => matches!(
179            numeric_cmp(actual, expected),
180            Some(Ordering::Less | Ordering::Equal)
181        ),
182        CondOp::Contains => match (actual, expected) {
183            (Value::String(s), Value::String(sub)) => s.contains(sub.as_str()),
184            (Value::Array(arr), v) => arr.contains(v),
185            _ => false,
186        },
187    }
188}
189
190fn numeric_cmp(a: &Value, b: &Value) -> Option<Ordering> {
191    a.as_f64()?.partial_cmp(&b.as_f64()?)
192}
193
194// ── Tests ──────────────────────────────────────────────────────────────────
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199    use serde_json::json;
200
201    fn outputs(pairs: &[(&str, Value)]) -> HashMap<String, Value> {
202        pairs
203            .iter()
204            .map(|(k, v)| (k.to_string(), v.clone()))
205            .collect()
206    }
207
208    fn inputs(pairs: &[(&str, Value)]) -> HashMap<String, Value> {
209        outputs(pairs)
210    }
211
212    #[test]
213    fn condition_eq_passes() {
214        let cond = Condition {
215            from: "a".into(),
216            path: "status".into(),
217            op: CondOp::Eq,
218            value: json!(200),
219        };
220        assert!(cond.evaluate(&outputs(&[("a", json!({"status": 200}))]), &HashSet::new()));
221    }
222
223    #[test]
224    fn condition_eq_fails() {
225        let cond = Condition {
226            from: "a".into(),
227            path: "status".into(),
228            op: CondOp::Eq,
229            value: json!(200),
230        };
231        assert!(!cond.evaluate(&outputs(&[("a", json!({"status": 404}))]), &HashSet::new()));
232    }
233
234    #[test]
235    fn skipped_upstream_propagates() {
236        let cond = Condition {
237            from: "a".into(),
238            path: "".into(),
239            op: CondOp::Eq,
240            value: json!(true),
241        };
242        let mut skipped = HashSet::new();
243        skipped.insert("a".to_string());
244        assert!(!cond.evaluate(&outputs(&[("a", json!(null))]), &skipped));
245    }
246
247    #[test]
248    fn null_upstream_returns_false() {
249        let cond = Condition {
250            from: "a".into(),
251            path: "x".into(),
252            op: CondOp::Eq,
253            value: json!(1),
254        };
255        assert!(!cond.evaluate_on_value(&json!(null)));
256    }
257
258    #[test]
259    fn gt_numeric() {
260        let cond = Condition {
261            from: "a".into(),
262            path: "count".into(),
263            op: CondOp::Gt,
264            value: json!(5),
265        };
266        assert!(cond.evaluate(&outputs(&[("a", json!({"count": 10}))]), &HashSet::new()));
267    }
268
269    #[test]
270    fn contains_string() {
271        let cond = Condition {
272            from: "a".into(),
273            path: "msg".into(),
274            op: CondOp::Contains,
275            value: json!("hello"),
276        };
277        assert!(cond.evaluate(
278            &outputs(&[("a", json!({"msg": "say hello world"}))]),
279            &HashSet::new()
280        ));
281    }
282
283    #[test]
284    fn case_and_all_pass() {
285        let case = Case {
286            id: "ok".into(),
287            logical_operator: LogicalOp::And,
288            conditions: vec![
289                Condition {
290                    from: "a".into(),
291                    path: "x".into(),
292                    op: CondOp::Eq,
293                    value: json!(1),
294                },
295                Condition {
296                    from: "a".into(),
297                    path: "y".into(),
298                    op: CondOp::Eq,
299                    value: json!(2),
300                },
301            ],
302        };
303        assert!(case.evaluate(&inputs(&[("a", json!({"x": 1, "y": 2}))])));
304    }
305
306    #[test]
307    fn case_and_one_fails() {
308        let case = Case {
309            id: "ok".into(),
310            logical_operator: LogicalOp::And,
311            conditions: vec![
312                Condition {
313                    from: "a".into(),
314                    path: "x".into(),
315                    op: CondOp::Eq,
316                    value: json!(1),
317                },
318                Condition {
319                    from: "a".into(),
320                    path: "y".into(),
321                    op: CondOp::Eq,
322                    value: json!(99),
323                },
324            ],
325        };
326        assert!(!case.evaluate(&inputs(&[("a", json!({"x": 1, "y": 2}))])));
327    }
328
329    #[test]
330    fn case_or_one_passes() {
331        let case = Case {
332            id: "ok".into(),
333            logical_operator: LogicalOp::Or,
334            conditions: vec![
335                Condition {
336                    from: "a".into(),
337                    path: "x".into(),
338                    op: CondOp::Eq,
339                    value: json!(99),
340                },
341                Condition {
342                    from: "a".into(),
343                    path: "y".into(),
344                    op: CondOp::Eq,
345                    value: json!(2),
346                },
347            ],
348        };
349        assert!(case.evaluate(&inputs(&[("a", json!({"x": 1, "y": 2}))])));
350    }
351
352    #[test]
353    fn case_empty_conditions_returns_false() {
354        let case = Case {
355            id: "ok".into(),
356            logical_operator: LogicalOp::And,
357            conditions: vec![],
358        };
359        assert!(!case.evaluate(&inputs(&[])));
360    }
361}