lib/ark/error.ex

defmodule Ark.Error do
  @doc false
  def __ark__(:doc) do
    """
    This module provides function to work errors as data.
    """
  end

  import Kernel, except: [to_string: 1]

  defmacro reason(tag, data) do
    quote do
      {__MODULE__, unquote(tag), unquote(data)}
    end
  end

  @spec to_iodata(any) :: iodata()
  def to_iodata(reason)

  def to_iodata({:error, e}),
    do: to_iodata(e)

  def to_iodata({:shutdown, e}),
    do: ["(shutdown) ", to_iodata(e)]

  case Code.ensure_loaded(Ecto.Changeset) do
    {:module, _} ->
      def to_iodata(%Ecto.InvalidChangesetError{changeset: changeset, action: action}) do
        [
          "could not perform changeset action ",
          inspect(action),
          " ",
          to_iodata(changeset)
        ]
      end

      def to_iodata(%Ecto.Changeset{} = changeset) do
        details =
          changeset
          |> Ecto.Changeset.traverse_errors(fn {msg, opts} ->
            Enum.reduce(opts, msg, fn {key, value}, acc ->
              String.replace(acc, "%{#{key}}", inspect(value))
            end)
          end)
          |> Enum.map(fn {field, field_msgs} ->
            joined_errors = Enum.intersperse(field_msgs, ", ")
            [Atom.to_string(field), ": ", joined_errors]
          end)
          |> Enum.intersperse(" ; ")
          |> :lists.reverse()

        [
          "invalid changeset for ",
          inspect(changeset.data.__struct__),
          ", ",
          details
        ]
      end

    {:error, _} ->
      nil
  end

  def to_iodata({%{__exception__: true} = e, stack}) when is_list(stack),
    do: Exception.format_banner(:error, e, stack)

  def to_iodata(%{__exception__: true} = e),
    do: Exception.message(e)

  def to_iodata(%struct{message: message}) when is_binary(message),
    do: "#{inspect(struct)}: #{message}"

  def to_iodata(message) when is_binary(message),
    do: message

  def to_iodata({module, tag, data}) when is_atom(module) and is_atom(tag) do
    if function_exported?(module, :format_reason, 2),
      do: module.format_reason(tag, data),
      else: format_fallback(module, tag, data)
  end

  def to_iodata(other), do: inspect(other)

  def to_string(reason), do: reason |> to_iodata |> :erlang.iolist_to_binary()

  if Mix.env() != :prod do
    def format_fallback(module, tag, data) do
      IO.warn("""
      undefined function or function clause error when calling #{inspect(module)}.format_reason/2

      Please provide an implementation to suppress this warning.

        @doc false
        @spec format_reason(term, term) :: iodata
        def format_reason(#{inspect(tag)}, #{inspect(data)}) do
          # ...
        end

        def format_reason(other, data) do
          #{inspect(__MODULE__)}.format_fallback(__MODULE__, other, data)
        end

      """)

      "#{inspect({tag, data})}"
    end
  else
    def format_fallback(module, tag, data) do
      inspect({module, tag, data})
    end
  end

  defmacro log_error(error, metadata \\ []) do
    quote do
      require Logger
      Logger.error(unquote(__MODULE__).to_string(unquote(error)), unquote(metadata))
    end
  end

  defmacro debug_error(error, metadata \\ []) do
    quote do
      require Logger
      Logger.debug(unquote(__MODULE__).to_string(unquote(error)), unquote(metadata))
    end
  end
end