lib/machete/matchers/iso8601_datetime_matcher.ex

defmodule Machete.ISO8601DateTimeMatcher do
  @moduledoc """
  Defines a matcher that matches ISO8601 formatted strings
  """

  import Machete.DateTimeMatcher
  import Machete.Mismatch
  import Machete.Operators

  defstruct datetime_opts: nil

  @typedoc """
  Describes an instance of this matcher
  """
  @opaque t :: %__MODULE__{}

  @typedoc """
  Describes the arguments that can be passed to this matcher
  """
  @type opts :: [
          {:precision, 0..6},
          {:time_zone, Calendar.time_zone() | :utc},
          {:exactly, DateTime.t()},
          {:roughly, DateTime.t() | :now},
          {:before, DateTime.t() | :now},
          {:after, DateTime.t() | :now}
        ]

  @doc """
  Matches against ISO8601 formatted strings

  Takes the following arguments:

  * `precision`: Requires the matched ISO8601 string to have the specified microsecond precision
  * `time_zone`: Requires the matched ISO8601 string to have the specified time zone. The atom
    `:utc` can be used to specify the "Etc/UTC" time zone
  * `exactly`: Requires the matched ISO8601 string to be exactly equal to the specified DateTime
  * `roughly`: Requires the matched ISO8601 string to be within +/- 10 seconds of the specified 
    DateTime. This values must be specified as a DateTime. The atom `:now` can be used to use the
    current time as the specified DateTime
  * `before`: Requires the matched ISO8601 string to be before or equal to the specified 
    DateTime. This values must be specified as a DateTime. The atom `:now` can be used to use the
    current time as the specified DateTime
  * `after`: Requires the matched ISO8601 string to be after or equal to the specified DateTime.
    This values must be specified as a DateTime. The atom `:now` can be used to use the current 
    time as the specified DateTime

  Examples:

      iex> assert "2020-01-01T00:00:00.000000Z" ~> iso8601_datetime()
      true

      iex> assert "2020-01-01T00:00:00.000000Z" ~> iso8601_datetime(precision: 6)
      true

      iex> assert "2020-01-01T00:00:00.000000Z" ~> iso8601_datetime(time_zone: :utc)
      true

      iex> assert "2020-01-01T00:00:00.000000Z" ~> iso8601_datetime(time_zone: "Etc/UTC")
      true

      iex> assert "2020-01-01 00:00:00.000000Z" ~> iso8601_datetime(exactly: ~U[2020-01-01 00:00:00.000000Z])
      true

      iex> assert DateTime.utc_now() |> DateTime.to_iso8601() ~> iso8601_datetime(roughly: :now)
      true

      iex> assert "2020-01-01T00:00:00.000000Z" ~> iso8601_datetime(roughly: ~U[2020-01-01 00:00:05.000000Z])
      true

      iex> assert "2020-01-01T00:00:00.000000Z" ~> iso8601_datetime(before: :now)
      true

      iex> assert "2020-01-01T00:00:00.000000Z" ~> iso8601_datetime(before: ~U[3000-01-01 00:00:00.000000Z])
      true

      iex> assert "3000-01-01T00:00:00.000000Z" ~> iso8601_datetime(after: :now)
      true

      iex> assert "3000-01-01T00:00:00.000000Z" ~> iso8601_datetime(after: ~U[2020-01-01 00:00:00.000000Z])
      true
  """
  @spec iso8601_datetime(opts()) :: t()
  def iso8601_datetime(opts \\ []), do: struct!(__MODULE__, datetime_opts: opts)

  defimpl Machete.Matchable do
    def mismatches(%@for{} = a, b) when is_binary(b) do
      DateTime.from_iso8601(b)
      |> case do
        {:ok, datetime_b, 0} -> datetime_b ~>> datetime(a.datetime_opts)
        _ -> mismatch("#{inspect(b)} is not a parseable ISO8601 datetime")
      end
    end

    def mismatches(%@for{}, b), do: mismatch("#{inspect(b)} is not a string")
  end
end