lib/spex/instance_manager.ex

defmodule Spex.InstanceManager do
  @moduledoc """
  Behaviour contract plus shared helpers for Spex instance managers.
  """

  @type instance_manager_opt ::
          Spex.InstanceManager.Server.server_opt() | (other_opt :: {atom(), term()})

  @doc """
  Returns a child spec for supervising the instance manager.
  """
  @callback child_spec(term()) :: Supervisor.child_spec()

  @doc """
  Starts the instance manager process tree.
  """
  @callback start_link([instance_manager_opt()]) :: Supervisor.on_start()

  @doc """
  Initializes a new instance and records initialisation as the first transition.

  Returns `:ok` on success or an error-handler return when initialization fails.
  """
  @callback init_instance(
              Spex.Specification.t(),
              Spex.InstanceManager.Instance.instance_identifier(),
              Spex.InstanceManager.Instance.meta() | nil,
              Spex.state() | nil
            ) :: :ok | Spex.Specification.error_handler_return()

  @doc """
  Same as `init_instance/4`, but raises on errors.
  """
  @callback init_instance!(
              Spex.Specification.t(),
              Spex.InstanceManager.Instance.instance_identifier(),
              Spex.InstanceManager.Instance.meta() | nil,
              Spex.state() | nil
            ) :: :ok

  @doc """
  Asynchronously initializes a new instance.

  Errors are reported via specification error handling instead of direct return.
  """
  @callback init_instance_async(
              Spex.Specification.t(),
              Spex.InstanceManager.Instance.instance_identifier(),
              Spex.InstanceManager.Instance.meta() | nil,
              Spex.state() | nil
            ) :: :ok

  @doc """
  Records an observed transition for an existing instance.

  Returns `:ok` on success or an error-handler return when validation/storage fails.
  """
  @callback transition(
              Spex.InstanceManager.Instance.instance_identifier(),
              Spex.action(),
              Spex.state()
            ) :: :ok | Spex.Specification.error_handler_return()

  @doc """
  Same as `transition/3`, but raises on errors.
  """
  @callback transition!(
              Spex.InstanceManager.Instance.instance_identifier(),
              Spex.action(),
              Spex.state()
            ) :: :ok

  @doc """
  Asynchronously records a transition for an existing instance.
  """
  @callback transition_async(
              Spex.InstanceManager.Instance.instance_identifier(),
              Spex.action(),
              Spex.state()
            ) :: :ok

  @doc """
  Fetches one instance by identifier.
  """
  @callback get_instance(Spex.InstanceManager.Instance.instance_identifier()) ::
              {:ok, Spex.InstanceManager.Instance.t()}
              | {:error, Spex.Errors.InstanceError.t()}
              | {:error, Spex.Errors.DetsError.t()}

  @doc """
  Returns all instances managed by this instance manager.
  """
  @callback all_instances ::
              {:ok, [Spex.InstanceManager.Instance.t()]} | {:error, Spex.Errors.DetsError.t()}

  @doc """
  Returns all instances for a given specification module.
  """
  @callback all_instances(Spex.Specification.t()) ::
              {:ok, [Spex.InstanceManager.Instance.t()]} | {:error, Spex.Errors.DetsError.t()}

  @doc """
  Deletes one instance by identifier.
  """
  @callback delete_instance(Spex.InstanceManager.Instance.instance_identifier()) ::
              :ok | {:error, Spex.Errors.DetsError.t()}

  @doc """
  Deletes all instances matching the provided filter function.
  """
  @callback delete_instances((Spex.InstanceManager.Instance.t() -> as_boolean(term()))) ::
              :ok | {:error, Spex.Errors.DetsError.t()}

  @doc """
  Returns all currently known implementation models.
  """
  @callback all_impl_models :: {:ok, [Spex.ImplModel.t()]}

  @doc """
  Serialises and exports implementation models as `{filename, content}` tuples.
  """
  @callback export_impl_models ::
              {:ok, [{filename :: String.t(), Spex.ImplModel.serialisation()}]}

  @doc """
  Inserts or updates a mock instance at a given state for testing purposes.
  """
  @callback mock_instance!(
              Spex.Specification.t(),
              Spex.InstanceManager.Instance.instance_identifier(),
              Spex.state(),
              Spex.InstanceManager.Instance.meta() | nil
            ) :: :ok

  @optional_callbacks init_instance_async: 4, transition_async: 3

  @doc """
  Injects the `Spex.InstanceManager` behaviour and shared convenience functions.

  Generated convenience functions:

  - `child_spec/1`
  - `init_instance!/4`
  - `transition!/3`
  """
  defmacro __using__(_) do
    quote do
      @behaviour unquote(__MODULE__)

      @doc "Callback implementation for `c:Spex.InstanceManager.child_spec/1`."
      @impl unquote(__MODULE__)
      def child_spec(opts) do
        %{
          id: __MODULE__,
          start: {__MODULE__, :start_link, [opts]}
        }
      end

      @doc "Callback implementation for `c:Spex.InstanceManager.init_instance!/4`."
      @impl unquote(__MODULE__)
      def init_instance!(spec, instance_identifier, meta \\ nil, initial_state \\ nil) do
        init_instance(spec, instance_identifier, meta, initial_state) |> __ok__!()
      end

      @doc "Callback implementation for `c:Spex.InstanceManager.transition!/3`."
      @impl unquote(__MODULE__)
      def transition!(instance_identifier, action, state) do
        transition(instance_identifier, action, state) |> __ok__!()
      end

      defp __ok__!(:ok), do: :ok
      defp __ok__!({:error, error}) when is_exception(error), do: raise(error)
      defp __ok__!({:error, error}), do: raise(RuntimeError, inspect(error))

      defoverridable unquote(__MODULE__)
    end
  end

  {instance_manager, instance_manager_opts} =
    case Application.compile_env(:spex, :instance_manager) do
      nil -> {Spex.InstanceManager.SimpleInstanceManager, []}
      module when is_atom(module) -> {module, []}
      {module, opts} when is_atom(module) and is_list(opts) -> {module, opts}
    end

  @doc """
  Returns the compile-time configured default instance manager module.
  """
  @spec default_instance_manager :: module()
  def default_instance_manager, do: unquote(instance_manager)

  @doc """
  Returns compile-time options for the default instance manager.
  """
  @spec default_instance_manager_opts :: keyword()
  def default_instance_manager_opts, do: unquote(instance_manager_opts)
end