Skip to main content

lib/mix/tasks/oban_powertools.doctor.ex

defmodule Mix.Tasks.ObanPowertools.Doctor do
  use Mix.Task

  @shortdoc "Diagnose Oban DB/config health (read-only)"

  @moduledoc """
  Runs read-only Oban health checks against `pg_catalog` and `information_schema`,
  then exits with an honest exit code for CI pipelines.

  ## Exit Codes

  | Code | Meaning |
  |------|---------|
  | 0    | All checks passed — DB and configuration are healthy |
  | 1    | Warnings only (e.g. uniqueness-timeout risk without `--strict`) |
  | 2    | One or more errors (INVALID index, missing index, migration drift, cannot-run) |

  ## Flags

      --repo MyApp.Repo     Ecto repo module to connect with. Falls back to
                            `config :oban_powertools, repo: MyApp.Repo`.
      --prefix public       Oban schema prefix. Falls back to the host Oban
                            config in application env, then "public". See note below.
      --oban-name Oban      Which Oban instance name to look up when reading the
                            prefix from application env (default: "Oban").
      --format human|json   Output format. "human" (default) renders a sectioned
                            report with ANSI color that auto-degrades in CI/non-TTY.
                            "json" emits a machine-readable payload with a
                            `schema_version: 1` stability contract.
      --strict              Promote the warning tier (uniqueness-timeout risk) to
                            errors. Scope: uniqueness_timeout_risk check only.

  ## Severity Table

  | Finding                              | Default   | Under --strict |
  |--------------------------------------|-----------|----------------|
  | INVALID index                        | error (2) | error (2)      |
  | Missing expected Oban index          | error (2) | error (2)      |
  | Migration drift (Oban/Powertools)    | error (2) | error (2)      |
  | Uniqueness-timeout risk              | warning(1)| error (2)      |
  | Cannot-run (no repo / DB unreachable)| error (2) | error (2)      |

  ## Prefix Resolution

  Prefix auto-detection reads the host Oban configuration from the loaded
  application environment without starting Oban. Because Oban is typically
  configured under the host OTP app key (e.g. `config :my_app, Oban, ...`),
  auto-detection may fall back to `"public"` when the host app hasn't started.
  **Use `--prefix` for reliable production results.**

  ## Boot Strategy

  This task starts only the Ecto repo via `Ecto.Migrator.with_repo/2`. It does
  **not** start Oban or any queue/worker supervision tree. It is safe to run
  around deploys without triggering job processing.
  """

  @switches [
    repo: :string,
    prefix: :string,
    oban_name: :string,
    format: :string,
    strict: :boolean
  ]

  @impl Mix.Task
  def run(argv) do
    # Load the host application's configuration and code paths so the repo module
    # and Oban app env are available, WITHOUT starting any application (D-09/D-10).
    # "app.config" loads config + code paths but never *starts* apps, so Oban's
    # supervision tree stays down. Called inline (not via a module-attribute task
    # requirement) and never via "app.start", which would start Oban (Pitfall 1).
    Mix.Task.run("app.config")

    {opts, _args, _invalid} = OptionParser.parse(argv, strict: @switches)

    repo_module = resolve_repo(opts)

    result =
      Ecto.Migrator.with_repo(
        repo_module,
        fn repo ->
          prefix = resolve_prefix(opts)
          strict = Keyword.get(opts, :strict, false)

          findings = ObanPowertools.Doctor.run(repo, prefix: prefix, strict: strict)
          exit_code = ObanPowertools.Doctor.exit_code_for(findings)

          # Map the --format string flag to a known atom explicitly. Avoids
          # String.to_existing_atom (fragile: the target atom may not be
          # registered yet at runtime) and never creates atoms from arbitrary
          # CLI input (T-48-05). Unknown values fall back to the human report.
          format =
            case Keyword.get(opts, :format, "human") do
              "json" -> :json
              _ -> :human
            end

          ObanPowertools.Doctor.Formatter.print(
            findings,
            format: format,
            prefix: prefix,
            exit_code: exit_code,
            oban_version_installed: Oban.Migrations.Postgres.current_version(),
            oban_version_db: ObanPowertools.Doctor.Checks.oban_db_version(repo, prefix)
          )

          exit_code
        end,
        pool_size: 2
      )

    case result do
      {:ok, exit_code, _apps} ->
        System.halt(exit_code)

      {:error, reason} ->
        Mix.shell().error(
          "Oban Powertools Doctor: cannot start repo — #{inspect(reason)}\n" <>
            "Configure your repo with: config :oban_powertools, repo: MyApp.Repo\n" <>
            "Or pass the flag: mix oban_powertools.doctor --repo MyApp.Repo"
        )

        System.halt(2)
    end
  end

  # ---------------------------------------------------------------------------
  # Repo resolution (D-07 / D-08 / T-48-05)
  # ---------------------------------------------------------------------------

  defp resolve_repo(opts) do
    case Keyword.get(opts, :repo) do
      nil ->
        # Fallback to the project's ObanPowertools.RuntimeConfig contract.
        # repo!/0 raises with a "config :oban_powertools, repo: MyApp.Repo" message if absent.
        ObanPowertools.RuntimeConfig.repo!()

      repo_string ->
        # Safe atom resolution: use Module.safe_concat which normalises the string
        # into a proper module atom without invoking String.to_atom on raw CLI input
        # (T-48-05 mitigation — never String.to_atom/1 on user-supplied input).
        Module.safe_concat([repo_string])
    end
  end

  # ---------------------------------------------------------------------------
  # Prefix resolution (D-07 / D-10)
  # ---------------------------------------------------------------------------

  defp resolve_prefix(opts) do
    cond do
      prefix = Keyword.get(opts, :prefix) ->
        prefix

      true ->
        # Attempt to read from the host's Oban application env without starting Oban.
        # Note: Oban is typically configured under the host app key
        # (e.g. `config :my_app, Oban, prefix: "..."`) so auto-detection is
        # best-effort. Use --prefix for reliable production results (RESEARCH Pitfall 6).
        oban_name = Keyword.get(opts, :oban_name, "Oban")

        oban_key =
          try do
            String.to_existing_atom(oban_name)
          rescue
            ArgumentError -> nil
          end

        case oban_key && Application.get_env(:oban, oban_key) do
          config when is_list(config) ->
            Keyword.get(config, :prefix, "public")

          _ ->
            "public"
        end
    end
  end
end