defmodule CheckEscript.Coverage do @moduledoc "Coverage merging, caching, and threshold checking." @coverage_cache_path ".check/coverage_cache" def merge(%{mod: false}), do: :ok def merge(%{mod: :native, html: html} = coverage) do IO.puts([IO.ANSI.format([:cyan, "\nMerging coverage data..."])]) current_hash = coverdata_hash() case read_cache(current_hash) do {:ok, cached_output} -> IO.puts([IO.ANSI.format([:cyan, "(cached)"])]) check(cached_output, "cover/", coverage) :miss -> port = CheckEscript.Port.open("mix", ["test.coverage"]) output = if html do {out, _status} = collect_port_until_exit(port, "") out else {out, _status} = collect_coverage_output(port, "") out end write_cache(current_hash, output) check(output, "cover/", coverage) end end def merge(%{mod: :coveralls} = coverage) do IO.puts([IO.ANSI.format([:cyan, "\nMerging coverage data..."])]) {output, _status} = System.cmd("mix", ["coveralls", "--import-cover", "cover/"], stderr_to_stdout: true) check(output, "cover/", coverage) end # Stream output from mix test.coverage, kill once Total line is found defp collect_coverage_output(port, acc) do receive do {^port, {:data, data}} -> acc = acc <> data if String.contains?(acc, "| Total") do Port.close(port) receive do {^port, {:exit_status, status}} -> {acc, status} after 100 -> {acc, 0} end else collect_coverage_output(port, acc) end {^port, {:exit_status, status}} -> {acc, status} end end defp collect_port_until_exit(port, acc) do receive do {^port, {:data, data}} -> collect_port_until_exit(port, acc <> data) {^port, {:exit_status, status}} -> {acc, status} end end def check(output, dir, coverage) when is_map(coverage) do case parse_total_percentage(output) do nil -> IO.puts([IO.ANSI.format([:yellow, "Warning: Could not parse coverage from output"])]) IO.puts(output) :ok percentage -> result = report_coverage(percentage, dir, coverage.limit) compare_baseline(percentage, coverage.baseline_cmd) result end end # backward compat for direct limit value def check(output, dir, limit) do check(output, dir, %{limit: limit, baseline_cmd: nil}) end defp parse_total_percentage(output) do case Regex.run(~r/(\d+\.?\d*)%\s*\|\s*Total|Coverage:\s+(\d+\.?\d*)%/, output) do [_, percentage, ""] -> String.to_float(percentage) [_, "", percentage] -> String.to_float(percentage) [_, percentage] -> String.to_float(percentage) _ -> nil end end defp report_coverage(percentage, dir, limit) when is_number(limit) and percentage < limit do IO.puts([ IO.ANSI.format([:red, "✗ Coverage: #{percentage}% (limit: #{limit}%) | Report: #{dir}"]) ]) :failed end defp report_coverage(percentage, dir, _limit) do color = if percentage >= 80, do: :green, else: :yellow IO.puts([IO.ANSI.format([color, "✓ Coverage: #{percentage}% | Report: #{dir}"])]) :ok end defp compare_baseline(_percentage, nil), do: :ok defp compare_baseline(percentage, baseline_cmd) do case System.cmd("sh", ["-c", baseline_cmd], stderr_to_stdout: true) do {output, 0} -> case Float.parse(String.trim(output)) do {baseline, _} -> print_delta(percentage, baseline) :error -> IO.puts([ IO.ANSI.format([ :yellow, "Warning: Could not parse baseline coverage from: #{String.trim(output)}" ]) ]) end {error, _} -> IO.puts([ IO.ANSI.format([:yellow, "Warning: Baseline command failed: #{String.trim(error)}"]) ]) end end defp print_delta(percentage, baseline) do delta = Float.round(percentage - baseline, 2) cond do delta > 0 -> IO.puts([IO.ANSI.format([:green, " Coverage: +#{delta}% vs baseline (#{baseline}%)"])]) delta < 0 -> IO.puts([IO.ANSI.format([:red, " Coverage: #{delta}% vs baseline (#{baseline}%)"])]) true -> IO.puts([IO.ANSI.format([:cyan, " Coverage: same as baseline (#{baseline}%)"])]) end end defp coverdata_hash do Path.wildcard("cover/*.coverdata") |> Enum.sort() |> Enum.reduce(:crypto.hash_init(:md5), fn path, acc -> :crypto.hash_update(acc, File.read!(path)) end) |> :crypto.hash_final() |> Base.encode16(case: :lower) end defp read_cache(current_hash) do hash_file = @coverage_cache_path <> ".hash" output_file = @coverage_cache_path <> ".txt" if File.exists?(hash_file) and File.exists?(output_file) do cached_hash = File.read!(hash_file) |> String.trim() if cached_hash == current_hash do {:ok, File.read!(output_file)} else :miss end else :miss end end defp write_cache(hash, output) do File.mkdir_p!(".check") File.write!(@coverage_cache_path <> ".hash", hash) File.write!(@coverage_cache_path <> ".txt", output) end end