lib/skogsra/type.ex

defmodule Skogsra.Type do
  @moduledoc """
  This module defines the functions and behaviours for casting `Skogsra` types.
  """
  alias Skogsra.Env

  ############
  # Public API

  @doc """
  Callback for casting a value.
  """
  @callback cast(term()) :: {:ok, term()} | :error

  @doc """
  Uses `Skogsra.Type` for implementing the behaviour e.g. a naive implementation
  for casting `"1, 2, 3, 4"` to `["1", "2", "3", "4"]` would be:

  ```
  defmodule MyList do
    use Skogsra.Type

    def cast(value) when is_binary(value) do
      list =
        value
        |> String.split(~r/,/)
        |> Enum.map(&String.trim/1)
      {:ok, list}
    end

    def cast(_) do
      :error
    end
  end
  ```
  """
  defmacro __using__(_) do
    quote do
      @behaviour Skogsra.Type

      @typedoc """
      Type for #{__MODULE__}.
      """
      @type t :: term()

      def cast(value) do
        Skogsra.Type.cast_binary(value)
      end

      defoverridable cast: 1
    end
  end

  @doc """
  Casts an environment variable.
  """
  @spec cast(Env.t(), term()) :: {:ok, term()} | :error
  def cast(env, value)

  def cast(_env, nil) do
    {:ok, nil}
  end

  def cast(%Env{} = env, value) do
    type = Env.type(env)

    do_cast(type, value)
  end

  #########
  # Helpers

  @doc false
  @spec do_cast(Env.type(), term()) :: {:ok, term()} | :error
  def do_cast(:binary, value), do: cast_binary(value)
  def do_cast(:integer, value), do: cast_integer(value)
  def do_cast(:neg_integer, value), do: cast_neg_integer(value)
  def do_cast(:non_neg_integer, value), do: cast_non_neg_integer(value)
  def do_cast(:pos_integer, value), do: cast_pos_integer(value)
  def do_cast(:float, value), do: cast_float(value)
  def do_cast(:boolean, value), do: cast_boolean(value)
  def do_cast(:atom, value), do: cast_atom(value)
  def do_cast(:module, value), do: cast_module(value)
  def do_cast(:unsafe_module, value), do: cast_unsafe_module(value)
  def do_cast(:any, value), do: {:ok, value}
  def do_cast(module, value), do: module.cast(value)

  @doc false
  @spec cast_binary(term()) :: {:ok, binary()} | :error
  def cast_binary(value)

  def cast_binary(value) when is_binary(value) do
    {:ok, value}
  end

  def cast_binary(value) do
    {:ok, to_string(value)}
  rescue
    _ ->
      :error
  end

  @doc false
  @spec cast_integer(term()) :: {:ok, integer()} | :error
  def cast_integer(value) when is_integer(value) do
    {:ok, value}
  end

  def cast_integer(value) when is_binary(value) do
    case Integer.parse(value) do
      {value, ""} -> {:ok, value}
      _ -> :error
    end
  end

  def cast_integer(_) do
    :error
  end

  @doc false
  @spec cast_neg_integer(term()) :: {:ok, neg_integer()} | :error
  def cast_neg_integer(value) do
    case cast_integer(value) do
      {:ok, value} when value < 0 ->
        {:ok, value}

      _ ->
        :error
    end
  end

  @doc false
  @spec cast_non_neg_integer(term()) :: {:ok, non_neg_integer()} | :error
  def cast_non_neg_integer(value) do
    case cast_integer(value) do
      {:ok, value} when value >= 0 ->
        {:ok, value}

      _ ->
        :error
    end
  end

  @doc false
  @spec cast_pos_integer(term()) :: {:ok, pos_integer()} | :error
  def cast_pos_integer(value) do
    case cast_integer(value) do
      {:ok, value} when value > 0 ->
        {:ok, value}

      _ ->
        :error
    end
  end

  @doc false
  @spec cast_float(term()) :: {:ok, float()} | :error
  def cast_float(value) when is_float(value) do
    {:ok, value}
  end

  def cast_float(value) when is_binary(value) do
    case Float.parse(value) do
      {value, ""} -> {:ok, value}
      _ -> :error
    end
  end

  def cast_float(_) do
    :error
  end

  @doc false
  @spec cast_boolean(term()) :: {:ok, boolean()} | :error
  def cast_boolean(value) when is_boolean(value) do
    {:ok, value}
  end

  def cast_boolean(value) when is_binary(value) do
    case String.downcase(value) do
      "true" -> {:ok, true}
      "false" -> {:ok, false}
      _ -> :error
    end
  end

  def cast_boolean(_) do
    :error
  end

  @doc false
  @spec cast_atom(term()) :: {:ok, atom()} | :error
  def cast_atom(value) when is_atom(value) do
    {:ok, value}
  end

  def cast_atom(value) when is_binary(value) do
    {:ok, String.to_existing_atom(value)}
  rescue
    _ ->
      :error
  end

  def cast_atom(_) do
    :error
  end

  @doc false
  @spec cast_module(term()) :: {:ok, module()} | :error
  def cast_module(value) when is_atom(value) do
    if Code.ensure_loaded?(value) do
      {:ok, value}
    else
      :error
    end
  end

  def cast_module(value) when is_binary(value) do
    value
    |> String.split(".")
    |> Module.concat()
    |> cast_module()
  end

  def cast_module(_) do
    :error
  end

  @doc false
  @spec cast_unsafe_module(term()) :: {:ok, module()} | :error
  def cast_unsafe_module(value) when is_atom(value) do
    {:ok, value}
  end

  def cast_unsafe_module(value) when is_binary(value) do
    value
    |> String.split(".")
    |> Module.concat()
    |> cast_unsafe_module()
  end

  def cast_unsafe_module(_) do
    :error
  end
end