lib/mix/tasks/doctor.explain.ex

defmodule Mix.Tasks.Doctor.Explain do
  @moduledoc """
  Figuring out why a particular module failed Doctor validation can sometimes
  be a bit difficult when the relevant information is embedded within a table with
  other validation results.

  The `mix doctor.explain` command has only a single required argument. That argument
  is the name of the module that you wish to get a detailed report of. For example you
  could run the following from the terminal:

  ```
  $ mix doctor.explain MyApp.Some.Module
  ```

  To generate a report like this:

  ```
  Doctor file found. Loading configuration.

  Function            @doc  @spec
  -------------------------------
  generate_report/2   ✗     ✗

  Module Results:
    Doc Coverage:    0.0%  --> Your config has a 'min_module_doc_coverage' value of 80
    Spec Coverage:   0.0%
    Has Module Doc:  ✓
    Has Struct Spec: N/A
  ```

  In addition, the following CLI flags are supported (similarly to the `mix doctor`
  command):

  ```
  --config-file  Provide a relative or absolute path to a `.doctor.exs`
                 file to use during the execution of the mix command.

  --raise        If any of your modules fails Doctor validation, then
                 raise an error and return a non-zero exit status.
  ```

  To use these command line args you would do something like so:

  ```
  $ mix doctor.explain --raise --config_file /some/path/to/some/.doctor.exs MyApp.Some.Module
  ```

  Note that `mix doctor.explain` takes a module name instead of a file path since you can
  define multiple modules in a single file.
  """

  use Mix.Task

  alias Doctor.{CLI, Config}

  @shortdoc "Debug why a particular module is failing validation"
  @recursive true
  @umbrella_accumulator Doctor.Umbrella

  @impl true
  def run(args) do
    {cli_arg_opts, args} = parse_cli_args(args)
    config_file_opts = load_config_file(cli_arg_opts)

    # Aggregate all of the various options sources
    # Precedence order is:
    # default < config file < cli args
    config =
      config_file_opts
      |> Map.merge(cli_arg_opts)
      |> Config.new()

    # Get the module name from args
    module_name =
      case args do
        [module] ->
          module

        _error ->
          raise "Invalid Argument: mix doctor.explain takes only a single module name as an argument"
      end

    if config.umbrella do
      run_umbrella(module_name, config)
    else
      run_default(module_name, config)
    end
  end

  defp run_umbrella(module_name, config) do
    acc_pid =
      case Process.whereis(@umbrella_accumulator) do
        nil -> init_umbrella_acc(module_name, config)
        pid -> pid
      end

    case CLI.generate_single_module_report(module_name, config) do
      :not_found ->
        :ok

      result ->
        Agent.update(acc_pid, fn %{result: acc_result} ->
          %{found: true, result: acc_result && result}
        end)
    end

    :ok
  end

  defp run_default(module_name, config) do
    case CLI.generate_single_module_report(module_name, config) do
      :not_found ->
        raise "Could not find module #{inspect(module_name)} in application"

      result ->
        unless result do
          System.at_exit(fn _ ->
            exit({:shutdown, 1})
          end)

          if config.raise do
            Mix.raise("Doctor validation has failed and raised an error")
          end
        end
    end

    :ok
  end

  defp init_umbrella_acc(module_name, config) do
    {:ok, pid} = Agent.start_link(fn -> %{found: false, result: true} end, name: @umbrella_accumulator)

    System.at_exit(fn _ ->
      acc = Agent.get(pid, & &1)
      Agent.stop(pid)
      report_umbrella_result(acc, module_name, config)
    end)

    pid
  end

  defp report_umbrella_result(%{found: false}, module_name, _config) do
    raise "Could not find module #{inspect(module_name)} in application"
  end

  defp report_umbrella_result(%{result: true}, _module_name, _config), do: :ok

  defp report_umbrella_result(_acc, _module_name, config) do
    if config.raise do
      Mix.raise("Doctor validation has failed and raised an error")
    end

    exit({:shutdown, 1})
  end

  defp load_config_file(%{config_file_path: file_path} = _cli_args) do
    full_path = Path.expand(file_path)

    if File.exists?(full_path) do
      Mix.shell().info("Doctor file found. Loading configuration.")

      {config, _bindings} = Code.eval_file(full_path)

      config
    else
      Mix.shell().error("Doctor file not found at path \"#{full_path}\". Using defaults.")

      %{}
    end
  end

  defp load_config_file(_) do
    # If we are performing this operation on an umbrella app then look to
    # the project root for the config file
    file =
      if Mix.Task.recursing?() do
        Path.join(["..", "..", Config.config_file()])
      else
        Config.config_file()
      end

    if File.exists?(file) do
      Mix.shell().info("Doctor file found. Loading configuration.")

      {config, _bindings} = Code.eval_file(file)

      config
    else
      Mix.shell().info("Doctor file not found. Using defaults.")

      %{}
    end
  end

  defp parse_cli_args(args) do
    {parsed_args, args, _invalid} =
      OptionParser.parse(args,
        strict: [
          raise: :boolean,
          config_file: :string
        ]
      )

    parsed_args =
      parsed_args
      |> Enum.reduce(%{}, fn
        {:raise, true}, acc -> Map.merge(acc, %{raise: true})
        {:config_file, file_path}, acc -> Map.merge(acc, %{config_file_path: file_path})
        _unexpected_arg, acc -> acc
      end)

    {parsed_args, args}
  end
end