lib/exoddic.ex

defmodule Exoddic do
  @moduledoc """
  A means for working with odds and probability.

  In particular, a means to convert between different representations.
  """
  @typedoc """
  A keyword list with conversion options

  The `to` and `from` formats are identified by atoms corresponding to
  the converter module names. They default to `:prob`

  - `from`: the supplied input format
  - `to`: the desired output format
  - `for_display`: whether to nicely format the output as a string, defaults to `true`

  """
  @type exoddic_options :: [from: atom, to: atom, for_display: boolean]

  @spec parse_options(exoddic_options) :: {atom, atom, boolean}
  defp parse_options(options) do
    {module_from_options(options, :from), module_from_options(options, :to),
     Keyword.get(options, :for_display, true)}
  end

  @spec module_from_options(exoddic_options, atom) :: atom
  defp module_from_options(options, which) do
    Module.concat([
      __MODULE__,
      Converter,
      options |> Keyword.get(which, :prob) |> Atom.to_string() |> String.capitalize()
    ])
  end

  @doc """
  Convert values among the various supported odds formats.

  Conversion amounts provided as strings will receive a best effort attempt at conversion to
  an appropriate number.
  """
  @spec convert(number | String.t(), exoddic_options) :: String.t() | float
  def convert(amount, options \\ []) do
    {from_module, to_module, for_display} = parse_options(options)

    final_amount = amount |> normalize |> from_module.to_prob |> to_module.from_prob

    if for_display, do: to_module.for_display(final_amount), else: final_amount
  end

  @spec normalize(number | String.t()) :: float
  # Guarantee float
  defp normalize(amount) when is_number(amount), do: amount / 1.0

  defp normalize(amount) when is_bitstring(amount) do
    captures =
      Regex.named_captures(
        ~r/^(?<s>[\+-])?(?<n>[\d\.]+)(?<q>[\/:-])?(?<d>[\d\.]+)?(?<p>%)?$/,
        amount
      )

    value_from_captures(captures) * modifier_from_captures(captures)
  end

  defp modifier_from_captures(cap) do
    case cap do
      # Both sounds crazy
      %{"s" => "-", "p" => "%"} ->
        -1.0 / 100.0

      %{"s" => "-"} ->
        -1.0

      %{"p" => "%"} ->
        1 / 100

      # Unmodified: covers nil, a "+" sign, etc.
      _ ->
        1.0
    end
  end

  defp value_from_captures(cap) do
    case cap do
      # Not even close
      nil ->
        0.0

      # Does not parse a numerator
      %{"n" => ""} ->
        0.0

      # No quotient operator, just numerator
      %{"q" => "", "n" => n} ->
        fparse(n)

      # Quotient without denominator, failure
      %{"d" => ""} ->
        0.0

      %{"n" => n, "d" => d} ->
        fparse(n) / fparse(d)
    end
  end

  @spec fparse(String.t()) :: float
  # This should be reasonable given how we parsed the above.
  defp fparse(str), do: str |> Float.parse() |> elem(0)
end