lib/fussy/validators/list.ex

defmodule Fussy.Validators.List do
  @behaviour Fussy.Validator

  alias Fussy.Utils

  defstruct [:inner, :min_length, :max_length]

  @opaque t :: %__MODULE__{}

  @spec new(
          Fussy.Validator.t(),
          min_length: non_neg_integer(),
          max_length: non_neg_integer()
        ) :: __MODULE__.t()
  def new(inner, args \\ []) do
    struct!(%__MODULE__{}, Keyword.merge(args, inner: inner))
  end

  @impl true
  def validate(%__MODULE__{inner: inner, min_length: min_length, max_length: max_length}, list)
      when is_list(list) do
    [min_length: min_length, max_length: max_length]
    |> Enum.filter(fn
      {_, nil} -> false
      _ -> true
    end)
    |> Enum.reduce([], fn validator, errors ->
      case validate_with(validator, list) do
        :ok -> errors
        reason -> [reason | errors]
      end
    end)
    |> then(fn
      [] -> validate_inner(inner, list)
      errors -> {:error, errors |> Enum.reverse()}
    end)
  end

  @impl true
  def validate(_, _), do: {:error, ["must be a list"]}

  defp validate_inner(inner, list) do
    list
    |> Enum.with_index()
    |> Enum.reduce({[], []}, fn {item, idx}, {valid_list, errors} ->
      case Utils.validate_using(inner, item) do
        {:ok, item} ->
          {[item | valid_list], errors}

        {:error, reason} ->
          {valid_list, ["idx #{idx} => {#{reason |> Enum.join(", ")}}" | errors]}
      end
    end)
    |> then(fn
      {valid_list, []} -> {:ok, valid_list |> Enum.reverse()}
      {_, errors} -> {:error, errors |> Enum.reverse()}
    end)
  end

  defp validate_with({:min_length, min_length}, list) do
    if length(list) >= min_length do
      :ok
    else
      "must have at least #{min_length} item(s)"
    end
  end

  defp validate_with({:max_length, max_length}, list) do
    if length(list) <= max_length do
      :ok
    else
      "must have at most #{max_length} item(s)"
    end
  end
end