lib/types/map.ex

defmodule Z.Map do
  @moduledoc """
  A module for validating a Map
  """

  alias Z.{Result, Error, Issue, Any}

  use Z.Type,
    options: Z.Any.__z__(:options) ++ [:atomize_keys, :size, :min, :max]

  def check(result, :conversions, rules, context) do
    result
    |> Any.check(:conversions, rules, context)
  end

  def check(result, :mutations, rules, context) do
    result
    |> Any.check(:mutations, rules, context)
    |> maybe_check(:atomize_keys, rules, context)
  end

  def check(result, :assertions, rules, context) do
    result
    |> Any.check(:assertions, rules, context)
    |> maybe_check(:size, rules, context)
    |> maybe_check(:min, rules, context)
    |> maybe_check(:max, rules, context)
  end

  def check(result, _rule, _options, _context) when result.value == nil do
    result
  end

  def check(result, :type, options, context) when not is_map(result.value) do
    message = Keyword.get(options, :message, "input is not a Map")

    result
    |> Result.add_issue(
      Issue.new(
        Error.Codes.invalid_type(),
        message,
        context
      )
    )
  end

  def check(result, :type, _options, _context) do
    result
  end

  def check(result, _rule, _options, _context) when not is_map(result.value) do
    result
  end

  def check(result, :atomize_keys, _enabled, _context) when is_struct(result.value) do
    result
  end

  def check(result, :atomize_keys, false, _context) do
    result
  end

  def check(result, :atomize_keys, true, context) do
    check(result, :atomize_keys, :existing_only, context)
  end

  def check(result, :atomize_keys, mode, context)
      when mode not in [:existing_only, :dangerously_allow_non_existing] do
    message = "atomize_keys mode must be :existing_only or :dangerously_allow_non_existing"

    result
    |> Result.add_issue(
      Issue.new(
        Error.Codes.invalid_arguments(),
        message,
        context
      )
    )
  end

  def check(result, :atomize_keys, :existing_only, context) do
    {kv_list, result} =
      Enum.map_reduce(result.value, result, fn kv, res ->
        key_to_existing_atom(kv, res, context)
      end)

    result |> Result.set_value(Map.new(kv_list))
  end

  def check(result, :atomize_keys, :dangerously_allow_non_existing, _context) do
    map =
      result.value
      |> Map.new(fn
        {k, v} when is_binary(k) -> {String.to_atom(k), v}
        {k, v} -> {k, v}
      end)

    result |> Result.set_value(map)
  end

  def check(result, :size, {size, _options}, context) when not is_integer(size) do
    message = "unable to check size with size: #{inspect(size)}, size must be an integer"

    result
    |> Result.add_issue(
      Issue.new(
        Error.Codes.invalid_arguments(),
        message,
        context
      )
    )
  end

  def check(result, :size, {size, options}, context) when map_size(result.value) < size do
    message = Keyword.get(options, :message, "input does not have correct size")

    result
    |> Result.add_issue(
      Issue.new(
        Error.Codes.too_small(),
        message,
        context
      )
    )
  end

  def check(result, :size, {size, options}, context) when map_size(result.value) > size do
    message = Keyword.get(options, :message, "input does not have correct size")

    result
    |> Result.add_issue(
      Issue.new(
        Error.Codes.too_big(),
        message,
        context
      )
    )
  end

  def check(result, :size, {_size, _options}, _context) do
    result
  end

  def check(result, :size, size, context) do
    check(result, :size, {size, []}, context)
  end

  def check(result, :min, {size, _options}, context) when not is_integer(size) do
    message = "unable to check min size with size: #{inspect(size)}, size must be an integer"

    result
    |> Result.add_issue(
      Issue.new(
        Error.Codes.invalid_arguments(),
        message,
        context
      )
    )
  end

  def check(result, :min, {size, options}, context) when map_size(result.value) < size do
    message = Keyword.get(options, :message, "input is too small")

    result
    |> Result.add_issue(
      Issue.new(
        Error.Codes.too_small(),
        message,
        context
      )
    )
  end

  def check(result, :min, {_size, _options}, _context) do
    result
  end

  def check(result, :min, size, context) do
    check(result, :min, {size, []}, context)
  end

  def check(result, :max, {size, _options}, context) when not is_integer(size) do
    message = "unable to check max size with size: #{inspect(size)}, size must be an integer"

    result
    |> Result.add_issue(
      Issue.new(
        Error.Codes.invalid_arguments(),
        message,
        context
      )
    )
  end

  def check(result, :max, {size, options}, context) when map_size(result.value) > size do
    message = Keyword.get(options, :message, "input is too big")

    result
    |> Result.add_issue(
      Issue.new(
        Error.Codes.too_big(),
        message,
        context
      )
    )
  end

  def check(result, :max, {_size, _options}, _context) do
    result
  end

  def check(result, :max, size, context) do
    check(result, :max, {size, []}, context)
  end

  defp key_to_existing_atom({k, v}, result, context) when is_binary(k) do
    try do
      a = String.to_existing_atom(k)
      {{a, v}, result}
    rescue
      _ ->
        message = "unable to atomize key"

        result =
          result
          |> Result.add_issue(
            Issue.new(
              Error.Codes.invalid_string(),
              message,
              Z.Context.new(nil, k, context)
            )
          )

        {{k, v}, result}
    end
  end

  defp key_to_existing_atom({k, v}, result, _context) do
    {{k, v}, result}
  end
end