1use std::{collections::HashMap, path::PathBuf};
2
3use atman_dsl::ast::{Arg, CmpOp, Expr, FlowDecl, Node, Stmt, WatchAction, WatchDecl, WatchEvent};
4
5use crate::env::Env;
6use crate::error::RuntimeError;
7use crate::eval::{EvalCtx, eval_expr};
8use crate::streaming::{WarnRule, WatchRules};
9use crate::tool::{BoxFut, Tool, ToolArgs, ToolCtx, ToolRegistry};
10use crate::value::Value;
11
12fn bind_pattern(
13 pattern: &atman_dsl::ast::Pattern,
14 value: Value,
15 env: &mut Env,
16) -> Result<(), RuntimeError> {
17 use atman_dsl::ast::{Pattern, PatternFieldBinding};
18 match pattern {
19 Pattern::Ident(id) => {
20 env.bind(id.name.clone(), value);
21 Ok(())
22 }
23 Pattern::Struct { fields } => {
24 let pairs = match value {
25 Value::Struct(pairs) => pairs,
26 other => {
27 return Err(RuntimeError::TypeMismatch {
28 expected: "struct for destructuring bind".into(),
29 actual: other.kind_name().into(),
30 });
31 }
32 };
33 for field in fields {
34 let Some((_, matched)) = pairs.iter().find(|(k, _)| k == &field.source.name) else {
35 return Err(RuntimeError::MissingArg(format!(
36 "destructure: struct has no field `{}`",
37 field.source.name
38 )));
39 };
40 match &field.binding {
41 PatternFieldBinding::Same => {
42 env.bind(field.source.name.clone(), matched.clone());
43 }
44 PatternFieldBinding::Rename(target) => {
45 env.bind(target.name.clone(), matched.clone());
46 }
47 PatternFieldBinding::Nested(inner) => {
48 bind_pattern(inner, matched.clone(), env)?;
49 }
50 }
51 }
52 Ok(())
53 }
54 }
55}
56
57pub enum StmtOutcome {
58 Continue,
59 Return(Value),
60 Err(RuntimeError),
61 LoopBreak,
62 LoopContinue,
63}
64
65pub fn exec_stmts<'a>(
66 stmts: &'a [Stmt],
67 env: &'a mut Env,
68 ctx: &'a EvalCtx<'a>,
69) -> BoxFut<'a, StmtOutcome> {
70 exec_stmts_prefixed(stmts, env, ctx, String::new())
71}
72
73pub fn exec_stmts_prefixed<'a>(
74 stmts: &'a [Stmt],
75 env: &'a mut Env,
76 ctx: &'a EvalCtx<'a>,
77 prefix: String,
78) -> BoxFut<'a, StmtOutcome> {
79 Box::pin(async move {
80 let watches = collect_watches(stmts);
81 let parent_node_id = ctx.current_node_id.clone();
82 for (i, stmt) in stmts.iter().enumerate() {
83 let node_id = if prefix.is_empty() {
84 format!("{i}")
85 } else {
86 format!("{prefix}.{i}")
87 };
88 if let Some(session) = ctx.session_runtime.as_ref()
90 && let Some(turn_id) = ctx.turn_id.as_ref()
91 {
92 if let Some(inj) = session.peek_pending_l2_or_higher(turn_id) {
93 match inj.level {
94 crate::injection::InjectionLevel::L4HardStop => {
95 session.mark_injection_consumed(&inj.id);
96 emit_flow_node_start(ctx, &node_id, stmt, parent_node_id.as_deref());
97 emit_flow_node_end(
98 ctx,
99 &node_id,
100 &StmtOutcome::Continue,
101 parent_node_id.as_deref(),
102 Some("cancelled: hard stop"),
103 );
104 return StmtOutcome::Err(RuntimeError::Cancelled(
105 "hard stop from user".into(),
106 ));
107 }
108 crate::injection::InjectionLevel::L3Redirect => {
109 if let Some(target) = inj.redirect_target.clone() {
110 session.mark_injection_consumed(&inj.id);
111 return StmtOutcome::Err(RuntimeError::Redirect(target));
112 }
113 }
114 _ => {}
115 }
116 }
117 }
118 emit_flow_node_start(ctx, &node_id, stmt, parent_node_id.as_deref());
119 let stmt_ctx = ctx.with_node(&node_id);
120 let (outcome, preview) = exec_stmt(stmt, env, &stmt_ctx, &watches).await;
121 emit_flow_node_end(
122 ctx,
123 &node_id,
124 &outcome,
125 parent_node_id.as_deref(),
126 preview.as_deref(),
127 );
128 match outcome {
129 StmtOutcome::Continue => continue,
130 other => return other,
131 }
132 }
133 StmtOutcome::Continue
134 })
135}
136
137fn emit_flow_node_start(
138 ctx: &EvalCtx<'_>,
139 node_id: &str,
140 stmt: &Stmt,
141 parent_node_id: Option<&str>,
142) {
143 let (kind, label) = stmt_to_node_kind_label(stmt);
144 emit_flow_node_start_raw(ctx, node_id, kind, &label, parent_node_id);
145}
146
147fn emit_flow_node_start_raw(
148 ctx: &EvalCtx<'_>,
149 node_id: &str,
150 kind: crate::nodegraph::NodeKind,
151 label: &str,
152 parent_node_id: Option<&str>,
153) {
154 let Some(run_id) = ctx.flow_run_id.clone() else {
155 return;
156 };
157 if let Some(sink) = ctx.events {
158 sink.emit(crate::event::Event::FlowNodeStart {
159 run_id: run_id.clone(),
160 node_id: node_id.to_string(),
161 kind: kind.clone(),
162 label: label.to_string(),
163 parent_node_id: parent_node_id.map(String::from),
164 });
165 }
166 if let Some(tx) = ctx.tool_ctx.stream_tx.clone() {
167 let _ = tx.send(crate::stream::StreamFrame::FlowNodeStart {
168 run_id: run_id.0.to_string(),
169 node_id: node_id.to_string(),
170 kind,
171 label: label.to_string(),
172 parent_node_id: parent_node_id.map(String::from),
173 });
174 }
175}
176
177fn value_preview(v: &Value) -> Option<String> {
178 let raw = match v {
179 Value::Str(s) => s.clone(),
180 Value::Message(m) => {
181 let text = m.text_concat();
182 let tool_uses: Vec<String> = m
183 .parts
184 .iter()
185 .filter_map(|p| match p {
186 crate::message::MessagePart::ToolUse { name, .. } => Some(name.clone()),
187 _ => None,
188 })
189 .collect();
190 match (text.trim().is_empty(), tool_uses.is_empty()) {
191 (false, true) => text,
192 (false, false) => format!("{}\n\nā tool_uses: {}", text, tool_uses.join(", ")),
193 (true, false) => format!("ā tool_uses: {}", tool_uses.join(", ")),
194 (true, true) => return None,
195 }
196 }
197 Value::Path(p) => p.display().to_string(),
198 Value::Int(n) => n.to_string(),
199 Value::Float(n) => n.to_string(),
200 Value::Bool(b) => b.to_string(),
201 Value::Unit => return None,
202 Value::Err(e) => format!("err: {e}"),
203 Value::List(items) => format!("list[{}]", items.len()),
204 Value::Struct(fields) => format!(
205 "{{{}}}",
206 fields
207 .iter()
208 .map(|(k, _)| k.as_str())
209 .collect::<Vec<_>>()
210 .join(", ")
211 ),
212 Value::EditProposal(_) => "<edit proposal>".into(),
213 Value::Lambda { .. } => "<lambda>".into(),
214 };
215 let trimmed = raw.trim();
216 if trimmed.is_empty() {
217 None
218 } else {
219 Some(trimmed.chars().take(4000).collect())
220 }
221}
222
223fn emit_flow_node_end(
224 ctx: &EvalCtx<'_>,
225 node_id: &str,
226 outcome: &StmtOutcome,
227 parent_node_id: Option<&str>,
228 output_preview: Option<&str>,
229) {
230 let Some(run_id) = ctx.flow_run_id.clone() else {
231 return;
232 };
233 let status = match outcome {
234 StmtOutcome::Err(_) => crate::event::FlowNodeStatus::Err,
235 StmtOutcome::LoopBreak => crate::event::FlowNodeStatus::Ok,
236 StmtOutcome::LoopContinue => crate::event::FlowNodeStatus::Ok,
237 _ => crate::event::FlowNodeStatus::Ok,
238 };
239 let preview_owned = output_preview.map(String::from);
240 if let Some(sink) = ctx.events {
241 sink.emit(crate::event::Event::FlowNodeEnd {
242 run_id: run_id.clone(),
243 node_id: node_id.to_string(),
244 status: status.clone(),
245 output_preview: preview_owned.clone(),
246 });
247 }
248 if let Some(tx) = ctx.tool_ctx.stream_tx.clone() {
249 let _ = tx.send(crate::stream::StreamFrame::FlowNodeEnd {
250 run_id: run_id.0.to_string(),
251 node_id: node_id.to_string(),
252 status,
253 output_preview: preview_owned,
254 parent_node_id: parent_node_id.map(String::from),
255 });
256 }
257}
258
259fn stmt_to_node_kind_label(stmt: &Stmt) -> (crate::nodegraph::NodeKind, String) {
260 use crate::nodegraph::NodeKind;
261 match stmt {
262 Stmt::Bind { value, .. } | Stmt::Expr(value) => expr_to_node_kind_label(value),
263 Stmt::Return { .. } => (NodeKind::Return, "return".into()),
264 Stmt::When { cond, .. } => {
265 let preview = crate::nodegraph::format_expr_short(cond);
266 (
267 NodeKind::When {
268 condition_preview: preview.clone(),
269 },
270 format!("when {preview}"),
271 )
272 }
273 Stmt::Watch(_) => (NodeKind::Return, "watch".into()),
274 Stmt::Loop { .. } => (NodeKind::Loop, "loop".into()),
275 Stmt::Break => (NodeKind::Return, "break".into()),
276 Stmt::Continue => (NodeKind::Return, "continue".into()),
277 }
278}
279
280fn expr_to_node_kind_label(expr: &Expr) -> (crate::nodegraph::NodeKind, String) {
281 use crate::nodegraph::NodeKind;
282 match expr {
283 Expr::Node(Node::ToolCall { path, .. })
284 if path.len() == 2 && path[0].name == "llm" && path[1].name == "call" =>
285 {
286 (NodeKind::Llm { model: None }, "llm.call".into())
287 }
288 Expr::Node(Node::ToolCall { path, .. }) => {
289 let p = path
290 .iter()
291 .map(|s| s.name.clone())
292 .collect::<Vec<_>>()
293 .join(".");
294 (NodeKind::ToolCall { path: p.clone() }, format!("ā¶ {p}"))
295 }
296 Expr::Node(Node::Fanout { items, collect }) => (
297 NodeKind::Fanout {
298 collect: (*collect).into(),
299 },
300 format!("fanout Ć{}", items.len()),
301 ),
302 Expr::Node(Node::Subflow { name, .. }) => (
303 NodeKind::Subflow {
304 name: name.name.clone(),
305 },
306 format!("subflow({})", name.name),
307 ),
308 _ => (NodeKind::Return, "expr".into()),
309 }
310}
311
312fn collect_watches(stmts: &[Stmt]) -> HashMap<String, Vec<&WatchDecl>> {
313 let mut out: HashMap<String, Vec<&WatchDecl>> = HashMap::new();
314 for stmt in stmts {
315 if let Stmt::Watch(w) = stmt {
316 out.entry(w.target.name.clone()).or_default().push(w);
317 }
318 }
319 out
320}
321
322fn exec_stmt<'a>(
323 stmt: &'a Stmt,
324 env: &'a mut Env,
325 ctx: &'a EvalCtx<'a>,
326 watches: &'a HashMap<String, Vec<&'a WatchDecl>>,
327) -> BoxFut<'a, (StmtOutcome, Option<String>)> {
328 Box::pin(async move {
329 match stmt {
330 Stmt::Bind { name, value } => {
331 let watch_target = name.as_single_ident().map(|id| id.name.clone());
332 let v = if let Some(target) = watch_target.as_ref()
333 && let Some(ws) = watches.get(target)
334 {
335 match eval_bind_with_watches(value, env, ctx, ws).await {
336 Ok(v) => v,
337 Err(e) => return (StmtOutcome::Err(e), None),
338 }
339 } else {
340 eval_expr(value, env, ctx).await
341 };
342 if let Value::Err(e) = v {
343 return (StmtOutcome::Err(e), None);
344 }
345 let preview = value_preview(&v);
346 if let Err(e) = bind_pattern(name, v, env) {
347 return (StmtOutcome::Err(e), None);
348 }
349 (StmtOutcome::Continue, preview)
350 }
351 Stmt::When { cond, body } => {
352 let c = eval_expr(cond, env, ctx).await;
353 let truthy = match c {
354 Value::Bool(b) => b,
355 Value::Unit => false,
356 Value::Err(e) => {
357 return (StmtOutcome::Err(e), None);
358 }
359 _ => true,
360 };
361 if truthy {
362 let prefix = ctx.current_node_id.clone().unwrap_or_default();
363 (
364 exec_stmts_prefixed(body, env, ctx, prefix).await,
365 Some("true".into()),
366 )
367 } else {
368 (StmtOutcome::Continue, Some("false".into()))
369 }
370 }
371 Stmt::Return { value } => {
372 let v = eval_expr(value, env, ctx).await;
373 if let Value::Err(e) = v {
374 return (StmtOutcome::Err(e), None);
375 }
376 let preview = value_preview(&v);
377 (StmtOutcome::Return(v), preview)
378 }
379 Stmt::Expr(e) => {
380 let v = eval_expr(e, env, ctx).await;
381 if let Value::Err(err) = v {
382 return (StmtOutcome::Err(err), None);
383 }
384 let preview = value_preview(&v);
385 (StmtOutcome::Continue, preview)
386 }
387 Stmt::Watch(_) => (StmtOutcome::Continue, None),
388 Stmt::Loop { body } => {
389 let loop_node_id = ctx.current_node_id.clone();
390 let mut iter = 0u64;
391 loop {
392 let iter_id = match &loop_node_id {
393 Some(p) => format!("{p}.iter[{iter}]"),
394 None => format!("iter[{iter}]"),
395 };
396 emit_flow_node_start_raw(
397 ctx,
398 &iter_id,
399 crate::nodegraph::NodeKind::Return,
400 &format!("iteration {iter}"),
401 loop_node_id.as_deref(),
402 );
403 let iter_ctx = ctx.with_node(&iter_id);
404 let outcome = exec_stmts_prefixed(body, env, &iter_ctx, iter_id.clone()).await;
405 let preview = match &outcome {
406 StmtOutcome::LoopBreak => Some("break"),
407 StmtOutcome::LoopContinue => Some("continue"),
408 _ => None,
409 };
410 emit_flow_node_end(ctx, &iter_id, &outcome, loop_node_id.as_deref(), preview);
411 match outcome {
412 StmtOutcome::Continue => {}
413 StmtOutcome::LoopContinue => {}
414 StmtOutcome::LoopBreak => break,
415 other => return (other, Some("loop interrupted".into())),
416 }
417 iter += 1;
418 }
419 (StmtOutcome::Continue, Some("loop end".into()))
420 }
421 Stmt::Break => (StmtOutcome::LoopBreak, Some("break".into())),
422 Stmt::Continue => (StmtOutcome::LoopContinue, Some("continue".into())),
423 }
424 })
425}
426
427async fn eval_bind_with_watches(
428 expr: &Expr,
429 env: &mut Env,
430 ctx: &EvalCtx<'_>,
431 watches: &[&WatchDecl],
432) -> Result<Value, RuntimeError> {
433 let Expr::Node(Node::ToolCall { path, args }) = expr else {
434 return Ok(eval_expr(expr, env, ctx).await);
435 };
436 if !(path.len() == 2 && path[0].name == "llm" && path[1].name == "call") {
437 return Ok(eval_expr(expr, env, ctx).await);
438 };
439
440 let mut positional = Vec::new();
441 let mut named = Vec::new();
442 for arg in args {
443 match arg {
444 Arg::Positional(expr) => {
445 let value = eval_expr(expr, env, ctx).await;
446 if value.is_err() {
447 return Ok(value);
448 }
449 positional.push(value);
450 }
451 Arg::Named { name, value } => {
452 let value = eval_expr(value, env, ctx).await;
453 if value.is_err() {
454 return Ok(value);
455 }
456 named.push((name.name.clone(), value));
457 }
458 }
459 }
460
461 let registry = std::sync::Arc::new(ctx.tools.clone());
462 let call_args = ToolArgs { positional, named };
463 let mut tool_ctx = ctx
464 .tool_ctx
465 .clone()
466 .with_anchors(
467 ctx.turn_id.clone(),
468 ctx.flow_run_id.clone(),
469 ctx.events.map(|s| s.next_seq_peek()),
470 )
471 .with_registry(registry)
472 .with_current_node(ctx.current_node_id.clone())
473 .with_providers(std::sync::Arc::new(ctx.providers.clone()))
474 .with_watch_rules(collect_watch_rules(watches));
475 if let Some(sink) = ctx.events {
476 tool_ctx = tool_ctx.with_events(sink.clone());
477 }
478 if let Some(session) = ctx.session_runtime.as_ref() {
479 tool_ctx = tool_ctx
480 .with_session_messages(session.messages_full())
481 .with_session_messages_handle(session.messages_handle())
482 .with_session_runtime(session.clone())
483 .with_watch_hub(std::sync::Arc::clone(&session.watch_hub))
484 .with_flow_registry(std::sync::Arc::clone(&session.flow_registry))
485 .with_compact_lock_handle(session.compact_lock_handle());
486 }
487 if let Some(safety) = ctx.safety.cloned() {
488 tool_ctx = tool_ctx.with_safety(safety);
489 }
490 if let Some(model) = &ctx.tool_ctx.current_model {
491 tool_ctx = tool_ctx.with_current_model(model.clone());
492 }
493 if let Some(tx) = ctx.tool_ctx.stream_tx.clone() {
494 tool_ctx = tool_ctx.with_stream_tx(tx);
495 }
496
497 let result = crate::tools::llm_call::LlmCallTool
498 .call(call_args, &tool_ctx)
499 .await;
500 Ok(match result {
501 Ok(v) => v,
502 Err(e) => Value::Err(e),
503 })
504}
505
506fn render_warn_msg(msg: &Option<Expr>, fallback: &str) -> String {
507 match msg {
508 Some(Expr::Literal(atman_dsl::ast::Literal::Str(s))) => s.clone(),
509 _ => fallback.to_string(),
510 }
511}
512
513fn collect_watch_rules(watches: &[&WatchDecl]) -> WatchRules {
514 let mut rules = WatchRules::default();
515 for w in watches {
516 for on in &w.on_blocks {
517 let has_abort = on
518 .actions
519 .iter()
520 .any(|a| matches!(a, WatchAction::Abort { .. }));
521 let warn_msg_expr = on.actions.iter().find_map(|a| match a {
522 WatchAction::Warn { msg } => Some(msg),
523 _ => None,
524 });
525 if !has_abort && warn_msg_expr.is_none() {
526 continue;
527 }
528 match &on.event {
529 WatchEvent::Token { patterns } => {
530 for p in patterns {
531 if has_abort {
532 rules
533 .token_matches
534 .push((p.clone(), format!("token match: {p}")));
535 }
536 if let Some(msg_expr) = warn_msg_expr {
537 rules.warn_token.push(WarnRule {
538 target: w.target.name.clone(),
539 message: render_warn_msg(
540 msg_expr,
541 &format!("watch warn: token `{p}`"),
542 ),
543 pattern: p.clone(),
544 });
545 }
546 }
547 }
548 WatchEvent::TokensConsumed { cmp, value }
549 if matches!(cmp, CmpOp::Gt | CmpOp::Ge) =>
550 {
551 let threshold = if matches!(cmp, CmpOp::Ge) {
552 value.saturating_sub(1)
553 } else {
554 *value
555 };
556 if has_abort {
557 rules.tokens_gt = Some(match rules.tokens_gt {
558 Some(existing) => existing.min(threshold),
559 None => threshold,
560 });
561 }
562 if let Some(msg_expr) = warn_msg_expr {
563 rules.warn_tokens_gt.push((
564 threshold,
565 WarnRule {
566 target: w.target.name.clone(),
567 message: render_warn_msg(
568 msg_expr,
569 &format!("watch warn: tokens_consumed > {threshold}"),
570 ),
571 pattern: format!("tokens_consumed>{threshold}"),
572 },
573 ));
574 }
575 }
576 WatchEvent::Elapsed { cmp, duration_ms }
577 if matches!(cmp, CmpOp::Gt | CmpOp::Ge) =>
578 {
579 let threshold = if matches!(cmp, CmpOp::Ge) {
580 duration_ms.saturating_sub(1)
581 } else {
582 *duration_ms
583 };
584 if has_abort {
585 rules.elapsed_ms_gt = Some(match rules.elapsed_ms_gt {
586 Some(existing) => existing.min(threshold),
587 None => threshold,
588 });
589 }
590 if let Some(msg_expr) = warn_msg_expr {
591 rules.warn_elapsed_ms_gt.push((
592 threshold,
593 WarnRule {
594 target: w.target.name.clone(),
595 message: render_warn_msg(
596 msg_expr,
597 &format!("watch warn: elapsed > {threshold}ms"),
598 ),
599 pattern: format!("elapsed>{threshold}ms"),
600 },
601 ));
602 }
603 }
604 _ => {}
605 }
606 }
607 }
608 rules
609}
610
611pub async fn exec_flow(
612 flow: &FlowDecl,
613 args: Vec<(String, Value)>,
614 tools: &ToolRegistry,
615 tool_ctx: &ToolCtx,
616 providers: &crate::provider::ProviderRegistry,
617 source_dir: Option<PathBuf>,
618) -> Result<Value, RuntimeError> {
619 let flows = std::collections::HashMap::new();
620 exec_flow_with_siblings(
621 flow,
622 args,
623 tools,
624 tool_ctx,
625 providers,
626 &flows,
627 None,
628 None,
629 None,
630 None,
631 tokio_util::sync::CancellationToken::new(),
632 None,
633 source_dir,
634 )
635 .await
636}
637
638#[allow(clippy::too_many_arguments)]
639pub async fn exec_flow_with_siblings(
640 flow: &FlowDecl,
641 args: Vec<(String, Value)>,
642 tools: &ToolRegistry,
643 tool_ctx: &ToolCtx,
644 providers: &crate::provider::ProviderRegistry,
645 flows: &std::collections::HashMap<String, FlowDecl>,
646 events: Option<&crate::event::EventSink>,
647 turn_id: Option<crate::event::TurnId>,
648 flow_run_id: Option<crate::event::FlowRunId>,
649 session: Option<std::sync::Arc<crate::session::Session>>,
650 flow_cancel: tokio_util::sync::CancellationToken,
651 safety: Option<&crate::safety::SafetyConfig>,
652 source_dir: Option<PathBuf>,
653) -> Result<Value, RuntimeError> {
654 let ctx = EvalCtx {
655 tools,
656 tool_ctx,
657 providers,
658 flows,
659 contract: flow.contract.as_ref(),
660 events,
661 turn_id,
662 flow_run_id,
663 session_runtime: session,
664 flow_cancel,
665 safety,
666 current_node_id: None,
667 source_dir,
668 };
669 let mut env = Env::new();
670 let provided: std::collections::HashSet<String> = args.iter().map(|(n, _)| n.clone()).collect();
671 for (name, value) in args {
672 env.bind(name, value);
673 }
674 for p in &flow.params {
675 if !provided.contains(&p.name.name) {
676 if let Some(default) = &p.default {
677 let val = crate::eval::eval_expr(default, &env, &ctx).await;
678 env.bind(p.name.name.clone(), val);
679 }
680 }
681 }
682 let raw_outcome = exec_stmts(&flow.body, &mut env, &ctx).await;
683 match raw_outcome {
684 StmtOutcome::Return(v) => Ok(v),
685 StmtOutcome::Err(e) => Err(e),
686 StmtOutcome::Continue => Ok(Value::Unit),
687 StmtOutcome::LoopBreak => Err(RuntimeError::ToolFailed("break outside loop".into())),
688 StmtOutcome::LoopContinue => Err(RuntimeError::ToolFailed("continue outside loop".into())),
689 }
690}
691
692#[cfg(test)]
693mod tests {
694 use super::*;
695 use atman_dsl::parse::parse_file;
696
697 async fn run(src: &str, args: Vec<(String, Value)>) -> Result<Value, RuntimeError> {
698 let file = parse_file(src).expect("parse test src");
699 let tools = ToolRegistry::new();
700 let tool_ctx = ToolCtx::new();
701 let providers = crate::provider::ProviderRegistry::new();
702 exec_flow(&file.flows[0], args, &tools, &tool_ctx, &providers, None).await
703 }
704
705 #[tokio::test]
706 async fn bind_and_return() {
707 let out = run(
708 r#"flow t() -> Int {
709 x = 1
710 y = x + 2
711 return y
712}
713"#,
714 vec![],
715 )
716 .await
717 .unwrap();
718 assert!(matches!(out, Value::Int(3)));
719 }
720
721 #[tokio::test]
722 async fn when_true_executes_body() {
723 let out = run(
724 r#"flow t() -> Int {
725 x = 5
726 when x > 3 {
727 return 42
728 }
729 return 0
730}
731"#,
732 vec![],
733 )
734 .await
735 .unwrap();
736 assert!(matches!(out, Value::Int(42)));
737 }
738
739 #[tokio::test]
740 async fn when_false_skips_body() {
741 let out = run(
742 r#"flow t() -> Int {
743 x = 1
744 when x > 3 {
745 return 42
746 }
747 return 0
748}
749"#,
750 vec![],
751 )
752 .await
753 .unwrap();
754 assert!(matches!(out, Value::Int(0)));
755 }
756
757 #[tokio::test]
758 async fn err_in_bind_stops_flow() {
759 let err = run(
760 r#"flow t() -> Int {
761 x = missing
762 return 1
763}
764"#,
765 vec![],
766 )
767 .await
768 .unwrap_err();
769 assert!(matches!(err, RuntimeError::UndefinedVar(n) if n == "missing"));
770 }
771
772 #[tokio::test]
773 async fn flow_args_bind_before_body() {
774 let out = run(
775 r#"flow t() -> Int {
776 return n + 1
777}
778"#,
779 vec![("n".into(), Value::Int(4))],
780 )
781 .await
782 .unwrap();
783 assert!(matches!(out, Value::Int(5)));
784 }
785
786 #[tokio::test]
787 async fn when_cond_unit_is_falsy() {
788 let out = run(
789 r#"flow t() -> Int {
790 when 1 {
791 return 1
792 }
793 return 0
794}
795"#,
796 vec![],
797 )
798 .await
799 .unwrap();
800 assert!(matches!(out, Value::Int(1)));
801 }
802
803 #[tokio::test]
804 async fn when_cond_struct_is_truthy() {
805 let out = run(
806 r#"flow t() -> Int {
807 x = { a: 1 }
808 when x {
809 return 42
810 }
811 return 0
812}
813"#,
814 vec![],
815 )
816 .await
817 .unwrap();
818 assert!(matches!(out, Value::Int(42)));
819 }
820
821 #[tokio::test]
822 async fn flow_falls_through_to_unit_without_return() {
823 let out = run(
824 r#"flow t() {
825 x = 1
826}
827"#,
828 vec![],
829 )
830 .await
831 .unwrap();
832 assert!(matches!(out, Value::Unit));
833 }
834}