lib/beamchmark/formatter.ex

defmodule Beamchmark.Formatter do
  @moduledoc """
  The module defines a  behaviour that will be used to format and output `#{inspect(Beamchmark.Suite)}`.
  You can adopt this behaviour to implement custom formatters.

  The module contains helper functions for validating and applying formatters defined in configuration
  of `#{inspect(Beamchmark.Suite)}`.
  """

  alias Beamchmark.Suite

  @typedoc """
  Represents a module implementing `#{inspect(__MODULE__)}` behaviour.
  """
  @type t :: module()

  @typedoc """
  Options given to formatters (defined by formatters authors).
  """
  @type options_t :: Keyword.t()

  @doc """
  Takes the suite and transforms it into some internal representation, that later on will be passed to
  `write/2`.
  """
  @callback format(Suite.t(), options_t) :: any()

  @doc """
  Works like `format/2`, but can provide additional information by comparing the latest suite with the
  previous one (passed as the second argument).
  """
  @callback format(Suite.t(), Suite.t(), options_t) :: any()

  @doc """
  Takes the return value of `format/1` or `format/2` and outputs it in a convenient form (stdout, file, UI...).
  """
  @callback write(any, options_t) :: :ok

  @doc """
  Takes the suite and uses its formatters to output it. If the suite was configured with `compare?` flag enabled,
  the previous suite will be also provided to the formatters.
  """
  @spec output(Suite.t()) :: :ok
  def output(%Suite{} = suite) do
    with true <- suite.configuration.compare?,
         {:ok, base_suite} <- Suite.try_load_base(suite) do
      output_compare(suite, base_suite)
    else
      false ->
        output_single(suite)

      {:error, posix} ->
        Mix.shell().info("""
        Comparison is enabled, but did not found any previous measurements (error: #{inspect(posix)}).
        Proceeding with single suite...
        """)

        output_single(suite)
    end
  end

  defp output_single(%Suite{} = suite) do
    suite
    |> get_formatters()
    |> Enum.each(fn {formatter, options} ->
      :ok =
        suite
        |> formatter.format(options)
        |> formatter.write(options)
    end)
  end

  defp output_compare(%Suite{} = suite, %Suite{} = base) do
    suite
    |> get_formatters()
    |> Enum.each(fn {formatter, options} ->
      :ok =
        suite
        |> formatter.format(base, options)
        |> formatter.write(options)
    end)
  end

  defp get_formatters(%Suite{configuration: config}) do
    config.formatters
    |> Enum.map(fn formatter ->
      case formatter do
        {module, options} -> {module, options}
        module -> {module, []}
      end
    end)
    |> tap(fn formatters -> Enum.each(formatters, &validate/1) end)
  end

  defp validate({formatter, options}) do
    unless Keyword.keyword?(options) do
      raise(
        "Options for #{inspect(formatter)} need to be passed as a keyword list. Got: #{inspect(options)}."
      )
    end

    implements_formatter? =
      formatter.module_info(:attributes)
      |> Keyword.get(:behaviour, [])
      |> Enum.member?(__MODULE__)

    unless implements_formatter? do
      raise "#{inspect(formatter)} does not implement #{inspect(__MODULE__)} behaviour."
    end
  end
end