Skip to main content

lib/rujira/coin.ex

defmodule Rujira.Coin do
  @moduledoc """
  Canonical coin type for Rujira.

  Represents an asset + amount pair. Amount is always an integer
  in smallest units. Asset is always resolved at construction time.
  """

  alias Rujira.Amount
  alias Rujira.Assets
  alias Rujira.Assets.Asset

  defstruct asset: nil, amount: 0

  @type t :: %__MODULE__{
          asset: Asset.t(),
          amount: Amount.t()
        }

  @doc "Returns the native denom string for this coin's asset."
  @spec denom(t()) :: {:ok, String.t()} | {:error, term()}
  def denom(%__MODULE__{asset: asset}), do: Assets.to_native(asset)

  @coin_regex ~r/^(\d+)\s*([a-zA-Z][a-zA-Z0-9\/:._-]{2,127})$/

  @spec new(Asset.t(), non_neg_integer()) :: t()
  def new(%Asset{} = asset, amount) when is_integer(amount) do
    %__MODULE__{asset: asset, amount: amount}
  end

  @spec new(String.t(), non_neg_integer()) :: {:ok, t()} | {:error, term()}
  def new(denom, amount) when is_binary(denom) and is_integer(amount) do
    with {:ok, asset} <- Assets.from_denom(denom) do
      {:ok, new(asset, amount)}
    end
  end

  @spec new(String.t(), String.t()) :: {:ok, t()} | {:error, term()}
  def new(denom, amount) when is_binary(denom) and is_binary(amount) do
    with {:ok, parsed} <- Amount.new(amount) do
      new(denom, parsed)
    end
  end

  @spec new(%{denom: String.t(), amount: String.t()}) :: {:ok, t()} | {:error, term()}
  def new(%{denom: denom, amount: amount}), do: new(denom, amount)

  @doc """
  Parse a comma-separated coin string into a list of coins.
  Supports both `"1000rune"` and `"1000 rune"` formats.
  """
  @spec parse(String.t()) :: {:ok, [t()]} | {:error, :invalid_coin_format}
  def parse(str) when is_binary(str) do
    str
    |> String.split(",", trim: true)
    |> Enum.reduce_while({:ok, []}, fn part, {:ok, acc} ->
      case parse_one(part) do
        {:ok, coin} -> {:cont, {:ok, [coin | acc]}}
        {:error, _} -> {:halt, {:error, :invalid_coin_format}}
      end
    end)
    |> case do
      {:ok, coins} -> {:ok, Enum.reverse(coins)}
      error -> error
    end
  end

  defp parse_one(str) do
    trimmed = String.trim(str)

    case String.split(trimmed, " ", parts: 2) do
      [amount_str, denom] when denom != "" ->
        with {:ok, amount} <- Amount.new(amount_str) do
          new(denom, amount)
        else
          _ -> {:error, :invalid_amount}
        end

      _ ->
        case Regex.run(@coin_regex, trimmed) do
          [_, amount_str, denom] ->
            with {:ok, amount} <- Amount.new(amount_str) do
              new(denom, amount)
            else
              _ -> {:error, :invalid_amount}
            end

          _ ->
            {:error, :invalid_coin_format}
        end
    end
  end
end