Skip to main content

lib/skuld/adapter.ex

defmodule Skuld.Adapter do
  @moduledoc """
  Bridges effectful implementations to plain Elixir interfaces.

  `Skuld.Adapter` wraps an effectful implementation module — one whose
  functions return `computation(return_type)` — with a handler stack and
  `Comp.run!/1`, producing a module that satisfies the Plain behaviour with
  plain Elixir functions.

  ## Options

    * `:contract` — the DoubleDown.Contract module (required)
    * `:impl` — the Effectful-behaviour implementation module (required)
    * `:stack` — a function `(computation -> computation)` that installs the
      handler stack (required)

  ## Example

      # Contract defines the port
      defmodule MyApp.UserService do
        use DoubleDown.Contract
        defcallback find_user(id :: String.t()) :: {:ok, User.t()} | {:error, term()}
      end

      # Effectful implementation satisfies Effectful behaviour
      defmodule MyApp.UserService.EffectfulImpl do
        @behaviour MyApp.UserService.Effectful
        defcomp find_user(id) do
          user <- MyApp.UserRepo.EffectPort.get(id)
          {:ok, user}
        end
      end

      # Skuld.Adapter satisfies Plain behaviour, runs effectful impl
      defmodule MyApp.UserService.Adapter do
        use Skuld.Adapter,
          contract: MyApp.UserService,
          impl: MyApp.UserService.EffectfulImpl,
          stack: &MyApp.Stacks.user_service/1
      end

      # Now MyApp.UserService.Adapter can be used as a plain implementation:
      MyApp.UserService.Adapter.find_user("user-123")
      # => {:ok, %User{...}}

  ## How It Works

  For each operation defined in the contract (via `__callbacks__/0`), the
  adapter generates a function that:

  1. Calls the impl module's corresponding function to get a computation
  2. Pipes through the stack function to install effect handlers
  3. Runs the computation with `Comp.run!/1`

  The generated module declares `@behaviour ContractModule`, ensuring
  compile-time verification that all required callbacks are implemented.

  ## Four Directions

  There are four scenarios for bridging between plain and effectful code:

    * **Skuld→Plain** — effectful code calls out to plain Elixir implementations
      through the Port effect, resolved by `Port.with_handler/2` at runtime.
    * **Skuld→Effectful** — effectful code calls out to effectful implementations
      through the Port effect with an `:effectful` resolver.
    * **Legacy→Plain** — plain Elixir code calls plain implementations through
      the DoubleDown-generated facade.
    * **Legacy→Effectful** — plain Elixir code calls into effectful implementations
      through `Skuld.Adapter`, which runs the effectful code with a handler stack,
      producing plain return values.

  ## Throw handler in the stack

  If the effectful implementation can throw (via `Skuld.Effects.Throw`), the
  stack function **must** install a `Throw.with_handler/1`. Without it,
  `Comp.run!/1` raises `Skuld.Comp.ThrowError`, which can be confusing if you
  don't realise a Throw handler is missing from the stack.

  A minimal stack that only handles throws:

      use Skuld.Adapter,
        contract: MyContract,
        impl: MyEffectfulImpl,
        stack: &Skuld.Effects.Throw.with_handler/1

  For stacks with multiple effects, place `Throw.with_handler/1` last (outermost)
  so it catches throws from all inner handlers:

      stack: fn comp ->
        comp
        |> State.with_handler(initial_state)
        |> Transaction.Ecto.with_handler(MyApp.Repo)
        |> Throw.with_handler()
      end

  ## Testing

  Adapters produce plain Elixir values, so they can be tested directly
  without effect machinery:

      test "adapter returns expected result" do
        result = MyApp.UserService.Adapter.find_user("user-123")
        assert {:ok, %User{id: "user-123"}} = result
      end

  To test the effectful implementation in isolation (without the adapter), use
  the standard effect testing patterns — install handlers and run the computation:

      test "effectful impl with handlers" do
        comp =
          MyApp.UserService.EffectfulImpl.find_user("user-123")
          |> MyApp.UserRepo.with_test_handler(...)
          |> Throw.with_handler()

        {result, _env} = Comp.run(comp)
        assert {:ok, %User{}} = result
      end
  """

  defmacro __using__(opts) do
    contract = Keyword.fetch!(opts, :contract)
    impl = Keyword.fetch!(opts, :impl)
    stack_ast = Keyword.fetch!(opts, :stack)

    # Store stack as escaped AST so it survives module attribute storage
    # and can be re-injected in __before_compile__
    escaped_stack = Macro.escape(stack_ast)

    quote do
      @before_compile {Skuld.Adapter, :__before_compile__}
      @__port_provider_contract__ unquote(contract)
      @__port_provider_impl__ unquote(impl)
      @__port_provider_stack_ast__ unquote(escaped_stack)
    end
  end

  defmacro __before_compile__(env) do
    contract = Module.get_attribute(env.module, :__port_provider_contract__)
    impl = Module.get_attribute(env.module, :__port_provider_impl__)
    stack_ast = Module.get_attribute(env.module, :__port_provider_stack_ast__)

    # Validate contract module has __callbacks__/0
    unless function_exported?(contract, :__callbacks__, 0) do
      raise CompileError,
        description:
          "#{inspect(contract)} does not appear to be a contract module " <>
            "(missing __callbacks__/0). Ensure it uses DoubleDown.Contract " <>
            "and defines at least one defcallback.",
        file: env.file,
        line: 0
    end

    operations = contract.__callbacks__()

    functions =
      Enum.map(operations, fn %{name: name, params: param_names} ->
        param_vars = Enum.map(param_names, fn pname -> {pname, [], nil} end)

        quote do
          @impl true
          def unquote(name)(unquote_splicing(param_vars)) do
            unquote(impl).unquote(name)(unquote_splicing(param_vars))
            |> unquote(stack_ast).()
            |> Skuld.Comp.run!()
          end
        end
      end)

    quote do
      @behaviour unquote(contract)
      unquote_splicing(functions)
    end
  end
end