Skip to main content

lib/pi/dev.ex

defmodule Pi.Dev do
  @moduledoc "Small dogfood/dev reload helpers callable from elixir_eval."

  alias Pi.LLM.Broker

  @default_prefixes ["Elixir.Pi"]

  @doc "Returns compact development reload status."
  def status(_opts \\ []) do
    %{
      app: Mix.Project.config()[:app],
      env: Mix.env(),
      bridge_version: Application.spec(:pi_bridge, :vsn) |> to_string(),
      loaded_modules: loaded() |> length(),
      restart_hint:
        "Use /elixir:restart to restart embedded BEAM; /elixir:refresh to refresh pi + BEAM."
    }
  end

  @doc "Compiles the current Mix project."
  def compile(opts \\ []) do
    args = if Keyword.get(opts, :force, false), do: ["--force"], else: []
    compile_args = Keyword.get(opts, :args, args)

    Mix.Task.clear()

    try do
      modules = Mix.Task.run("compile", compile_args) |> normalize_modules()
      {:ok, %{modules: modules, count: length(modules)}}
    rescue
      exception in [Mix.Error, CompileError, RuntimeError, ArgumentError] ->
        {:error,
         %{kind: :compile_error, message: Exception.format(:error, exception, __STACKTRACE__)}}
    catch
      :exit, reason -> {:error, %{kind: :compile_exit, reason: inspect(reason)}}
    end
  end

  @doc "Compiles and soft-reloads matching BEAM modules."
  def reload(opts \\ []) do
    with {:ok, %{modules: modules} = compile_report} <-
           compile(Keyword.put_new(opts, :force, true)) do
      modules = reloadable_modules(modules, opts)
      warnings = Enum.flat_map(modules, &reload_module/1)
      {:ok, Map.merge(compile_report, %{reloaded: modules, warnings: warnings})}
    end
  end

  @doc "Returns loaded modules matching optional prefixes."
  def loaded(opts \\ []) do
    prefixes = Keyword.get(opts, :prefixes, @default_prefixes)

    :code.all_loaded()
    |> Enum.map(&elem(&1, 0))
    |> Enum.filter(&prefixed?(&1, prefixes))
    |> Enum.sort()
  end

  @doc "Requests pi to perform a process or extension reload after the current eval returns."
  def request(action, opts \\ []) when action in [:beam_restart, :pi_reload, :dev_reload] do
    timeout = Keyword.get(opts, :timeout, 1_000)
    Broker.request(:dev_reload, %{action: Atom.to_string(action)}, timeout)
  end

  @doc "Requests pi to restart the embedded BEAM bridge after the current eval returns."
  def restart(opts \\ []), do: request(:beam_restart, opts)

  @doc "Requests pi to refresh the dev environment after the current eval returns."
  def refresh(opts \\ []), do: request(:dev_reload, opts)

  defp normalize_modules({:ok, modules}) when is_list(modules), do: modules
  defp normalize_modules(modules) when is_list(modules), do: modules
  defp normalize_modules(_other), do: []

  defp reloadable_modules([], opts), do: loaded(opts)

  defp reloadable_modules(modules, opts),
    do: Enum.filter(modules, &prefixed?(&1, Keyword.get(opts, :prefixes, @default_prefixes)))

  defp prefixed?(module, prefixes) do
    name = to_string(module)
    Enum.any?(prefixes, &String.starts_with?(name, &1))
  end

  defp reload_module(module) do
    if :code.soft_purge(module) do
      :code.delete(module)
      :code.ensure_loaded(module)
      []
    else
      [{:old_code_still_running, module}]
    end
  end
end