Skip to main content

lib/mix/tasks/cairnloop.doctor.ex

defmodule Mix.Tasks.Cairnloop.Doctor do
  @shortdoc "Check that your app's Cairnloop wiring is complete and reachable"

  @moduledoc """
  Diagnoses your host application's Cairnloop wiring and prints calm, reason-forward findings.

  It inspects your router and `:cairnloop` config to catch the "compiles but isn't wired"
  problems that are easy to miss: a dashboard or `/health` route that was never mounted, a
  `/metrics` endpoint that will 501 because the optional Prometheus dependency is absent, or an
  audit log that will render empty because no auditor is configured.

  ## Usage

      # Infer your router (<App>Web.Router):
      mix cairnloop.doctor

      # Name the router explicitly (umbrellas / non-standard names):
      mix cairnloop.doctor MyAppWeb.Router

      # Treat warnings as failures (CI):
      mix cairnloop.doctor --strict

  ## Exit codes

  - `0` — no blocking issues (warnings allowed unless `--strict`).
  - `1` — at least one blocking issue (or any warning under `--strict`).
  """

  use Mix.Task

  alias Cairnloop.Doctor

  @impl Mix.Task
  def run(args) do
    {opts, rest, _} = OptionParser.parse(args, strict: [strict: :boolean])
    strict? = Keyword.get(opts, :strict, false)

    # Load config (populates Application env) without starting the supervision tree.
    Mix.Task.run("app.config")

    router = resolve_router(rest)
    findings = Doctor.checks(router)

    Mix.shell().info("\nCairnloop doctor\n")
    Enum.each(findings, &print_finding/1)

    counts = Doctor.tally(findings)
    Mix.shell().info("\n" <> summary(counts))

    cond do
      counts.error > 0 ->
        System.halt(1)

      strict? and counts.warn > 0 ->
        Mix.shell().info("\n(--strict: treating items to review as failures)")
        System.halt(1)

      true ->
        :ok
    end
  end

  defp print_finding({:ok, msg}), do: Mix.shell().info([:green, "  [ok] ", :reset, msg])
  defp print_finding({:warn, msg}), do: Mix.shell().info([:yellow, "  [check] ", :reset, msg])
  defp print_finding({:error, msg}), do: Mix.shell().info([:red, "  [blocked] ", :reset, msg])

  defp summary(%{ok: ok, warn: warn, error: error}) do
    "#{ok} ok · #{warn} to review · #{error} blocking"
  end

  defp resolve_router([name | _]) when is_binary(name) do
    mod = Module.concat([name])
    if loadable_router?(mod), do: mod, else: nil
  end

  defp resolve_router([]) do
    case Mix.Project.config()[:app] do
      nil ->
        nil

      app ->
        base = app |> to_string() |> Macro.camelize()

        [Module.concat([base <> "Web", "Router"]), Module.concat([base, "Router"])]
        |> Enum.find(&loadable_router?/1)
    end
  end

  defp loadable_router?(mod) do
    Code.ensure_loaded?(mod) and function_exported?(mod, :__routes__, 0)
  end
end