lib/spex/specification.ex

defmodule Spex.Specification do
  @moduledoc """
  Behaviour and DSL for defining specifications through labelled transition systems (LTSs).

  A specification module describes the expected states, actions, and transitions
  of a system. Spex uses this information to:

  - validate observed runtime behaviour,
  - construct and maintain implementation models,
  - check branching bisimilarity between model and specification,
  - enforce transition timeout and pruning policies.

  This module provides:

  - behaviour callbacks that every specification must implement,
  - a `use` macro that generates those callbacks from a concise DSL,
  - `def_transition/3` for declaring transitions,
  - default error handling that can be overridden per specification.

  ## Configuration Options

  Pass options to `use Spex.Specification, ...`.

  ### `:transition_timeout`

  Maximum time between two observed transitions for an instance.

  - Default: `:infinity`
  - Accepted values: `:infinity`, integer milliseconds, or `%Duration{}`
    (converted via `to_timeout/1`)
  - Used by instance manager timeout checks.

  If an instance exceeds this timeout, Spex emits
  `%Spex.Errors.TransitionError{reason: :transition_timeout}` and routes it
  through the specification `error_handler/2`.

  ### `:prune_timeout`

  Minimum idle time before an instance can be considered for pruning.

  - Default: `:infinity`
  - Accepted values: `:infinity`, integer milliseconds, or `%Duration{}`
    (converted via `to_timeout/1`)

  Pruning eligibility also depends on `:prunable_states`.

  ### `:prunable_states`

  Controls which states are allowed to be pruned after `:prune_timeout`.

  - Default: `[]` (no states are prunable)
  - Accepted values:
    - `:all`: any current state may be pruned
    - `:terminal`: only terminal states may be pruned
    - explicit state list, e.g. `[:done, :failed]`

  Terminal states are derived from transitions as states with no outgoing edge.

  ## Transition DSL

  Define transitions with `def_transition(from_state, action, to_state)`.

  Each call contributes to compile-time metadata used to generate:

  - `states/0`
  - `actions/0`
  - `transitions/0`
  - `initial_state/0`
  - `terminal_states/0`

  Duplicate states/actions/transitions are deduplicated.

  The initial state is the source state of the first declared transition.
  If no transitions are declared, `initial_state/0` returns `nil`.

  ## Generated Callbacks

  When you `use Spex.Specification`, the following callbacks are generated
  automatically:

  - `transition_timeout/0`
  - `prune_timeout/0`
  - `prunable_states/0`
  - `states/0`
  - `actions/0`
  - `transitions/0`
  - `initial_state/0`
  - `terminal_states/0`
  - `error_handler/2` (delegates to `default_error_handler/2` by default)

  `error_handler/2` is overridable.

  ## Error Handling Contract

  `error_handler/2` receives `(error, caller_pid)` and must return:

  - `:ok` to swallow/accept the error condition, or
  - `{:error, reason}` to propagate failure.

  Default behaviour in `default_error_handler/2`:

  - logs the error and caller stacktrace,
  - returns `:ok` for:
    - `%Spex.Errors.TransitionError{reason: :deviation_still_bisimilar}`
    - `%Spex.Errors.ImplModelError{reason: :impl_model_not_found}`
  - returns `{:error, error}` for all other errors.

  ## Full Example

      defmodule MySpec do
        use Spex.Specification,
          transition_timeout: 30_000,
          prune_timeout: %Duration{hour: 1},
          prunable_states: :terminal

        def_transition :idle, :start, :running
        def_transition :running, :complete, :done
        def_transition :running, :fail, :failed

        @impl Spex.Specification
        def error_handler(error, caller) do
          send(caller, {:spec_error, error})
          {:error, error}
        end
      end
  """

  require Logger
  @typedoc "A module implementing this behaviour"
  @type t :: module()

  @doc false
  @callback states :: [Spex.state()]
  @doc false
  @callback actions :: [Spex.action()]
  @doc false
  @callback initial_state :: Spex.state()
  @doc false
  @callback terminal_states :: [Spex.state()]
  @doc false
  @callback transitions :: [Spex.transition()]

  @doc false
  @callback transition_timeout :: timeout()
  @doc false
  @callback prune_timeout :: timeout()
  @doc false
  @callback prunable_states :: [Spex.state()] | :all | :terminal

  @typedoc false
  @type handled_error :: Spex.Errors.InstanceError.t() | Spex.Errors.TransitionError.t()

  @typedoc "The return type for `error_handler/2` callbacks."
  @type error_handler_return :: :ok | {:error, Exception.t() | term()}

  @doc """
  Callback for handling errors emitted by Spex during instance management.
  """
  @callback error_handler(handled_error, caller_process :: pid()) :: error_handler_return()

  @doc false
  defmacro __using__(opts) do
    quote bind_quoted: [opts: opts] do
      # Extract configuration options with defaults
      transition_timeout = Keyword.get(opts, :transition_timeout, :infinity) |> to_timeout()
      prune_timeout = Keyword.get(opts, :prune_timeout, :infinity) |> to_timeout()
      prunable_states = Keyword.get(opts, :prunable_states, [])

      @behaviour Spex.Specification

      # Module attributes to collect data during compilation
      Module.register_attribute(__MODULE__, :spex_transitions, accumulate: true)
      Module.register_attribute(__MODULE__, :spex_states, accumulate: true)
      Module.register_attribute(__MODULE__, :spex_actions, accumulate: true)

      import Spex.Specification, only: [def_transition: 3]

      @before_compile Spex.Specification

      # Configure callbacks based on options

      @impl Spex.Specification
      def transition_timeout, do: unquote(transition_timeout)

      @impl Spex.Specification
      def prune_timeout, do: unquote(prune_timeout)

      @impl Spex.Specification
      def prunable_states, do: unquote(prunable_states)

      # Default error handler (overridable)
      defdelegate error_handler(error, caller), to: Spex.Specification, as: :default_error_handler

      defoverridable error_handler: 2
    end
  end

  @doc """
  Defines a transition from one state to another via an action.

  The first state of the first transition becomes the initial state.
  States and actions are automatically collected and deduplicated.

  ## Example

      def_transition :idle, :start, :working
      def_transition :working, :complete, :idle
  """
  defmacro def_transition(from_state, action, to_state) do
    quote do
      # Add the transition
      @spex_transitions {unquote(from_state), unquote(action), unquote(to_state)}

      # Add states (will be deduplicated later)
      @spex_states unquote(from_state)
      @spex_states unquote(to_state)

      # Add action
      @spex_actions unquote(action)
    end
  end

  @doc false
  defmacro __before_compile__(_env) do
    quote unquote: false do
      # Compute data at compile time

      spex_states_uniq = @spex_states |> Enum.reverse() |> Enum.uniq()
      spex_actions_uniq = @spex_actions |> Enum.reverse() |> Enum.uniq()
      spex_transitions_uniq = @spex_transitions |> Enum.reverse() |> Enum.uniq()

      spex_initial_state =
        case spex_transitions_uniq do
          [{initial_state, _, _} | _] -> initial_state
          [] -> nil
        end

      spex_terminal_states =
        Enum.reject(spex_states_uniq, fn state ->
          Enum.any?(spex_transitions_uniq, &match?({^state, _, _}, &1))
        end)

      # Configure callbacks to return the computed data

      @impl Spex.Specification
      def states, do: unquote(Macro.escape(spex_states_uniq))

      @impl Spex.Specification
      def actions, do: unquote(Macro.escape(spex_actions_uniq))

      @impl Spex.Specification
      def transitions, do: unquote(Macro.escape(spex_transitions_uniq))

      @impl Spex.Specification
      def initial_state, do: unquote(Macro.escape(spex_initial_state))

      @impl Spex.Specification
      def terminal_states, do: unquote(Macro.escape(spex_terminal_states))
    end
  end

  @doc """
  Default error handling strategy used by specification modules.
  """
  @spec default_error_handler(handled_error(), caller_process :: pid()) :: error_handler_return()
  def default_error_handler(error, caller) do
    caller_stacktrace = Process.info(caller, :current_stacktrace)

    Logger.error("[Spex] Error: #{inspect(error)};\n stacktrace: #{inspect(caller_stacktrace)}")

    case error do
      %Spex.Errors.TransitionError{reason: :deviation_still_bisimilar} -> :ok
      %Spex.Errors.ImplModelError{reason: :impl_model_not_found} -> :ok
      _other_error -> {:error, error}
    end
  end
end