lib/archeometer/explore/coverage.ex

defmodule Archeometer.Explore.Coverage do
  @moduledoc """
  Module for collecting test coverage statistics, using Erlang `cover` module.

  As it's a tool for Erlang code, instead of using its module and function
  capabilities, the line coverage is directly used, and then counted per module,
  in the same manner the Mix `test.coverage` task does.
  """

  def prepare_code_server() do
    _code_server = cover_compile()
    run_tests()
  end

  def calculate_module_coverage() do
    module_coverage =
      get_line_coverage()
      |> mod_coverage_from_lines()

    missing_coverage =
      module_coverage
      |> MapSet.new(fn %{module: mod} -> mod end)
      |> missing_mods()

    module_coverage
    |> Enum.concat(missing_coverage)
    |> Enum.sort()
  end

  def calculate_function_coverage() do
    get_function_coverage()
    |> Enum.map(fn {{module, name, arity}, coverage} ->
      %{name: name, module: module, num_args: arity, coverage: coverage}
    end)
  end

  defp missing_mods(coverage) do
    :cover.modules()
    |> Enum.filter(fn mod -> mod not in coverage end)
    |> Enum.map(fn mod -> %{module: mod, coverage: 1.0} end)
  end

  defp run_tests() do
    unless System.get_env("MIX_ENV") || Mix.env() == :test do
      Mix.raise("env: test != #{Mix.env()}")
    end

    Mix.Task.run("test")
  end

  defp mod_coverage_from_lines(line_diagnosis) do
    line_diagnosis
    |> Enum.group_by(fn {{mod, _}, _} -> mod end, fn {_, line_diagnosis} ->
      # a line is considere covered if any of its diagnosis shows it as covered
      Enum.any?(line_diagnosis)
    end)
    |> Enum.map(fn {mod, line_coverage} ->
      covered = Enum.count(line_coverage, & &1)
      %{module: mod, coverage: covered / length(line_coverage)}
    end)
  end

  defp get_line_coverage() do
    {:result, raw_coverage, _failed} = :cover.analyse(:coverage, :line)

    raw_coverage
    |> Enum.filter(fn {{_, line}, _} -> line != 0 end)
    |> Enum.group_by(&elem(&1, 0), fn {_, cov} ->
      case cov do
        {1, 0} -> true
        {0, 1} -> false
      end
    end)
  end

  defp get_function_coverage() do
    {:result, raw_coverage, _failed} = :cover.analyse(:coverage, :function)

    raw_coverage
    |> Enum.map(fn {function_id, {covered, uncovered}} ->
      {function_id, covered / (covered + uncovered)}
    end)
  end

  def cover_compile() do
    _ = :cover.stop()

    Mix.Task.run("compile")

    {:ok, pid} = :cover.start()

    compiled_files =
      Mix.Project.config()
      |> app_paths()
      |> Enum.flat_map(&beams(&1))
      |> :cover.compile_beam()

    case compiled_files do
      results when is_list(results) ->
        :ok

      {:error, reason} ->
        Mix.raise("#{inspect(reason)}")
    end

    pid
  end

  defp app_paths(config) do
    case Mix.Project.apps_paths(config) do
      nil ->
        [Mix.Project.compile_path()]

      paths ->
        build_path = Mix.Project.build_path(config)

        Enum.map(paths, fn {app, _} ->
          Path.join([build_path, "lib", Atom.to_string(app), "ebin"])
        end)
    end
  end

  defp beams(dir) do
    consolidation_dir = Mix.Project.consolidation_path()

    consolidated =
      case File.ls(consolidation_dir) do
        {:ok, files} -> files
        _ -> []
      end

    for file <- File.ls!(dir), Path.extname(file) == ".beam" do
      with true <- file in consolidated,
           [_ | _] = path <- :code.which(file |> Path.rootname() |> String.to_atom()) do
        path
      else
        _ -> String.to_charlist(Path.join(dir, file))
      end
    end
  end
end