Skip to main content

dataflow_rs/engine/functions/
config.rs

1use crate::engine::error::Result;
2use crate::engine::executor::ArenaContext;
3use crate::engine::functions::filter::FilterConfig;
4use crate::engine::functions::integration::{EnrichConfig, HttpCallConfig, PublishKafkaConfig};
5use crate::engine::functions::log::LogConfig;
6use crate::engine::functions::map::MapConfig;
7use crate::engine::functions::parse::{
8    ParseConfig, execute_parse_json_in_arena, execute_parse_xml,
9};
10use crate::engine::functions::publish::{PublishConfig, execute_publish_json, execute_publish_xml};
11use crate::engine::functions::validation::ValidationConfig;
12use crate::engine::message::{Change, Message};
13use crate::engine::task_outcome::TaskOutcome;
14use datalogic_rs::Engine;
15use serde::de::DeserializeOwned;
16use serde::{Deserialize, Deserializer};
17use serde_json::Value;
18use std::any::Any;
19use std::sync::Arc;
20
21/// Pre-parsed typed input for a `FunctionConfig::Custom` task. Populated by
22/// the engine at `Engine::new()` time by calling the registered
23/// `AsyncFunctionHandler::parse_input` for the named function. Cached as
24/// `Arc<dyn Any>` so the dispatch path can hand it to the handler with a
25/// single `downcast_ref` (O(1)) and zero per-message deserialization cost.
26///
27/// The wrapper exists because `dyn Any` does not implement `Debug`, which
28/// would otherwise prevent `#[derive(Debug)]` on `FunctionConfig`.
29#[derive(Clone)]
30pub struct CompiledCustomInput(pub Arc<dyn Any + Send + Sync>);
31
32impl CompiledCustomInput {
33    /// Borrow the inner value as `&(dyn Any + Send + Sync)` for handoff to
34    /// `DynAsyncFunctionHandler::dyn_execute`.
35    #[inline]
36    pub fn as_any(&self) -> &(dyn Any + Send + Sync) {
37        &*self.0
38    }
39}
40
41impl std::fmt::Debug for CompiledCustomInput {
42    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43        f.write_str("CompiledCustomInput(<opaque>)")
44    }
45}
46
47/// Enum containing all possible function configurations.
48///
49/// Deserialization dispatches on the `name` field: each known built-in
50/// (`map`, `validate`, `parse_json`, …) parses its `input` strictly into the
51/// matching typed config and errors with a clear envelope (`config for
52/// function 'map': missing field 'mappings'`). Unknown names fall through
53/// to [`FunctionConfig::Custom`], which preserves the raw input for a
54/// user-registered handler to consume at engine construction time.
55#[derive(Debug, Clone)]
56pub enum FunctionConfig {
57    Map {
58        name: MapName,
59        input: MapConfig,
60    },
61    Validation {
62        name: ValidationName,
63        input: ValidationConfig,
64    },
65    ParseJson {
66        name: ParseJsonName,
67        input: ParseConfig,
68    },
69    ParseXml {
70        name: ParseXmlName,
71        input: ParseConfig,
72    },
73    PublishJson {
74        name: PublishJsonName,
75        input: PublishConfig,
76    },
77    PublishXml {
78        name: PublishXmlName,
79        input: PublishConfig,
80    },
81    Filter {
82        name: FilterName,
83        input: FilterConfig,
84    },
85    Log {
86        name: LogName,
87        input: LogConfig,
88    },
89    HttpCall {
90        name: HttpCallName,
91        input: HttpCallConfig,
92    },
93    Enrich {
94        name: EnrichName,
95        input: EnrichConfig,
96    },
97    PublishKafka {
98        name: PublishKafkaName,
99        input: PublishKafkaConfig,
100    },
101    /// For custom or unknown functions, store raw input and a slot for the
102    /// pre-parsed typed value populated at engine construction time.
103    Custom {
104        name: String,
105        input: Value,
106        /// Pre-parsed `<RegisteredHandler as AsyncFunctionHandler>::Input`,
107        /// boxed as `dyn Any`. Set by the engine after handler registration;
108        /// `None` on initial deserialization. `FunctionConfig` is
109        /// deserialize-only — round-tripping a workflow through JSON
110        /// re-parses on the next `Engine::new()` call.
111        compiled_input: Option<CompiledCustomInput>,
112    },
113}
114
115#[derive(Debug, Clone, Deserialize)]
116#[serde(rename_all = "lowercase")]
117pub enum MapName {
118    Map,
119}
120
121#[derive(Debug, Clone, Deserialize, PartialEq)]
122#[serde(rename_all = "lowercase")]
123pub enum ValidationName {
124    Validation,
125    Validate,
126}
127
128#[derive(Debug, Clone, Deserialize, PartialEq)]
129#[serde(rename_all = "snake_case")]
130pub enum ParseJsonName {
131    ParseJson,
132}
133
134#[derive(Debug, Clone, Deserialize, PartialEq)]
135#[serde(rename_all = "snake_case")]
136pub enum ParseXmlName {
137    ParseXml,
138}
139
140#[derive(Debug, Clone, Deserialize, PartialEq)]
141#[serde(rename_all = "snake_case")]
142pub enum PublishJsonName {
143    PublishJson,
144}
145
146#[derive(Debug, Clone, Deserialize, PartialEq)]
147#[serde(rename_all = "snake_case")]
148pub enum PublishXmlName {
149    PublishXml,
150}
151
152#[derive(Debug, Clone, Deserialize, PartialEq)]
153#[serde(rename_all = "lowercase")]
154pub enum FilterName {
155    Filter,
156}
157
158#[derive(Debug, Clone, Deserialize, PartialEq)]
159#[serde(rename_all = "lowercase")]
160pub enum LogName {
161    Log,
162}
163
164#[derive(Debug, Clone, Deserialize, PartialEq)]
165#[serde(rename_all = "snake_case")]
166pub enum HttpCallName {
167    HttpCall,
168}
169
170#[derive(Debug, Clone, Deserialize, PartialEq)]
171#[serde(rename_all = "snake_case")]
172pub enum EnrichName {
173    Enrich,
174}
175
176#[derive(Debug, Clone, Deserialize, PartialEq)]
177#[serde(rename_all = "snake_case")]
178pub enum PublishKafkaName {
179    PublishKafka,
180}
181
182/// Every function name that [`FunctionConfig`]'s deserializer resolves to a
183/// typed built-in variant instead of [`FunctionConfig::Custom`].
184///
185/// Used in error messages and as the discriminator for [`FunctionConfig`]
186/// deserialization. Kept in one place so adding a new built-in updates the
187/// dispatch, the error suggestion list, and the docs in lockstep.
188///
189/// Public so a service layer that gates workflow authoring on a closed
190/// function set can derive that set rather than copy it. Membership here is
191/// **not** the same fact as "this engine can run it" — see
192/// [`builtin_function_kind`], and prefer it for that question.
193///
194/// # Stability
195///
196/// Names are only added in a minor release and only removed in a major one, so
197/// a caller may treat a name that appears here as durable. **Ordering is not
198/// meaningful** and may change without notice; treat this as a set. Note that
199/// `validation` and `validate` are both present and both resolve to
200/// [`FunctionConfig::Validation`].
201pub const BUILTIN_FUNCTION_NAMES: &[&str] = &[
202    "map",
203    "validation",
204    "validate",
205    "parse_json",
206    "parse_xml",
207    "publish_json",
208    "publish_xml",
209    "filter",
210    "log",
211    "http_call",
212    "enrich",
213    "publish_kafka",
214];
215
216/// How a built-in function reaches an implementation.
217///
218/// This is the programmatic form of the distinction
219/// `docs/src/built-in-functions/integrations.md` draws in prose: some built-ins
220/// this crate executes itself, and for others it only supplies a config schema.
221///
222/// Deliberately **not** `#[non_exhaustive]`. A caller matching on this is
223/// usually deciding whether to accept a workflow definition, and if a third
224/// kind is ever added that decision needs revisiting — a compile error at every
225/// match site is the correct signal, not a silent fall-through to a `_` arm.
226#[derive(Debug, Clone, Copy, PartialEq, Eq)]
227pub enum BuiltinKind {
228    /// Executed by this crate. Needs no registration; an engine can always run it.
229    SelfContained,
230    /// Deserializes into a typed built-in variant — so `Engine::new` accepts it
231    /// without complaint — but dispatches to a handler registered under the same
232    /// name, and fails with [`crate::DataflowError::FunctionNotFound`] on the
233    /// first message if none is registered.
234    ///
235    /// `http_call`, `enrich` and `publish_kafka` are these. A validator that
236    /// treats them like [`BuiltinKind::SelfContained`] will green-light a
237    /// workflow that builds cleanly and then fails every request.
238    RequiresHandler,
239}
240
241/// Classify `name` as a built-in function.
242///
243/// `None` means it is not a built-in: it lands in [`FunctionConfig::Custom`] and
244/// needs `Engine::builder().register(name, handler)`.
245///
246/// Matching is exact, the same as the deserializer dispatch — `"HTTP_CALL"` and
247/// `"htttp_call"` are both `None`.
248///
249/// ```
250/// use dataflow_rs::{BuiltinKind, builtin_function_kind};
251///
252/// assert_eq!(builtin_function_kind("map"), Some(BuiltinKind::SelfContained));
253/// assert_eq!(builtin_function_kind("enrich"), Some(BuiltinKind::RequiresHandler));
254/// assert_eq!(builtin_function_kind("my_handler"), None);
255/// ```
256pub fn builtin_function_kind(name: &str) -> Option<BuiltinKind> {
257    match name {
258        "map" | "validation" | "validate" | "parse_json" | "parse_xml" | "publish_json"
259        | "publish_xml" | "filter" | "log" => Some(BuiltinKind::SelfContained),
260        "http_call" | "enrich" | "publish_kafka" => Some(BuiltinKind::RequiresHandler),
261        _ => None,
262    }
263}
264
265/// Whether `name` deserializes to a typed built-in variant at all.
266///
267/// Equivalent to `builtin_function_kind(name).is_some()`. This answers "is this
268/// a name the crate special-cases", **not** "can this engine run it" — a
269/// [`BuiltinKind::RequiresHandler`] name returns `true` here whether or not a
270/// handler is registered.
271#[inline]
272pub fn is_builtin_function(name: &str) -> bool {
273    builtin_function_kind(name).is_some()
274}
275
276/// One function an engine will actually dispatch.
277///
278/// Yielded by [`crate::Engine::dispatchable_functions`] and
279/// [`crate::EngineBuilder::dispatchable_functions`]. Together with
280/// [`BuiltinKind`] this is the whole authoring-side vocabulary: `kind` says how
281/// the name reaches an implementation, and `aliases` says which other spellings
282/// resolve to the same one.
283#[derive(Debug, Clone, PartialEq, Eq)]
284pub struct DispatchableFunction<'a> {
285    /// The canonical name. Alternative spellings are listed in
286    /// [`Self::aliases`] rather than yielded as separate entries.
287    pub name: &'a str,
288    /// `Some(..)` for a built-in; `None` for a name backed only by a registered
289    /// custom handler.
290    ///
291    /// The `Option` deliberately mirrors [`builtin_function_kind`], where
292    /// `None` already means "not a built-in". [`BuiltinKind`] is not
293    /// `#[non_exhaustive]` on purpose, so widening it with a third variant
294    /// would break every downstream `match`; this carries the same fact
295    /// additively.
296    pub kind: Option<BuiltinKind>,
297    /// Other accepted spellings of this same function.
298    ///
299    /// `validate` carries `["validation"]`; every other name is empty today.
300    /// An alias never appears as its own entry, but
301    /// [`crate::Engine::can_dispatch`] does accept it — a task named
302    /// `validation` really does execute.
303    pub aliases: &'static [&'static str],
304}
305
306/// Aliases of `validate`. Both spellings deserialize to
307/// [`FunctionConfig::Validation`]; `function_name()` reports `"validate"`, which
308/// makes that the canonical one.
309const VALIDATE_ALIASES: &[&str] = &["validation"];
310
311/// The empty alias list, so [`builtin_aliases`] can return a `'static` slice for
312/// every name without allocating.
313const NO_ALIASES: &[&str] = &[];
314
315/// Map a built-in spelling to the canonical one for its function.
316///
317/// Every name is its own canonical form except `validation`, which is an alias
318/// of `validate`. Deliberately a match rather than a table: the alias relation
319/// is the *only* new fact here, and a table of canonical names would be a
320/// second copy of [`BUILTIN_FUNCTION_NAMES`] to keep in sync.
321pub(crate) fn canonical_builtin_name(name: &str) -> &str {
322    match name {
323        "validation" => "validate",
324        other => other,
325    }
326}
327
328/// The alternative spellings of `canonical`, which must already be a canonical
329/// name. Paired with [`canonical_builtin_name`]; the two are pinned to each
330/// other and to [`BUILTIN_FUNCTION_NAMES`] by `aliases_and_canonical_names_agree`.
331pub(crate) fn builtin_aliases(canonical: &str) -> &'static [&'static str] {
332    match canonical {
333        "validate" => VALIDATE_ALIASES,
334        _ => NO_ALIASES,
335    }
336}
337
338/// Whether a registry containing `registry`'s keys will dispatch `name`.
339///
340/// The single definition of "this engine can run it": a
341/// [`BuiltinKind::SelfContained`] built-in always can, and every other name —
342/// [`BuiltinKind::RequiresHandler`] built-ins and custom names alike — can only
343/// if a handler is registered under it. `TaskExecutor::has_function` and the
344/// two public `can_dispatch` methods all route through here so the predicate
345/// the engine dispatches on and the predicate hosts query cannot drift.
346///
347/// Generic over the map's value type so this module needs no dependency on
348/// `BoxedFunctionHandler`.
349pub(crate) fn can_dispatch_in<V>(
350    registry: &std::collections::HashMap<String, V>,
351    name: &str,
352) -> bool {
353    match builtin_function_kind(name) {
354        Some(BuiltinKind::SelfContained) => true,
355        // RequiresHandler and Custom alike: only if a handler was registered.
356        _ => registry.contains_key(name),
357    }
358}
359
360/// Every function a registry with these keys will dispatch.
361///
362/// Built-ins are yielded only when they are their own canonical name, which
363/// performs the alias grouping with no list to maintain. `RequiresHandler`
364/// built-ins appear only when backed by a registration; custom keys appear with
365/// `kind: None`.
366///
367/// A key that names a [`BuiltinKind::SelfContained`] built-in is skipped on the
368/// registry side — it is already yielded as a built-in, and the registration
369/// itself is inert (the deserializer routes `map` to [`FunctionConfig::Map`],
370/// which this crate executes without consulting the registry).
371pub(crate) fn dispatchable_functions_in<V>(
372    registry: &std::collections::HashMap<String, V>,
373) -> impl Iterator<Item = DispatchableFunction<'_>> {
374    let builtins = BUILTIN_FUNCTION_NAMES
375        .iter()
376        .copied()
377        // Skip aliases: `validation` is reported under `validate`.
378        .filter(|name| canonical_builtin_name(name) == *name)
379        .filter_map(move |name| match builtin_function_kind(name) {
380            // Always runnable, registered or not.
381            kind @ Some(BuiltinKind::SelfContained) => Some(DispatchableFunction {
382                name,
383                kind,
384                aliases: builtin_aliases(name),
385            }),
386            // Config schema only — present iff a handler backs it.
387            kind @ Some(BuiltinKind::RequiresHandler) if registry.contains_key(name) => {
388                Some(DispatchableFunction {
389                    name,
390                    kind,
391                    aliases: builtin_aliases(name),
392                })
393            }
394            _ => None,
395        });
396
397    let customs = registry
398        .keys()
399        .map(String::as_str)
400        // Built-in names are handled above; a registration under one is either
401        // already counted (RequiresHandler) or inert (SelfContained).
402        .filter(|name| builtin_function_kind(name).is_none())
403        .map(|name| DispatchableFunction {
404            name,
405            kind: None,
406            aliases: NO_ALIASES,
407        });
408
409    builtins.chain(customs)
410}
411
412/// Parse a `serde_json::Value` into a typed config, wrapping any error in a
413/// "config for function '<func>': …" envelope. Strips the trailing
414/// `" at line 0 column 0"` that `serde_json::from_value` always appends
415/// (since the source `Value` has no source-text location); the outer
416/// deserializer re-attaches the real source location when this error
417/// bubbles up to e.g. `Workflow::from_json`.
418fn parse_function_input<T, E>(func: &str, input: Value) -> std::result::Result<T, E>
419where
420    T: DeserializeOwned,
421    E: serde::de::Error,
422{
423    serde_json::from_value::<T>(input).map_err(|err| {
424        let raw = err.to_string();
425        let trimmed = raw
426            .rsplit_once(" at line ")
427            .map(|(head, _)| head)
428            .unwrap_or(&raw);
429        E::custom(format!("config for function '{func}': {trimmed}"))
430    })
431}
432
433impl<'de> Deserialize<'de> for FunctionConfig {
434    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
435    where
436        D: Deserializer<'de>,
437    {
438        // Tag-only intermediate. Format-agnostic: works for any deserializer
439        // that produces `String`/`serde_json::Value`. The strict typed parse
440        // happens in the dispatch below.
441        #[derive(Deserialize)]
442        struct Raw {
443            name: String,
444            input: Value,
445        }
446
447        let Raw { name, input } = Raw::deserialize(deserializer)?;
448
449        Ok(match name.as_str() {
450            "map" => FunctionConfig::Map {
451                name: MapName::Map,
452                input: parse_function_input("map", input)?,
453            },
454            "validate" => FunctionConfig::Validation {
455                name: ValidationName::Validate,
456                input: parse_function_input("validate", input)?,
457            },
458            "validation" => FunctionConfig::Validation {
459                name: ValidationName::Validation,
460                input: parse_function_input("validation", input)?,
461            },
462            "parse_json" => FunctionConfig::ParseJson {
463                name: ParseJsonName::ParseJson,
464                input: parse_function_input("parse_json", input)?,
465            },
466            "parse_xml" => FunctionConfig::ParseXml {
467                name: ParseXmlName::ParseXml,
468                input: parse_function_input("parse_xml", input)?,
469            },
470            "publish_json" => FunctionConfig::PublishJson {
471                name: PublishJsonName::PublishJson,
472                input: parse_function_input("publish_json", input)?,
473            },
474            "publish_xml" => FunctionConfig::PublishXml {
475                name: PublishXmlName::PublishXml,
476                input: parse_function_input("publish_xml", input)?,
477            },
478            "filter" => FunctionConfig::Filter {
479                name: FilterName::Filter,
480                input: parse_function_input("filter", input)?,
481            },
482            "log" => FunctionConfig::Log {
483                name: LogName::Log,
484                input: parse_function_input("log", input)?,
485            },
486            "http_call" => FunctionConfig::HttpCall {
487                name: HttpCallName::HttpCall,
488                input: parse_function_input("http_call", input)?,
489            },
490            "enrich" => FunctionConfig::Enrich {
491                name: EnrichName::Enrich,
492                input: parse_function_input("enrich", input)?,
493            },
494            "publish_kafka" => FunctionConfig::PublishKafka {
495                name: PublishKafkaName::PublishKafka,
496                input: parse_function_input("publish_kafka", input)?,
497            },
498            _ => FunctionConfig::Custom {
499                name,
500                input,
501                compiled_input: None,
502            },
503        })
504    }
505}
506
507/// Refresh the arena's `"data"` slot on `Ok`, then return `result` unchanged.
508/// Shared by the three built-ins (`parse_xml`, `publish_json`, `publish_xml`)
509/// that write through `set_nested_value` on the owned context rather than the
510/// arena — the arena cache would otherwise miss that write for the rest of the
511/// sync stretch. On `Err` the context didn't change, so the cache is already
512/// in sync and is left alone.
513fn refresh_data_on_success(
514    message: &Message,
515    arena_ctx: &mut ArenaContext<'_>,
516    result: Result<(TaskOutcome, Vec<Change>)>,
517) -> Result<(TaskOutcome, Vec<Change>)> {
518    if result.is_ok() {
519        arena_ctx.refresh_for_path(&message.context, "data");
520    }
521    result
522}
523
524impl FunctionConfig {
525    /// Get the function name for this configuration
526    pub fn function_name(&self) -> &str {
527        match self {
528            FunctionConfig::Map { .. } => "map",
529            FunctionConfig::Validation { .. } => "validate",
530            FunctionConfig::ParseJson { .. } => "parse_json",
531            FunctionConfig::ParseXml { .. } => "parse_xml",
532            FunctionConfig::PublishJson { .. } => "publish_json",
533            FunctionConfig::PublishXml { .. } => "publish_xml",
534            FunctionConfig::Filter { .. } => "filter",
535            FunctionConfig::Log { .. } => "log",
536            FunctionConfig::HttpCall { .. } => "http_call",
537            FunctionConfig::Enrich { .. } => "enrich",
538            FunctionConfig::PublishKafka { .. } => "publish_kafka",
539            FunctionConfig::Custom { name, .. } => name,
540        }
541    }
542
543    /// Whether this is a synchronous built-in. Synchronous built-ins can share
544    /// a single `ArenaContext` lifetime across consecutive tasks within a
545    /// workflow without crossing any `.await` point.
546    ///
547    /// Must match the variants handled in [`Self::try_execute_in_arena`]; the
548    /// debug assertion below ties the two together so they can't drift.
549    /// The connector this task references, if any.
550    ///
551    /// The three integration variants return their typed `connector` field
552    /// verbatim — including an empty string. Whether an empty connector name is
553    /// acceptable is a validation question for the host, not this accessor's.
554    ///
555    /// [`FunctionConfig::Custom`] returns `input["connector"]` when that key
556    /// holds a string. That is the convention for service-registered integration
557    /// handlers, mirroring the three built-in schemas; a `Custom` input whose
558    /// `connector` key means something else is a false positive, and the
559    /// convention is the only contract available.
560    ///
561    /// Usable without a [`crate::Task`]: `FunctionConfig` deserializes from a
562    /// bare `{"name": .., "input": ..}` object, so a caller holding only a task's
563    /// `function` value does not need to satisfy `Task`'s required `id` and
564    /// `name`.
565    ///
566    /// The match is exhaustive on purpose — a future connector-bearing config
567    /// cannot be silently omitted.
568    pub fn connector(&self) -> Option<&str> {
569        match self {
570            FunctionConfig::HttpCall { input, .. } => Some(&input.connector),
571            FunctionConfig::Enrich { input, .. } => Some(&input.connector),
572            FunctionConfig::PublishKafka { input, .. } => Some(&input.connector),
573            FunctionConfig::Custom { input, .. } => input.get("connector").and_then(Value::as_str),
574            FunctionConfig::Map { .. }
575            | FunctionConfig::Validation { .. }
576            | FunctionConfig::ParseJson { .. }
577            | FunctionConfig::ParseXml { .. }
578            | FunctionConfig::PublishJson { .. }
579            | FunctionConfig::PublishXml { .. }
580            | FunctionConfig::Filter { .. }
581            | FunctionConfig::Log { .. } => None,
582        }
583    }
584
585    pub fn is_sync_builtin(&self) -> bool {
586        matches!(
587            self,
588            FunctionConfig::Map { .. }
589                | FunctionConfig::Validation { .. }
590                | FunctionConfig::ParseJson { .. }
591                | FunctionConfig::ParseXml { .. }
592                | FunctionConfig::PublishJson { .. }
593                | FunctionConfig::PublishXml { .. }
594                | FunctionConfig::Filter { .. }
595                | FunctionConfig::Log { .. }
596        )
597    }
598
599    /// If this config is a sync built-in, execute it against the supplied
600    /// arena context and return `Some(result)`. Otherwise return `None` —
601    /// the workflow executor uses that as the signal to break the sync
602    /// stretch and dispatch the task on the async path instead.
603    ///
604    /// `mapping_snapshots` is only consulted by the `Map` variant — when
605    /// `Some`, the map function pushes a `serde_json::Value` snapshot of the
606    /// context before each mapping (for the trace surface). All other
607    /// variants ignore it. Pass `None` from the production path.
608    ///
609    /// This is the single source of truth for the sync-stretch dispatch:
610    /// adding a new sync built-in only requires adding an arm here (and the
611    /// matching variant to `is_sync_builtin` above).
612    pub(crate) fn try_execute_in_arena<'arena>(
613        &'arena self,
614        message: &mut Message,
615        arena_ctx: &mut ArenaContext<'arena>,
616        engine: &Arc<Engine>,
617        mapping_snapshots: Option<&mut Vec<Value>>,
618    ) -> Option<Result<(TaskOutcome, Vec<Change>)>> {
619        match self {
620            FunctionConfig::Map { input, .. } => {
621                Some(input.execute_in_arena(message, arena_ctx, engine, mapping_snapshots))
622            }
623            FunctionConfig::Validation { input, .. } => {
624                Some(input.execute_in_arena(message, arena_ctx, engine))
625            }
626            FunctionConfig::ParseJson { input, .. } => {
627                Some(execute_parse_json_in_arena(message, input, arena_ctx))
628            }
629            FunctionConfig::ParseXml { input, .. } => {
630                // parse_xml/publish_json/publish_xml all write through
631                // `set_nested_value` on the owned context rather than the
632                // arena, so the arena's "data" slot needs a manual refresh —
633                // but only on success; on error the context didn't change
634                // either, so the arena cache is still in sync.
635                let result = execute_parse_xml(message, input);
636                Some(refresh_data_on_success(message, arena_ctx, result))
637            }
638            FunctionConfig::PublishJson { input, .. } => {
639                let result = execute_publish_json(message, input);
640                Some(refresh_data_on_success(message, arena_ctx, result))
641            }
642            FunctionConfig::PublishXml { input, .. } => {
643                let result = execute_publish_xml(message, input);
644                Some(refresh_data_on_success(message, arena_ctx, result))
645            }
646            FunctionConfig::Filter { input, .. } => {
647                Some(input.execute_in_arena(message, arena_ctx, engine))
648            }
649            FunctionConfig::Log { input, .. } => {
650                Some(input.execute_in_arena(message, arena_ctx, engine))
651            }
652            FunctionConfig::HttpCall { .. }
653            | FunctionConfig::Enrich { .. }
654            | FunctionConfig::PublishKafka { .. }
655            | FunctionConfig::Custom { .. } => None,
656        }
657    }
658}
659
660#[cfg(test)]
661mod tests {
662    use super::*;
663    use serde_json::json;
664
665    fn parse(value: serde_json::Value) -> std::result::Result<FunctionConfig, serde_json::Error> {
666        serde_json::from_value(value)
667    }
668
669    #[test]
670    fn map_with_valid_config_deserializes_to_map_variant() {
671        let cfg = parse(json!({
672            "name": "map",
673            "input": {
674                "mappings": [
675                    { "path": "data.x", "logic": { "var": "data.y" } }
676                ]
677            }
678        }))
679        .expect("valid map config should deserialize");
680        assert!(matches!(cfg, FunctionConfig::Map { .. }));
681    }
682
683    #[test]
684    fn map_with_missing_mappings_gives_clear_error() {
685        let err = parse(json!({
686            "name": "map",
687            "input": {}
688        }))
689        .expect_err("map with empty input should fail");
690        let msg = err.to_string();
691        assert!(
692            msg.starts_with("config for function 'map':"),
693            "error should be prefixed with function envelope, got: {msg}"
694        );
695        assert!(
696            msg.contains("mappings"),
697            "error should mention the missing field, got: {msg}"
698        );
699    }
700
701    #[test]
702    fn map_with_wrong_input_shape_gives_clear_error() {
703        let err = parse(json!({
704            "name": "map",
705            "input": { "mappings": "not an array" }
706        }))
707        .expect_err("map with bad mappings type should fail");
708        let msg = err.to_string();
709        assert!(
710            msg.starts_with("config for function 'map':"),
711            "error should be prefixed with function envelope, got: {msg}"
712        );
713    }
714
715    #[test]
716    fn validation_accepts_both_spellings() {
717        for name in ["validate", "validation"] {
718            let cfg = parse(json!({
719                "name": name,
720                "input": { "rules": [] }
721            }))
722            .unwrap_or_else(|e| panic!("'{name}' should deserialize: {e}"));
723            assert!(matches!(cfg, FunctionConfig::Validation { .. }));
724        }
725    }
726
727    #[test]
728    fn unknown_name_falls_through_to_custom() {
729        let cfg = parse(json!({
730            "name": "my_custom_handler",
731            "input": { "anything": "goes" }
732        }))
733        .expect("unknown name should produce Custom");
734        match cfg {
735            FunctionConfig::Custom {
736                name,
737                compiled_input,
738                ..
739            } => {
740                assert_eq!(name, "my_custom_handler");
741                assert!(compiled_input.is_none());
742            }
743            other => panic!("expected Custom, got {other:?}"),
744        }
745    }
746
747    #[test]
748    fn missing_name_field_errors() {
749        let err = parse(json!({ "input": {} })).expect_err("missing name should fail");
750        assert!(err.to_string().contains("name"));
751    }
752
753    #[test]
754    fn missing_input_field_errors() {
755        let err = parse(json!({ "name": "map" })).expect_err("missing input should fail");
756        assert!(err.to_string().contains("input"));
757    }
758
759    #[test]
760    fn http_call_with_missing_connector_gives_clear_error() {
761        let err = parse(json!({
762            "name": "http_call",
763            "input": { "method": "GET" }
764        }))
765        .expect_err("http_call needs connector");
766        let msg = err.to_string();
767        assert!(
768            msg.starts_with("config for function 'http_call':"),
769            "error should be prefixed with function envelope, got: {msg}"
770        );
771        assert!(msg.contains("connector"));
772    }
773
774    #[test]
775    fn builtin_names_never_fall_through_to_custom() {
776        // Every name in BUILTIN_FUNCTION_NAMES must be handled by the
777        // dispatch — either parsing successfully or failing with the
778        // envelope. None should silently land in Custom.
779        for name in BUILTIN_FUNCTION_NAMES {
780            let cfg = parse(json!({
781                "name": name,
782                "input": {}
783            }));
784            match cfg {
785                Ok(c) => assert!(
786                    !matches!(c, FunctionConfig::Custom { .. }),
787                    "name '{name}' silently fell through to Custom"
788                ),
789                Err(e) => assert!(
790                    e.to_string()
791                        .starts_with(&format!("config for function '{name}':")),
792                    "name '{name}' failed without envelope: {e}"
793                ),
794            }
795
796            // The const and the classifier cannot drift: anything listed as a
797            // built-in must classify as one.
798            assert!(
799                builtin_function_kind(name).is_some(),
800                "name '{name}' is in BUILTIN_FUNCTION_NAMES but classifies as None"
801            );
802        }
803    }
804
805    /// Parse an `http_call` task and hand back its typed config.
806    fn parse_http_call(
807        input: serde_json::Value,
808    ) -> std::result::Result<HttpCallConfig, serde_json::Error> {
809        match parse(json!({ "name": "http_call", "input": input }))? {
810            FunctionConfig::HttpCall { input, .. } => Ok(input),
811            other => panic!("expected HttpCall, got {other:?}"),
812        }
813    }
814
815    #[test]
816    fn http_call_response_path_is_read_under_its_own_name() {
817        let cfg = parse_http_call(json!({ "connector": "c", "response_path": "data.x" }))
818            .expect("response_path should parse");
819        assert_eq!(cfg.response_path.as_deref(), Some("data.x"));
820    }
821
822    #[test]
823    fn http_call_response_path_accepts_the_output_alias() {
824        // This is the case that previously yielded None, silently: the request
825        // was made and the response thrown away.
826        let cfg = parse_http_call(json!({ "connector": "c", "output": "data.x" }))
827            .expect("output should be accepted as an alias");
828        assert_eq!(cfg.response_path.as_deref(), Some("data.x"));
829    }
830
831    #[test]
832    fn http_call_response_path_is_optional() {
833        let cfg = parse_http_call(json!({ "connector": "c" })).expect("no destination is valid");
834        assert_eq!(cfg.response_path, None);
835    }
836
837    #[test]
838    fn http_call_rejects_both_destination_keys_in_either_order() {
839        // Asserting both orderings matters: a single-order test would also pass
840        // on an implementation that had an order-dependent precedence rule.
841        for input in [
842            json!({ "connector": "c", "response_path": "a", "output": "b" }),
843            json!({ "connector": "c", "output": "b", "response_path": "a" }),
844        ] {
845            let err = parse_http_call(input.clone())
846                .expect_err("supplying both destination keys must fail");
847            let msg = err.to_string();
848            assert!(
849                msg.starts_with("config for function 'http_call':"),
850                "error should carry the function envelope, got: {msg}"
851            );
852            assert!(
853                msg.contains("duplicate field"),
854                "error should name the conflict, got: {msg}"
855            );
856        }
857    }
858
859    #[test]
860    fn http_call_rejects_a_misspelled_destination_field() {
861        // The recorded decision: `HttpCallConfig` is `deny_unknown_fields`, so a
862        // near-miss spelling is a parse error naming the field rather than a
863        // silently discarded response. This is the defect this closes.
864        for bad in ["outputs", "Output", "respose_path", "response-path"] {
865            let mut input = serde_json::Map::new();
866            input.insert("connector".to_string(), json!("c"));
867            input.insert(bad.to_string(), json!("data.x"));
868
869            let err = parse_http_call(serde_json::Value::Object(input))
870                .expect_err("a misspelled field must be rejected, not silently discarded");
871            let msg = err.to_string();
872            assert!(
873                msg.starts_with("config for function 'http_call':"),
874                "error should carry the function envelope, got: {msg}"
875            );
876            assert!(
877                msg.contains("unknown field"),
878                "error should say the field is unknown, got: {msg}"
879            );
880            assert!(
881                msg.contains(bad),
882                "error should name the offending field '{bad}', got: {msg}"
883            );
884        }
885    }
886
887    #[test]
888    fn enrich_does_not_accept_the_output_alias() {
889        // The asymmetry is deliberate: only `HttpCallConfig::response_path`
890        // takes the alias. `EnrichConfig`'s destination is `merge_path`, and
891        // `deny_unknown_fields` makes the mistake loud instead of silent.
892        let err = parse(json!({
893            "name": "enrich",
894            "input": { "connector": "c", "output": "data.x" }
895        }))
896        .expect_err("enrich has no `output` field");
897        let msg = err.to_string();
898        assert!(
899            msg.starts_with("config for function 'enrich':"),
900            "error should carry the function envelope, got: {msg}"
901        );
902
903        // And the real spelling still works.
904        let ok = parse(json!({
905            "name": "enrich",
906            "input": { "connector": "c", "merge_path": "data.x" }
907        }))
908        .expect("merge_path is enrich's destination field");
909        assert!(matches!(ok, FunctionConfig::Enrich { .. }));
910    }
911
912    #[test]
913    fn publish_kafka_rejects_unknown_fields() {
914        let err = parse(json!({
915            "name": "publish_kafka",
916            "input": { "connector": "c", "topic": "t", "tpoic": "typo" }
917        }))
918        .expect_err("publish_kafka should reject an unknown field");
919        assert!(err.to_string().contains("unknown field"), "got: {err}");
920    }
921
922    #[test]
923    fn connector_is_returned_for_the_three_typed_integrations() {
924        let cases = [
925            (
926                json!({ "name": "http_call", "input": { "connector": "user_service" } }),
927                "user_service",
928            ),
929            (
930                json!({ "name": "enrich",
931                        "input": { "connector": "ref_data", "merge_path": "data.out" } }),
932                "ref_data",
933            ),
934            (
935                json!({ "name": "publish_kafka",
936                        "input": { "connector": "events", "topic": "t" } }),
937                "events",
938            ),
939        ];
940        for (input, expected) in cases {
941            let cfg = parse(input.clone()).expect("should parse");
942            assert_eq!(cfg.connector(), Some(expected), "for {input}");
943        }
944    }
945
946    #[test]
947    fn connector_is_none_for_every_non_connector_builtin() {
948        // Table-driven over BUILTIN_FUNCTION_NAMES minus the three integration
949        // names, so this cannot go stale when a built-in is added.
950        let minimal_input = |name: &str| -> serde_json::Value {
951            match name {
952                "map" => json!({ "mappings": [] }),
953                "validation" | "validate" => json!({ "rules": [] }),
954                "parse_json" | "parse_xml" | "publish_json" | "publish_xml" => {
955                    json!({ "source": "data.in", "target": "out" })
956                }
957                "filter" => json!({ "condition": true }),
958                "log" => json!({ "message": "hi" }),
959                _ => json!({}),
960            }
961        };
962
963        for name in BUILTIN_FUNCTION_NAMES {
964            if matches!(*name, "http_call" | "enrich" | "publish_kafka") {
965                continue;
966            }
967            let cfg = parse(json!({ "name": name, "input": minimal_input(name) }))
968                .unwrap_or_else(|e| panic!("'{name}' should parse: {e}"));
969            assert_eq!(cfg.connector(), None, "'{name}' names no connector");
970        }
971    }
972
973    #[test]
974    fn connector_reads_the_custom_convention() {
975        let cfg = parse(json!({
976            "name": "pg_query",
977            "input": { "connector": "pg_main", "database": "orders" }
978        }))
979        .unwrap();
980        assert_eq!(cfg.connector(), Some("pg_main"));
981    }
982
983    #[test]
984    fn connector_is_none_for_a_custom_input_without_a_string_connector() {
985        // `Custom` accepts arbitrary input, so every one of these is reachable.
986        for input in [
987            json!({}),                            // key absent
988            json!({ "connector": 7 }),            // number
989            json!({ "connector": true }),         // bool
990            json!({ "connector": null }),         // null
991            json!({ "connector": ["a"] }),        // array
992            json!({ "connector": { "n": "a" } }), // object
993            json!([]),                            // input is not an object
994            json!(7),                             // input is a scalar
995        ] {
996            let cfg = parse(json!({ "name": "my_handler", "input": input.clone() }))
997                .unwrap_or_else(|e| panic!("custom should parse {input}: {e}"));
998            assert_eq!(cfg.connector(), None, "for input {input}");
999        }
1000    }
1001
1002    #[test]
1003    fn connector_returns_an_empty_name_verbatim() {
1004        // The recorded decision: the accessor reports what was authored and
1005        // never disagrees with itself across the typed and Custom arms. Whether
1006        // an empty connector is acceptable is the host's validation question.
1007        let typed = parse(json!({ "name": "http_call", "input": { "connector": "" } })).unwrap();
1008        assert_eq!(typed.connector(), Some(""));
1009
1010        let custom = parse(json!({ "name": "x", "input": { "connector": "" } })).unwrap();
1011        assert_eq!(custom.connector(), Some(""));
1012    }
1013
1014    #[test]
1015    fn connector_returns_a_non_ascii_name_byte_for_byte() {
1016        // A pin against a future "normalize or trim it here" change.
1017        let cfg =
1018            parse(json!({ "name": "http_call", "input": { "connector": "連携先" } })).unwrap();
1019        assert_eq!(cfg.connector(), Some("連携先"));
1020    }
1021
1022    #[test]
1023    fn builtin_function_kind_is_none_for_non_builtins() {
1024        // Classification is exact-match, same as the deserializer dispatch.
1025        for name in [
1026            "",
1027            "__not_a_builtin__",
1028            "HTTP_CALL",    // case differs
1029            "htttp_call",   // typo
1030            "map ",         // trailing space
1031            "publish_kafk", // truncated
1032        ] {
1033            assert_eq!(
1034                builtin_function_kind(name),
1035                None,
1036                "'{name}' must not classify as a built-in"
1037            );
1038            assert!(!is_builtin_function(name));
1039        }
1040    }
1041
1042    #[test]
1043    fn builtin_kinds_partition_matches_real_dispatch_behaviour() {
1044        // Tie the classifier to executed code rather than to a second
1045        // hand-maintained list: `is_sync_builtin` decides whether the workflow
1046        // executor runs a task itself in the arena, and `try_execute_in_arena`
1047        // returns `None` for exactly the handler-backed variants. So a
1048        // SelfContained name must be a sync built-in and a RequiresHandler name
1049        // must not be.
1050        let minimal_input = |name: &str| -> serde_json::Value {
1051            match name {
1052                "map" => json!({ "mappings": [] }),
1053                "validation" | "validate" => json!({ "rules": [] }),
1054                "parse_json" | "parse_xml" | "publish_json" | "publish_xml" => {
1055                    json!({ "source": "data.in", "target": "out" })
1056                }
1057                "filter" => json!({ "condition": true }),
1058                "log" => json!({ "message": "hi" }),
1059                "http_call" => json!({ "connector": "c" }),
1060                "enrich" => json!({ "connector": "c", "merge_path": "data.out" }),
1061                "publish_kafka" => json!({ "connector": "c", "topic": "t" }),
1062                // A new built-in with required fields will fail loudly below
1063                // rather than silently skewing the partition.
1064                _ => json!({}),
1065            }
1066        };
1067
1068        for name in BUILTIN_FUNCTION_NAMES {
1069            let kind = builtin_function_kind(name)
1070                .unwrap_or_else(|| panic!("'{name}' must classify as a built-in"));
1071            let cfg = parse(json!({ "name": name, "input": minimal_input(name) }))
1072                .unwrap_or_else(|e| panic!("'{name}' should parse with minimal input: {e}"));
1073
1074            assert_eq!(
1075                cfg.is_sync_builtin(),
1076                matches!(kind, BuiltinKind::SelfContained),
1077                "'{name}' classifies as {kind:?} but is_sync_builtin() is {}",
1078                cfg.is_sync_builtin()
1079            );
1080        }
1081    }
1082
1083    #[test]
1084    fn requires_handler_kind_covers_exactly_the_config_only_integrations() {
1085        // The three names documented as shipping config-only.
1086        for name in ["http_call", "enrich", "publish_kafka"] {
1087            assert_eq!(
1088                builtin_function_kind(name),
1089                Some(BuiltinKind::RequiresHandler),
1090                "'{name}' ships as config only and needs a registered handler"
1091            );
1092        }
1093
1094        // Both accepted spellings of validation are self-contained. The
1095        // deserializer takes either; `function_name()` only ever returns
1096        // "validate", so checking one spelling would miss a regression.
1097        for name in [
1098            "map",
1099            "validation",
1100            "validate",
1101            "parse_json",
1102            "parse_xml",
1103            "publish_json",
1104            "publish_xml",
1105            "filter",
1106            "log",
1107        ] {
1108            assert_eq!(
1109                builtin_function_kind(name),
1110                Some(BuiltinKind::SelfContained),
1111                "'{name}' is executed by this crate"
1112            );
1113        }
1114    }
1115}
1116
1117#[cfg(test)]
1118mod dispatch_vocabulary_tests {
1119    use super::*;
1120    use std::collections::HashMap;
1121
1122    /// A registry whose values are irrelevant — every function here keys off
1123    /// name membership only.
1124    fn registry(names: &[&str]) -> HashMap<String, ()> {
1125        names.iter().map(|n| ((*n).to_string(), ())).collect()
1126    }
1127
1128    fn names(registry: &HashMap<String, ()>) -> Vec<&str> {
1129        let mut out: Vec<&str> = dispatchable_functions_in(registry)
1130            .map(|f| f.name)
1131            .collect();
1132        out.sort_unstable();
1133        out
1134    }
1135
1136    /// Acceptance criterion: the enumeration accounts for every name in
1137    /// `BUILTIN_FUNCTION_NAMES` exactly once, aliases grouped.
1138    ///
1139    /// This is the drift net for `canonical_builtin_name` / `builtin_aliases`.
1140    /// Adding a built-in without teaching them about it fails here rather than
1141    /// silently dropping the name from every host's vocabulary.
1142    #[test]
1143    fn aliases_and_canonical_names_agree() {
1144        // Every canonical name is its own canonical form, and every alias
1145        // resolves to a name that is.
1146        for name in BUILTIN_FUNCTION_NAMES {
1147            let canonical = canonical_builtin_name(name);
1148            assert_eq!(
1149                canonical_builtin_name(canonical),
1150                canonical,
1151                "'{name}' resolves to '{canonical}', which must itself be canonical"
1152            );
1153            assert!(
1154                BUILTIN_FUNCTION_NAMES.contains(&canonical),
1155                "'{canonical}' is a canonical name and must be an accepted spelling"
1156            );
1157            // An alias shares its canonical name's kind — both spellings
1158            // deserialize to the same variant.
1159            assert_eq!(
1160                builtin_function_kind(name),
1161                builtin_function_kind(canonical),
1162                "'{name}' and '{canonical}' are one function and must classify alike"
1163            );
1164        }
1165
1166        // Each name is either a canonical entry or an alias of exactly one —
1167        // never both, never neither.
1168        for name in BUILTIN_FUNCTION_NAMES {
1169            let is_canonical = canonical_builtin_name(name) == *name;
1170            let alias_of: Vec<&str> = BUILTIN_FUNCTION_NAMES
1171                .iter()
1172                .copied()
1173                .filter(|c| builtin_aliases(c).contains(name))
1174                .collect();
1175            assert_eq!(
1176                is_canonical,
1177                alias_of.is_empty(),
1178                "'{name}' must be canonical XOR an alias, got canonical={is_canonical} \
1179                 listed-as-alias-of={alias_of:?}"
1180            );
1181            assert!(
1182                alias_of.len() <= 1,
1183                "'{name}' is listed as an alias of more than one function: {alias_of:?}"
1184            );
1185        }
1186
1187        // And the two directions agree: every alias listed is a real spelling.
1188        for canonical in BUILTIN_FUNCTION_NAMES {
1189            for alias in builtin_aliases(canonical) {
1190                assert_eq!(
1191                    canonical_builtin_name(alias),
1192                    *canonical,
1193                    "'{alias}' is listed under '{canonical}' but does not resolve to it"
1194                );
1195            }
1196        }
1197    }
1198
1199    #[test]
1200    fn validate_is_canonical_and_validation_is_its_alias() {
1201        assert_eq!(canonical_builtin_name("validation"), "validate");
1202        assert_eq!(canonical_builtin_name("validate"), "validate");
1203        assert_eq!(builtin_aliases("validate"), &["validation"]);
1204        assert!(builtin_aliases("validation").is_empty());
1205        assert!(builtin_aliases("map").is_empty());
1206    }
1207
1208    #[test]
1209    fn an_empty_registry_dispatches_every_self_contained_builtin() {
1210        assert_eq!(
1211            names(&registry(&[])),
1212            vec![
1213                "filter",
1214                "log",
1215                "map",
1216                "parse_json",
1217                "parse_xml",
1218                "publish_json",
1219                "publish_xml",
1220                "validate",
1221            ],
1222            "self-contained built-ins need no registration; `validation` is \
1223             folded into `validate`, and the three config-only integrations are absent"
1224        );
1225    }
1226
1227    #[test]
1228    fn requires_handler_builtins_appear_only_when_registered() {
1229        let empty = registry(&[]);
1230        assert!(!names(&empty).contains(&"enrich"));
1231        assert!(!can_dispatch_in(&empty, "enrich"));
1232
1233        let backed = registry(&["enrich"]);
1234        assert!(names(&backed).contains(&"enrich"));
1235        assert!(can_dispatch_in(&backed, "enrich"));
1236
1237        // …and it keeps its built-in classification rather than reading as custom.
1238        let entry = dispatchable_functions_in(&backed)
1239            .find(|f| f.name == "enrich")
1240            .expect("registered enrich is enumerated");
1241        assert_eq!(entry.kind, Some(BuiltinKind::RequiresHandler));
1242    }
1243
1244    #[test]
1245    fn custom_names_are_enumerated_with_no_kind() {
1246        let reg = registry(&["shout"]);
1247        let entry = dispatchable_functions_in(&reg)
1248            .find(|f| f.name == "shout")
1249            .expect("a registered custom name is enumerated");
1250        assert_eq!(entry.kind, None, "None is how a custom handler reports");
1251        assert!(entry.aliases.is_empty());
1252        assert!(can_dispatch_in(&reg, "shout"));
1253        assert!(!can_dispatch_in(&registry(&[]), "shout"));
1254    }
1255
1256    #[test]
1257    fn registering_a_self_contained_name_is_inert_and_never_duplicates_it() {
1258        // Registering under `map` does nothing: the deserializer routes `map`
1259        // to FunctionConfig::Map, which the crate executes itself. The name is
1260        // reported once either way.
1261        let shadowed = registry(&["map"]);
1262        assert_eq!(
1263            names(&shadowed),
1264            names(&registry(&[])),
1265            "a shadowing registration changes nothing about the vocabulary"
1266        );
1267        assert_eq!(
1268            dispatchable_functions_in(&shadowed)
1269                .filter(|f| f.name == "map")
1270                .count(),
1271            1,
1272            "`map` is yielded exactly once, not once per source"
1273        );
1274    }
1275
1276    #[test]
1277    fn aliases_dispatch_but_are_not_enumerated() {
1278        let reg = registry(&[]);
1279        assert!(
1280            can_dispatch_in(&reg, "validation"),
1281            "a task named `validation` really does execute"
1282        );
1283        assert!(
1284            !names(&reg).contains(&"validation"),
1285            "but the enumeration reports it under `validate`"
1286        );
1287    }
1288
1289    #[test]
1290    fn can_dispatch_rejects_names_the_crate_does_not_know() {
1291        let reg = registry(&["shout"]);
1292        assert!(!can_dispatch_in(&reg, "SHOUT"), "matching is exact");
1293        assert!(!can_dispatch_in(&reg, "htttp_call"));
1294        assert!(!can_dispatch_in(&reg, ""));
1295    }
1296
1297    /// The predicate and the enumeration must describe the same set — with the
1298    /// one documented exception that `can_dispatch` also accepts aliases.
1299    #[test]
1300    fn every_enumerated_name_is_dispatchable() {
1301        let reg = registry(&["enrich", "shout"]);
1302        for f in dispatchable_functions_in(&reg) {
1303            assert!(
1304                can_dispatch_in(&reg, f.name),
1305                "'{}' is enumerated, so it must dispatch",
1306                f.name
1307            );
1308            for alias in f.aliases {
1309                assert!(
1310                    can_dispatch_in(&reg, alias),
1311                    "alias '{alias}' of '{}' must dispatch too",
1312                    f.name
1313                );
1314            }
1315        }
1316    }
1317}