lib/chaps/local.ex

defmodule Chaps.Local do
  @moduledoc """
  Locally displays the result to screen.
  """

  defmodule Count do
    @moduledoc """
    Stores count information for calculating coverage values.
    """

    defstruct lines: 0, relevant: 0, covered: 0
  end

  @doc """
  Provides an entry point for the module.
  """
  def execute(stats, options \\ []) do
    print_summary(stats, options)

    if options[:detail] == true do
      source(stats, options[:filter]) |> IO.puts()
    end

    Chaps.Stats.ensure_minimum_coverage(stats)
  end

  @doc """
  Format the source code with color for the files that matches with
  the specified patterns.
  """
  def source(stats, _patterns = nil), do: source(stats)
  def source(stats, _patterns = []), do: source(stats)

  def source(stats, patterns) do
    Enum.filter(stats, fn stat -> String.contains?(stat[:name], patterns) end)
    |> source
  end

  def source(stats) do
    stats
    |> Enum.map(&format_source/1)
    |> Enum.join("\n")
  end

  @doc """
  Prints summary statistics for given coverage.
  """
  def print_summary(stats, options \\ []) do
    enabled = Chaps.Settings.get_print_summary()

    if enabled do
      coverage(stats, options) |> IO.puts()
    end
  end

  defp format_source(stat) do
    "\n\e[33m--------#{stat[:name]}--------\e[m\n" <> colorize(stat)
  end

  defp colorize(%{name: _name, source: source, coverage: coverage}) do
    lines = String.split(source, "\n")

    Enum.zip(lines, coverage)
    |> Enum.map(&do_colorize/1)
    |> Enum.join("\n")
  end

  defp do_colorize({line, coverage}) do
    case coverage do
      nil -> line
      0 -> "\e[31m#{line}\e[m"
      _ -> "\e[32m#{line}\e[m"
    end
  end

  @doc """
  Format the source coverage stats into string.
  """
  def coverage(stats, options \\ []) do
    file_width = Chaps.Settings.get_file_col_width()
    print_files? = Chaps.Settings.get_print_files()
    filter_fully_covered? = Chaps.Settings.get_terminal_filter_fully_covered()

    count_info =
      Enum.map(stats, fn stat -> [stat, calculate_count(stat[:coverage])] end)

    count_info = sort(count_info, options)

    filter_fully_covered_disclaimer =
      if filter_fully_covered? do
        "\nFully covered modules were omitted (the :filter_fully_covered terminal option is on).\n"
      else
        ""
      end

    if print_files? do
      """
      ----------------
      #{print_string("~-6s ~-#{file_width}s ~8s ~8s ~8s", ["COV", "FILE", "LINES", "RELEVANT", "MISSED"])}
      #{Enum.join(format_body(count_info, filter_fully_covered?) ++ [""], "\n")}\
      #{format_total(count_info)}
      #{filter_fully_covered_disclaimer}\
      ----------------\
      """
    else
      "Test Coverage #{format_total(count_info)}\n"
    end
  end

  defp sort(count_info, options) do
    if options[:sort] do
      sort_order = parse_sort_options(options)

      flattened =
        Enum.map(count_info, fn original ->
          [stat, count] = original

          %{
            "cov" => get_coverage(count),
            "file" => stat[:name],
            "lines" => count.lines,
            "relevant" => count.relevant,
            "missed" => count.relevant - count.covered,
            :_original => original
          }
        end)

      sorted =
        Enum.reduce(sort_order, flattened, fn {key, comparator}, acc ->
          Enum.sort(acc, fn x, y ->
            args = [x[key], y[key]]
            apply(Kernel, comparator, args)
          end)
        end)

      Enum.map(sorted, fn flattened -> flattened[:_original] end)
    else
      Enum.sort(count_info, fn [x, _], [y, _] -> x[:name] <= y[:name] end)
    end
  end

  defp parse_sort_options(options) do
    sort_order =
      options[:sort]
      |> String.split(",")
      |> Enum.reverse()

    Enum.map(sort_order, fn sort_chunk ->
      case String.split(sort_chunk, ":") do
        [key, "asc"] -> {key, :<=}
        [key, "desc"] -> {key, :>=}
        [key] -> {key, :>=}
      end
    end)
  end

  defp format_body(info, filter_fully_covered?) do
    info
    |> Enum.reject(fn [_stat, count] ->
      filter_fully_covered? and get_coverage(count) == 100.0
    end)
    |> Enum.map(&format_info/1)
  end

  defp format_info([stat, count]) do
    coverage = get_coverage(count)
    file_width = Chaps.Settings.get_file_col_width()

    print_string(
      "~5.1f% ~-#{file_width}s ~8w ~8w ~8w",
      [
        coverage,
        stat[:name],
        count.lines,
        count.relevant,
        count.relevant - count.covered
      ]
    )
  end

  defp format_total(info) do
    totals =
      Enum.reduce(info, %Count{}, fn [_, count], acc -> append(count, acc) end)

    coverage = get_coverage(totals)
    print_string("[TOTAL] ~5.1f%", [coverage])
  end

  defp append(a, b) do
    %Count{
      lines: a.lines + b.lines,
      relevant: a.relevant + b.relevant,
      covered: a.covered + b.covered
    }
  end

  defp get_coverage(count) do
    case count.relevant do
      0 -> Chaps.Settings.default_coverage_value()
      _ -> count.covered / count.relevant * 100
    end
  end

  @doc """
  Calculate count information from the coverage stats.
  """
  def calculate_count(coverage) do
    do_calculate_count(coverage, 0, 0, 0)
  end

  defp do_calculate_count([], lines, relevant, covered) do
    %Count{lines: lines, relevant: relevant, covered: covered}
  end

  defp do_calculate_count([h | t], lines, relevant, covered) do
    case h do
      nil ->
        do_calculate_count(t, lines + 1, relevant, covered)

      0 ->
        do_calculate_count(t, lines + 1, relevant + 1, covered)

      n when is_number(n) ->
        do_calculate_count(t, lines + 1, relevant + 1, covered + 1)

      _ ->
        raise "Invalid data - #{h}"
    end
  end

  defp print_string(format, params) do
    char_list = :io_lib.format(format, params)
    List.to_string(char_list)
  end
end