lib/reporters/full.ex

defmodule Doctor.Reporters.Full do
  @moduledoc """
  This reporter generates a full documentation coverage report and lists
  all the files in the project along with whether they pass or fail.
  """

  @behaviour Doctor.Reporter

  alias Doctor.{Reporters.OutputUtils, ReportUtils}
  alias Elixir.IO.ANSI

  @doc_cov_width 9
  @spec_cov_width 10
  @module_width 41
  @file_width 58
  @functions_width 11
  @missed_docs_width 9
  @missed_specs_width 10
  @module_doc_width 12
  @struct_type_spec_width 11

  @doc """
  Generate a full Doctor report and print to STDOUT
  """
  @impl true
  def generate_report(module_reports, args) do
    print_divider()
    print_header()

    Enum.each(module_reports, fn module_report ->
      doc_cov = massage_coverage(module_report.doc_coverage)
      spec_cov = massage_coverage(module_report.spec_coverage)
      module_doc = massage_module_doc(module_report)
      struct_type_spec = massage_struct_type_spec(module_report.has_struct_type_spec)

      output_line =
        OutputUtils.generate_table_line([
          {doc_cov, @doc_cov_width},
          {spec_cov, @spec_cov_width},
          {module_report.module, @module_width},
          {module_report.file, @file_width},
          {module_report.functions, @functions_width},
          {module_report.missed_docs, @missed_docs_width},
          {module_report.missed_specs, @missed_specs_width},
          {module_doc, @module_doc_width},
          {struct_type_spec, @struct_type_spec_width, 0}
        ])

      if ReportUtils.module_passed_validation?(module_report, args) do
        unless args.failed do
          Mix.shell().info(output_line)
        end
      else
        Mix.shell().info(ANSI.red() <> output_line <> ANSI.reset())
      end
    end)

    overall_errors = ReportUtils.doctor_report_errors(module_reports, args)
    overall_passed = ReportUtils.count_total_passed_modules(module_reports, args)
    overall_failed = ReportUtils.count_total_failed_modules(module_reports, args)
    overall_doc_coverage = ReportUtils.calc_overall_doc_coverage(module_reports)
    overall_moduledoc_coverage = ReportUtils.calc_overall_moduledoc_coverage(module_reports)
    overall_spec_coverage = ReportUtils.calc_overall_spec_coverage(module_reports)

    print_footer(
      overall_errors,
      overall_passed,
      overall_failed,
      overall_doc_coverage,
      overall_moduledoc_coverage,
      overall_spec_coverage
    )
  end

  defp print_header() do
    output_header =
      OutputUtils.generate_table_line([
        {"Doc Cov", @doc_cov_width},
        {"Spec Cov", @spec_cov_width},
        {"Module", @module_width},
        {"File", @file_width},
        {"Functions", @functions_width},
        {"No Docs", @missed_docs_width},
        {"No Specs", @missed_specs_width},
        {"Module Doc", @module_doc_width},
        {"Struct Spec", @struct_type_spec_width, 0}
      ])

    Mix.shell().info(output_header)
  end

  defp print_divider do
    "-"
    |> String.duplicate(171)
    |> Mix.shell().info()
  end

  defp print_footer(errors, passed, failed, doc_coverage, moduledoc_coverage, spec_coverage) do
    doc_coverage = Decimal.round(doc_coverage, 1)
    moduledoc_coverage = Decimal.round(moduledoc_coverage, 1)
    spec_coverage = Decimal.round(spec_coverage, 1)

    print_divider()
    Mix.shell().info("Summary:\n")
    Mix.shell().info("Passed Modules: #{passed}")
    Mix.shell().info("Failed Modules: #{failed}")
    Mix.shell().info("Total Doc Coverage: #{doc_coverage}%")
    Mix.shell().info("Total Moduledoc Coverage: #{moduledoc_coverage}%")
    Mix.shell().info("Total Spec Coverage: #{spec_coverage}%\n")

    msg =
      case errors do
        [] ->
          "Doctor validation has passed!"

        [err] ->
          """
          #{ANSI.red()}Doctor validation has failed because #{err}.#{ANSI.reset()}
          """

        errs ->
          """
          #{ANSI.red()}Doctor validation has failed because:
            * #{Enum.map_join(errs, ".\n  * ", &String.capitalize(&1))}.\
          #{ANSI.reset()})")
          """
      end

    Mix.shell().info(msg)
  end

  defp massage_coverage(coverage) do
    if coverage do
      "#{Decimal.round(coverage)}%"
    else
      "N/A"
    end
  end

  defp massage_module_doc(%{is_protocol_implementation: true}), do: "N/A"
  defp massage_module_doc(%{has_module_doc: true}), do: "Yes"
  defp massage_module_doc(%{has_module_doc: false}), do: "No"

  defp massage_struct_type_spec(:not_struct), do: "N/A"
  defp massage_struct_type_spec(true), do: "Yes"
  defp massage_struct_type_spec(false), do: "No"
end