lib/durex.ex

defmodule Durex do
  @moduledoc """
  Parse durations, such as `"1s"`, to its numerical millisecond value, e.g. `1_000`,
  so you can do things such as:

  ```
  "1s"
  |> Durex.ms!()
  |> Process.sleep()
  ```

  ## Examples

      iex> Durex.ms "3s"
      {:ok, 3_000}

      iex> Durex.ms "1h"
      {:ok, 3600_000}

      # Works with float too
      iex> Durex.ms "0.5s"
      {:ok, 500}

      iex> Durex.ms "1.5h"
      {:ok, 5400_000}

      # Cannot ms duration less than 1ms | 1.0ms
      iex> Durex.ms "0.5ms"
      :error

      # Fractional durations in ms will be truncated
      iex> Durex.ms "1.5ms"
      {:ok, 1}

  Of course, there is also the bang version `ms!/1`:

      # Bang version available
      iex> Durex.ms! "3s"
      3_000

  #### Supported units

    * `ms` (for millisecond)
    * `s` (for second)
    * `m` (for minute)
    * `h` (for hour)
    * `d` (for days)
    * `w` (for week)

  #### Performance Notes

    * Parsing durations which include integers is about 4x faster
      than their version containing floats.
      So instead of parsing "0.5s", use "500ms" for maximum performance.

    * To benchmark, run: `$ mix run bench/ms.exs`

  """

  @type duration :: bitstring

  defmacrop s_to_ms(s), do: quote(do: 1_000 * unquote(s))
  defmacrop m_to_ms(m), do: quote(do: 1_000 * 60 * unquote(m))
  defmacrop h_to_ms(h), do: quote(do: 1_000 * 60 * 60 * unquote(h))
  defmacrop d_to_ms(d), do: quote(do: 1_000 * 60 * 60 * 24 * unquote(d))
  defmacrop w_to_ms(w), do: quote(do: 1_000 * 60 * 60 * 24 * 7 * unquote(w))

  @doc "Parse duration as milliseconds"
  @spec ms(duration) :: {:ok, pos_integer} | :error
  def ms(duration) when is_bitstring(duration) do
    case Integer.parse(duration) do
      {ms, "ms"} when ms >= 1 ->
        {:ok, ms}

      {s, "s"} when s > 0 ->
        {:ok, s_to_ms(s)}

      {m, "m"} when m > 0 ->
        {:ok, m_to_ms(m)}

      {h, "h"} when h > 0 ->
        {:ok, h_to_ms(h)}

      {d, "d"} when d > 0 ->
        {:ok, d_to_ms(d)}

      {w, "w"} when w > 0 ->
        {:ok, w_to_ms(w)}

      _ ->
        case Float.parse(duration) do
          {ms, "ms"} when ms >= 1.0 -> {:ok, trunc(ms)}
          {s, "s"} when s > 0.0 -> {:ok, trunc(s_to_ms(s))}
          {m, "m"} when m > 0.0 -> {:ok, trunc(m_to_ms(m))}
          {h, "h"} when h > 0.0 -> {:ok, trunc(h_to_ms(h))}
          {d, "d"} when d > 0.0 -> {:ok, trunc(d_to_ms(d))}
          {w, "w"} when w > 0.0 -> {:ok, trunc(w_to_ms(w))}
          _ -> :error
        end
    end
  end

  @doc "Parse duration but raise if it fails"
  @spec ms!(duration) :: pos_integer
  def ms!(duration) do
    case ms(duration) do
      {:ok, ms} -> ms
      :error -> raise ArgumentError, "cannot parse #{inspect(duration)}"
    end
  end
end