Skip to main content

lib/ex_check/reporter.ex

defmodule ExCheck.Reporter do
  @moduledoc false

  # Behaviour for rendering the final check results. A reporter receives the full
  # list of result tuples once all tools have finished and is responsible for
  # producing the user-facing output for its format.
  #
  # Result tuples are `{status, {name, cmd, opts}, {exit_code, output, duration}}`
  # for run tools (status `:ok`/`:error`) and `{:skipped, name, reason}` for skipped
  # ones. `name` is an atom or `{atom, app}` for umbrella instances.

  @type result :: tuple
  @type opts :: keyword

  @callback report(results :: [result], total_duration :: integer, opts) :: :ok

  @formats %{
    pretty: ExCheck.Reporter.Pretty,
    json: ExCheck.Reporter.Json,
    agent: ExCheck.Reporter.Agent
  }

  @doc "Resolves a format atom to its reporter module."
  def resolve(format) do
    Map.get(@formats, format, ExCheck.Reporter.Pretty)
  end

  @doc "List of supported format atoms."
  def formats, do: Map.keys(@formats)

  @doc """
  Sink shared by the batch reporters (agent/json): writes the rendered report to the
  file given by `:output`, or to stdout when no output path is set.
  """
  # sobelow_skip ["Traversal.FileModule"]
  def emit(iodata, opts) do
    case Keyword.get(opts, :output) do
      nil -> IO.puts(iodata)
      path -> File.write!(path, iodata)
    end

    :ok
  end

  # Shared formatting helpers.

  @doc "Plain (uncolored) tool name, such as formatter or 'formatter in child'."
  def tool_name_string(name) when is_atom(name), do: Atom.to_string(name)
  def tool_name_string({name, app}), do: "#{name} in #{app}"

  @doc "Colored tool name iodata for terminal output."
  def format_tool_name(name) when is_atom(name), do: bright(name)
  def format_tool_name({name, app}) when is_atom(name), do: [bright(name), " in ", bright(app)]

  @doc "Formats a duration in seconds as `M:SS`."
  def format_duration(secs) do
    min = div(secs, 60)
    sec = rem(secs, 60)
    sec_str = if sec < 10, do: "0#{sec}", else: "#{sec}"

    "#{min}:#{sec_str}"
  end

  @doc "Wraps inner text in bright/normal ANSI markers."
  def bright(inner), do: [:bright, to_string(inner), :normal]

  @doc "Sort comparable for results: ok first, then errors, then skipped, by name."
  def summary_order({:ok, {name, _, _}, _}), do: {0, normalize_name(name)}
  def summary_order({:error, {name, _, _}, _}), do: {1, normalize_name(name)}
  def summary_order({:skipped, name, _}), do: {2, normalize_name(name)}

  defp normalize_name(name = {_, _}), do: name
  defp normalize_name(name), do: {name, 0}

  @ansi_code_regex ~r/\x1b\[[0-9;]*[a-zA-Z]/

  @doc "Strips ANSI escape codes from captured tool output."
  def strip_ansi(output), do: String.replace(output, @ansi_code_regex, "")

  @doc "Whether a captured output block needs a trailing blank line for padding."
  def output_needs_padding?(output) do
    not (String.match?(output, ~r/\n{2,}$/) or output == "")
  end

  @doc "Splits a result name into `{name_string, app_string_or_nil}`."
  def split_name(name) when is_atom(name), do: {Atom.to_string(name), nil}
  def split_name({name, app}), do: {Atom.to_string(name), to_string(app)}

  @doc "Renders a skip reason as a plain string."
  def skip_reason_string({:elixir, version}),
    do: "Elixir version = #{System.version()}, not #{version}"

  def skip_reason_string({:deps, [name | _]}),
    do: "unsatisfied dependency #{tool_name_string(name)}"

  def skip_reason_string({:package, name}), do: "missing package #{name}"
  def skip_reason_string({:package, name, app}), do: "missing package #{name} in #{app}"
  def skip_reason_string({:file, name}), do: "missing file #{name}"
  def skip_reason_string({:cd, cd}), do: "missing directory #{cd}"
end