lib/bylaw/credo/check/phoenix/no_repo_in_controller.ex

defmodule Bylaw.Credo.Check.Phoenix.NoRepoInController do
  @moduledoc """
  Disallows calling `Repo` directly from controller modules.

  ## Examples

  Controllers should delegate data access to context modules (e.g. `Conversations`,
  `Runs`) rather than calling `Repo` directly. This enforces a boundary between the
  web layer and the persistence layer.
  Avoid:

        defmodule MyAppWeb.ThingController do
          def show(conn, %{"id" => id}) do
            thing = Repo.get!(Thing, id)
            render(conn, :show, thing: thing)
          end
        end

  Prefer:

        defmodule MyAppWeb.ThingController do
          def show(conn, %{"id" => id}) do
            thing = Things.get_thing!(id)
            render(conn, :show, thing: thing)
          end
        end

  ## Notes

  This check uses static AST analysis, so it favors clear source-level patterns over runtime behavior.

  ## Options

  This check has no check-specific options. Configure it with an empty option list.

  ## Usage

  Add this check to Credo's `checks:` list in `.credo.exs`:

  ```elixir
  %{
    configs: [
      %{
        name: "default",
        checks: [
          {Bylaw.Credo.Check.Phoenix.NoRepoInController, []}
        ]
      }
    ]
  }
  ```
  """

  use Credo.Check,
    base_priority: :higher,
    category: :warning,
    explanations: [check: @moduledoc]

  @doc false
  @impl Credo.Check
  def run(%Credo.SourceFile{} = source_file, params \\ []) do
    if controller_file?(source_file.filename) do
      issue_meta = IssueMeta.for(source_file, params)
      Credo.Code.prewalk(source_file, &traverse(&1, &2, issue_meta))
    else
      []
    end
  end

  defp controller_file?(filename) do
    String.contains?(filename, "_controller.ex") and
      not String.ends_with?(filename, "_test.exs")
  end

  defp traverse(
         {{:., _dot_meta, [{:__aliases__, _aliases_meta, aliases}, _func]}, meta, _args} = ast,
         issues,
         issue_meta
       ) do
    if List.last(aliases) == :Repo do
      {ast, [issue_for(issue_meta, meta[:line] || 0) | issues]}
    else
      {ast, issues}
    end
  end

  defp traverse(ast, issues, _issue_meta), do: {ast, issues}

  defp issue_for(issue_meta, line_no) do
    format_issue(
      issue_meta,
      message: "Do not call `Repo` from a controller. Use a context module instead.",
      trigger: "Repo",
      line_no: line_no
    )
  end
end