lib/pipeline/state.ex

defmodule Pipeline.State do
  @moduledoc """
  Pipeline state management.

  This module defines a struct that is used to keep track of the state of a pipeline: the initial value, the current
  value, it's still valid (or not) and any error that may have occurred.

  It also has functions to create and manipulate a state.

  You probably won't need to interact with this module too often, since it's all managed by the pipeline engine. The
  only part of a pipeline where this module is accessible is on callback functions.
  """
  defstruct [:initial_value, :value, :valid?, :error, :executed_steps]

  @typedoc """
  A struct that wraps metadata information about a pipeline.

  * `initial_value` is the first ever value that is passed to the first step on a pipeline.
  * `value` is the current value of the pipeline
  * `valid?` is boolean indicating wether the pipeline is still valid (true) or not (false).
  * `error` the error that may have happened during the execution of the pipeline.
  * `executed_steps` a list of all steps that were executed
  """
  @type t :: %__MODULE__{
          initial_value: any(),
          value: any(),
          valid?: boolean(),
          error: any(),
          executed_steps: [{module, atom}]
        }

  alias Pipeline.TransformError
  alias Pipeline.Types

  @doc """
  Creates a new, valid, `%Pipeline.State{}` struct with the given initial value

  ## Examples

      iex> Pipeline.State.new(%{id: 1})
      %Pipeline.State{error: nil, executed_steps: [], initial_value: %{id: 1}, valid?: true, value: %{id: 1}}
  """
  @spec new(any()) :: t()
  def new(initial_value) do
    %__MODULE__{
      initial_value: initial_value,
      value: initial_value,
      valid?: true,
      error: nil,
      executed_steps: []
    }
  end

  @doc """
  Updates a state with the given reducer.

  If everything goes well and the function returns an ok tuple, it will return an updated `%Pipeline.State{}` struct.

  If the function returns an error tuple, it will call `invalidate/1` or `invalidate/2` and return an updated and
  invalid `%Pipeline.State{}` struct.

  Note that the function must return an ok/error tuple, otherwise a `Transform.Error` error is thrown.
  """
  @spec update(t(), Types.reducer(), Types.options()) :: t()
  def update(state, transform, options \\ [])

  def update(%__MODULE__{valid?: true, value: value} = state, {module, fun} = reducer, options) do
    updated_state =
      module
      |> apply(fun, [value, options])
      |> check_update(state)

    %__MODULE__{updated_state | executed_steps: state.executed_steps ++ [reducer]}
  end

  def update(%__MODULE__{valid?: false} = state, _transform, _options), do: state

  # Check if the transformation is valid
  defp check_update(new_value, state) do
    case new_value do
      {:ok, value} ->
        %__MODULE__{state | value: value}

      {:error, error} ->
        invalidate(state, error)

      :error ->
        invalidate(state)

      unexpected ->
        raise(TransformError, "expected an ok or error tuple, got #{inspect(unexpected)}")
    end
  end

  @doc """
  Marks the given state as invalid.

  Since no errors are specified, the `error` field on the state becomes a generic error string.

  ## Examples

      iex> %Pipeline.State{valid?: true, error: nil} |> Pipeline.State.invalidate()
      %Pipeline.State{error: "an error occured during the execution of the pipeline", valid?: false}
  """
  @spec invalidate(t()) :: t()
  def invalidate(%__MODULE__{} = state) do
    %__MODULE__{
      state
      | valid?: false,
        error: "an error occured during the execution of the pipeline"
    }
  end

  @doc """
  Marks the given state as invalid and adds an error to the state.

  The `error` field on the state will have the same value from the given error.

  ## Examples

      iex> %Pipeline.State{valid?: true, error: nil} |> Pipeline.State.invalidate(:bad_thing)
      %Pipeline.State{error: :bad_thing, valid?: false}
  """
  @spec invalidate(t(), any()) :: t()
  def invalidate(%__MODULE__{} = state, error) do
    %__MODULE__{state | valid?: false, error: error}
  end
end