lib/short_uuid.ex

defmodule ShortUUID do
  import ShortUUID.Guards

  @external_resource "README.md"

  @moduledoc "README.md"
             |> File.read!()
             |> String.split("<!-- MDOC !-->")
             |> Enum.fetch!(1)

  @abc ["1", "2", "3", "4", "5", "6", "7", "8", "9"] ++
         ["A", "B", "C", "D", "E", "F", "G", "H"] ++
         ["J", "K", "L", "M", "N"] ++
         ["P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z"] ++
         ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k"] ++
         ["m", "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z"]

  @abc_length Enum.count(@abc)

  @typedoc "A standard UUID string, e.g. `\"64d7280f-736a-4ffa-b9c0-383f43486d0b\"`."
  @type uuid :: binary()

  @typedoc "A Base58-encoded UUID, e.g. `\"DTEETeS5R2XxjrVTZxXoJS\"`."
  @type short_uuid :: binary()

  @doc """
  Encodes the given UUID into a ShortUUID.

  Returns `{:ok, short_uuid}` on success, `:error` on invalid input.

  ## Examples

      iex> ShortUUID.encode("64d7280f-736a-4ffa-b9c0-383f43486d0b")
      {:ok, "DTEETeS5R2XxjrVTZxXoJS"}

      iex> ShortUUID.encode("invalid-uuid-here")
      :error

  """
  @spec encode(uuid()) :: {:ok, short_uuid()} | :error
  def encode(input) when is_uuid(input) do
    input
    |> String.replace("-", "")
    |> String.to_integer(16)
    |> encode("")
  end

  def encode(_), do: :error

  defp encode(input, output) when input > 0 do
    index = rem(input, @abc_length)
    input = div(input, @abc_length)
    output = "#{Enum.at(@abc, index)}#{output}"

    encode(input, output)
  end

  defp encode(0, output), do: {:ok, output}

  @doc """
  Encodes the given UUID into a ShortUUID.

  Raises `ArgumentError` on invalid input.

  ## Examples

      iex> ShortUUID.encode!("64d7280f-736a-4ffa-b9c0-383f43486d0b")
      "DTEETeS5R2XxjrVTZxXoJS"

  """
  @doc since: "2.1.0"
  @spec encode!(uuid()) :: short_uuid()
  def encode!(input) do
    case encode(input) do
      {:ok, result} -> result
      :error -> raise ArgumentError
    end
  end

  @doc """
  Decodes the given ShortUUID back into a UUID.

  Returns `{:ok, uuid}` on success, `:error` on invalid input.

  ## Examples

      iex> ShortUUID.decode("DTEETeS5R2XxjrVTZxXoJS")
      {:ok, "64d7280f-736a-4ffa-b9c0-383f43486d0b"}

      iex> ShortUUID.decode("DTEETeS5R2XxjrVTZxXoJS123")
      :error

      iex> ShortUUID.decode("InvalidShortUUID")
      :error

  """
  @spec decode(short_uuid()) :: {:ok, uuid()} | :error
  def decode(input) when is_binary(input) do
    codepoints = String.codepoints(input)

    unless Enum.all?(codepoints, &(&1 in @abc)) do
      :error
    else
      codepoints
      |> Enum.reduce(0, fn codepoint, acc ->
        acc * @abc_length + Enum.find_index(@abc, &(&1 == codepoint))
      end)
      |> Integer.to_string(16)
      |> String.pad_leading(32, "0")
      |> String.downcase()
      |> format()
    end
  end

  @doc """
  Decodes the given ShortUUID back into a UUID.

  Raises `ArgumentError` on invalid input.

  ## Examples

      iex> ShortUUID.decode!("DTEETeS5R2XxjrVTZxXoJS")
      "64d7280f-736a-4ffa-b9c0-383f43486d0b"

  """
  @doc since: "2.1.0"
  @spec decode!(short_uuid()) :: uuid()
  def decode!(input) do
    case decode(input) do
      {:ok, result} -> result
      :error -> raise ArgumentError
    end
  end

  defp format(<<a::64, b::32, c::32, d::32, e::96>>) do
    {:ok, <<a::64, ?-, b::32, ?-, c::32, ?-, d::32, ?-, e::96>>}
  end

  defp format(_), do: :error
end