lib/fussy/validators/string.ex

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

  defstruct [:min_length, :max_length, :regex]

  @opaque t :: %__MODULE__{}

  @spec new(
          min_length: integer() | nil,
          max_length: integer() | nil,
          regex: Regex.t() | nil
        ) :: __MODULE__.t()
  def new(args \\ []), do: struct!(%__MODULE__{}, args)

  @impl true
  def validate(%__MODULE__{min_length: min_length, max_length: max_length, regex: regex}, str)
      when is_binary(str) do
    if String.valid?(str) do
      [min_length: min_length, max_length: max_length, regex: regex]
      |> Enum.filter(fn
        {_, nil} -> false
        _ -> true
      end)
      |> Enum.reduce([], fn validator, errors ->
        case validate_with(validator, str) do
          :ok -> errors
          reason -> [reason | errors]
        end
      end)
      |> then(fn
        [] -> {:ok, str}
        errors -> {:error, errors |> Enum.reverse()}
      end)
    else
      {:error, ["must be a valid utf-8 string"]}
    end
  end

  @impl true
  def validate(%__MODULE__{}, _), do: {:error, ["must be a valid utf-8 string"]}

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

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

  defp validate_with({:regex, regex}, str) do
    if Regex.match?(regex, str) do
      :ok
    else
      "must match regex /#{Regex.source(regex)}/"
    end
  end
end