defmodule Mobus.Stepwise.ProjectionHelpers do
@moduledoc """
Shared projection helper functions used by all profile projections
(stepwise, FSM, flow).
Extracted to eliminate duplication across projection components.
"""
@doc """
Builds the subscriptions list from spec declarations and runtime context,
prepending the mandatory workflow_execution topic and interpolating
`{placeholder}` variables from runtime context.
"""
@spec subscriptions_for(map(), map()) :: [String.t()]
def subscriptions_for(spec, runtime) do
base = ["workflow_execution:#{Map.fetch!(runtime, :execution_id)}"]
declared =
Map.get(spec, :subscriptions) ||
Map.get(spec, "subscriptions") ||
[]
declared
|> Enum.flat_map(fn
topic when is_binary(topic) -> [topic]
%{topic: topic} when is_binary(topic) -> [topic]
%{"topic" => topic} when is_binary(topic) -> [topic]
_ -> []
end)
|> Enum.map(&interpolate_topic(&1, Map.get(runtime, :context, %{}) || %{}))
|> Enum.reject(&is_nil/1)
|> then(&Enum.uniq(base ++ &1))
end
@doc """
Replaces `{key}` placeholders in a topic template with values from
the runtime context. Returns `nil` if any placeholder is missing or empty.
"""
@spec interpolate_topic(String.t(), map()) :: String.t() | nil
def interpolate_topic(template, context) when is_binary(template) and is_map(context) do
placeholders = Regex.scan(~r/\{([a-zA-Z0-9_]+)\}/, template, capture: :all_but_first)
Enum.reduce_while(placeholders, template, fn [key], acc ->
value = Map.get(context, key) || Map.get(context, existing_atom(key))
if is_binary(value) and byte_size(value) > 0 do
{:cont, String.replace(acc, "{#{key}}", value)}
else
{:halt, nil}
end
end)
end
@doc """
Resolves a UI descriptor for a given state from the spec definition.
Looks up `state_def.ui` with optional `key` and `assigns` and merges
in context assigns (`context` and `state`). Returns `nil` if no UI key
is configured for the state.
"""
@spec ui_for(map(), atom() | String.t(), map()) :: map() | nil
def ui_for(spec, current, runtime) do
states = Map.get(spec, :states) || %{}
state_def = Map.get(states, current) || Map.get(states, to_string(current)) || %{}
ui =
Map.get(state_def, :ui) ||
Map.get(state_def, "ui") ||
%{}
key =
Map.get(ui, :key) || Map.get(ui, "key") || Map.get(state_def, :ui_key) ||
Map.get(state_def, "ui_key")
assigns =
Map.get(ui, :assigns) ||
Map.get(ui, "assigns") ||
%{}
context_assigns = %{context: Map.get(runtime, :context, %{}), state: current}
if key do
%{key: key, assigns: Map.merge(assigns, context_assigns)}
else
nil
end
end
@doc """
Safe conversion of a binary key to an existing atom. Returns `nil`
if the atom doesn't exist in the atom table, rather than raising.
"""
@spec existing_atom(String.t()) :: atom() | nil
def existing_atom(key) when is_binary(key) do
try do
:erlang.binary_to_existing_atom(key, :utf8)
rescue
ArgumentError -> nil
end
end
@doc """
Builds the projection extensions map.
When `spec.projection_enricher` names a module exporting `enrich/2`,
it is called with the base extensions and the runtime, and its return
replaces the extensions map. When absent, extensions contain just the
runtime meta (if any).
This is shared so all profiles (stepwise, FSM, flow) can populate
extensions consistently.
"""
require Logger
@spec build_extensions(map(), map()) :: map()
def build_extensions(spec, runtime) do
base = %{meta: Map.get(runtime, :meta, %{})}
case Map.get(spec, :projection_enricher) do
nil ->
base
module when is_atom(module) ->
if function_exported?(module, :enrich, 2) do
try do
module.enrich(base, runtime)
rescue
e ->
Logger.warning(
"Projection enricher #{inspect(module)}.enrich/2 raised: #{inspect(e)}"
)
base
end
else
base
end
_ ->
base
end
end
end