lib/spex/instance_manager/instance.ex

defmodule Spex.InstanceManager.Instance do
  @moduledoc """
  Runtime representation of a specification instance and its observed history.
  """

  @type instance_identifier :: term()
  @type meta :: map()

  @type transition_record :: {
          action :: Spex.action(),
          to_state :: Spex.state(),
          timestamp :: DateTime.t()
        }

  @type t :: %__MODULE__{
          specification: Spex.Specification.t(),
          identifier: instance_identifier(),
          meta: meta() | nil,
          current_state: Spex.state() | nil,
          transitions: [transition_record()]
        }

  defstruct [:specification, :identifier, :meta, :current_state, :transitions]

  @doc """
  Initialises a new instance with an empty transition history.
  """
  @spec initialise(Spex.Specification.t(), instance_identifier(), meta() | nil) :: t()
  def initialise(specification, identifier, meta) do
    %__MODULE__{
      specification: specification,
      identifier: identifier,
      meta: meta,
      current_state: nil,
      transitions: []
    }
  end

  @doc """
  Records an observed transition and updates the current state.
  """
  @spec observe_transition(t(), Spex.action(), Spex.state()) :: t()
  def observe_transition(
        %__MODULE__{transitions: transitions} = instance,
        action,
        to_state
      ) do
    transition_record = {action, to_state, DateTime.utc_now()}
    %{instance | transitions: [transition_record | transitions], current_state: to_state}
  end

  @doc """
  Returns whether the instance exceeded its specification transition timeout.
  """
  @spec beyond_transition_timeout?(t(), DateTime.t()) :: boolean()
  def beyond_transition_timeout?(instance, now \\ DateTime.utc_now())

  def beyond_transition_timeout?(%__MODULE__{transitions: []}, _now), do: false

  def beyond_transition_timeout?(
        %__MODULE__{
          specification: specification,
          transitions: [{_, _, last_transition_timestamp} | _]
        },
        now
      ) do
    DateTime.diff(now, last_transition_timestamp, :millisecond) >
      specification.transition_timeout()
  end

  @doc """
  Returns whether the instance is currently eligible for pruning.
  """
  @spec prunable?(t()) :: boolean()

  def prunable?(%__MODULE__{transitions: []}), do: false

  def prunable?(%__MODULE__{
        specification: specification,
        current_state: current_state,
        transitions: [{_, _, last_transition_timestamp} | _]
      }) do
    beyond_prune_timeout?(last_transition_timestamp, specification) and
      state_is_prunable?(current_state, specification)
  end

  @spec beyond_prune_timeout?(DateTime.t(), Spex.Specification.t()) :: boolean()
  defp beyond_prune_timeout?(last_transition_timestamp, specification) do
    DateTime.diff(DateTime.utc_now(), last_transition_timestamp, :millisecond) >
      specification.prune_timeout()
  end

  @spec state_is_prunable?(Spex.state(), Spex.Specification.t()) :: boolean()
  defp state_is_prunable?(current_state, specification) do
    case specification.prunable_states() do
      :all -> true
      :terminal -> current_state in specification.terminal_states()
      states when is_list(states) -> current_state in states
    end
  end
end