lib/fussy/validators/string.ex

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

  alias Fussy.ValidationError

  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)

  def validate(%__MODULE__{} = v, term), do: validate(v, [], term)

  @impl true
  def validate(%__MODULE__{} = v, path, term) when is_binary(term) do
    if String.valid?(term) do
      validate_str(v, path, term)
    else
      {:error,
       [
         %ValidationError{
           path: path,
           mod: __MODULE__,
           msg: "must be a valid utf-8 string",
           term: term
         }
       ]}
    end
  end

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

  defp validate_str(
         %__MODULE__{min_length: min_length, max_length: max_length, regex: regex},
         path,
         term
       ) 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, term) do
        :ok -> errors
        reason -> [reason | errors]
      end
    end)
    |> then(fn
      [] ->
        {:ok, term}

      errors ->
        {:error,
         errors
         |> Enum.map(fn msg ->
           %ValidationError{path: path, mod: __MODULE__, msg: msg, term: term}
         end)}
    end)
  end

  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