lib/reactor/step/switch.ex

defmodule Reactor.Step.Switch do
  @moduledoc """
  Conditionally decide which steps should be run at runtime.

  ## Options

  * `matches` - a list of match consisting of predicates and a list of steps to
    execute if the predicate returns a truthy value.  See `t:matches` for more
    information.  Required.
  * `default` - a list of steps to execute if none of the predicates match.
    Optional.
  * `allow_async?` - a boolean indicating whether to allow the steps to be
    executed asynchronously.  Optional.  Defaults to `true`.
  * `on` - the name of the argument to pass into the predicates.  If this
    argument is not provided to this step, then an error will be returned.

  ## Branching behaviour

  Each of the predicates in `matches` are tried in order, until either one
  returns a truthy value, or all the matches are exhausted.

  If there is a match, then the matching steps are emitted into the parent
  running Reactor.

  In the case that no match is found, then the steps provided in the `default`
  option are emitted.  If no default is provided, then an error is returned.

  > #### Tip {: .tip}
  >
  > Execution of predicates stops once the first match is found.  This means
  > that if multiple predicates potentially match, the subsequent ones will
  > never be called.

  ## Returning

  By default the step returns `nil` as it's result.

  You can have the step return the result of a branch by adding a step to the
  branch with the same name as the switch which returns the expected value.
  This will be handled by normal Reactor step emission rules.
  """

  use Reactor.Step
  alias Reactor.Step
  import Reactor.Utils

  @typedoc """
  A list of predicates and steps to execute if the predicate returns a truthy
  value.
  """
  @type matches :: [{predicate, [Step.t()]}]

  @typedoc """
  A predicate is a 1-arity function.  It can return anything.  Any result which
  is not `nil` or `false` is considered true.
  """
  @type predicate :: (any -> any)

  @type options :: [match_option | default_option | allow_async_option | on_option]

  @type match_option :: {:matches, matches}
  @type default_option :: {:default, [Step.t()]}
  @type allow_async_option :: {:allow_async?, boolean}
  @type on_option :: {:on, atom}

  @doc false
  @spec run(Reactor.inputs(), Reactor.context(), options) :: {:ok, any} | {:error, any}
  def run(arguments, _context, options) do
    allow_async? = Keyword.get(options, :allow_async?, true)

    with {:ok, on} <- fetch_on(arguments, options),
         {:ok, matches} <- fetch_matches(options),
         :no_match <- find_match(matches, on),
         {:ok, defaults} <- fetch_defaults(options) do
      {:ok, nil, maybe_rewrite_async(defaults, allow_async?)}
    else
      {:match, steps} -> {:ok, nil, maybe_rewrite_async(steps, allow_async?)}
      {:error, reason} -> {:error, reason}
    end
  end

  defp find_match(matches, value) do
    Enum.reduce_while(matches, :no_match, fn {predicate, steps}, :no_match ->
      if predicate.(value) do
        {:halt, {:match, steps}}
      else
        {:cont, :no_match}
      end
    end)
  end

  defp fetch_defaults(options) do
    with {:ok, steps} <- Keyword.fetch(options, :default),
         {:ok, steps} <- validate_steps(steps) do
      {:ok, steps}
    else
      {:error, reason} ->
        {:error, reason}

      :error ->
        {:error, "No branch matched in switch and no default branch is set"}
    end
  end

  defp fetch_on(arguments, options) do
    case Keyword.fetch(options, :on) do
      {:ok, on} when is_atom(on) and is_map_key(arguments, on) ->
        {:ok, Map.get(arguments, on)}

      {:ok, _on} ->
        {:error,
         argument_error(:options, "Expected `on` option to match a provided argument", options)}

      :error ->
        {:error, argument_error(:options, "Missing `on` option.")}
    end
  end

  defp fetch_matches(options) do
    case Keyword.fetch(options, :matches) do
      {:ok, matches} -> map_while_ok(matches, &validate_match/1, true)
      :error -> {:error, argument_error(:options, "Missing `matches` option.")}
    end
  end

  defp validate_match({predicate, steps}) do
    with {:ok, predicate} <- capture(predicate),
         {:ok, steps} <- validate_steps(steps) do
      {:ok, {predicate, steps}}
    end
  end

  defp validate_steps(steps) do
    if Enum.all?(steps, &is_struct(&1, Step)),
      do: {:ok, steps},
      else: {:error, argument_error(:steps, "Expected all steps to be a `Reactor.Step` struct.")}
  end

  defp capture(predicate) when is_function(predicate, 1), do: {:ok, predicate}

  defp capture({m, f, []}) when is_atom(m) and is_atom(f),
    do: ensure_exported(m, f, 1, fn -> {:ok, Function.capture(m, f, 1)} end)

  defp capture({m, f, a}) when is_atom(m) and is_atom(f) and is_list(a),
    do:
      ensure_exported(m, f, length(a) + 1, fn ->
        {:ok, fn input -> apply(m, f, [input | a]) end}
      end)

  defp capture(predicate),
    do:
      {:error,
       argument_error(:predicate, "Expected `predicate` to be a 1 arity function", predicate)}

  defp ensure_exported(m, f, arity, callback) do
    if Code.ensure_loaded?(m) && function_exported?(m, f, arity) do
      callback.()
    else
      {:error, "Expected `#{inspect(m)}.#{f}/#{arity}` to be exported."}
    end
  end

  defp maybe_rewrite_async(steps, true), do: steps
  defp maybe_rewrite_async(steps, false), do: Enum.map(steps, &%{&1 | async?: false})
end