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 (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