Skip to main content

lib/phoenix_gen_api_tui.ex

defmodule PhoenixGenApiTui do
  @moduledoc """
  Terminal-based interactive explorer for PhoenixGenApi applications.

  `phoenix_gen_api_tui` provides a navigable two-panel TUI for discovering
  services, function configs, call flows, cluster topology, rate limits,
  and runtime health in any PhoenixGenApi project.

  ## Usage

  Add `phoenix_gen_api_tui` to your dependencies:

      def deps do
        [
          {:phoenix_gen_api_tui, "~> 0.1.0"}
        ]
      end

  ### From IEx (recommended for runtime inspection)

      iex> PhoenixGenApiTui.ui()

  ### Inspect a remote node

      iex> PhoenixGenApiTui.ui(remote_node: :"app@remote-host")

  ### Programmatic usage

      PhoenixGenApiTui.explore()
      PhoenixGenApiTui.explore(transport: :ssh)
      PhoenixGenApiTui.explore(transport: :distributed)

  ## Transports

  The same explorer can be served locally, over SSH, or over Erlang
  distribution — powered by [ExRatatui](https://hexdocs.pm/ex_ratatui)
  transports. See `explore/1` for options.

  ### Local (default)

      PhoenixGenApiTui.ui()
      # or
      PhoenixGenApiTui.explore()

  ### SSH

      PhoenixGenApiTui.explore(transport: :ssh)
      # then: ssh phoenix@localhost -p 4546   (password: tui)

  ### Erlang Distribution

      # Terminal 1 — start the listener
      iex --sname app --cookie demo -S mix
      iex> PhoenixGenApiTui.explore(transport: :distributed)

      # Terminal 2 — attach from another node
      iex --sname local --cookie demo -S mix
      iex> ExRatatui.Distributed.attach(:"app@hostname", PhoenixGenApiTui.App)
  """

  @doc """
  Launches the PhoenixGenApi TUI explorer in the local terminal.

  This is the primary entry point for interactive use from IEx.
  Reads runtime configuration from the connected PhoenixGenApi application.

  **Note:** This function blocks the calling process (IEx) until the TUI exits.
  The TUI takes over the terminal. Press `q` to quit and return to IEx.

  ## Options

    * `:remote_node` — query PhoenixGenApi data from a remote node via RPC.
    * `:timeout` — RPC timeout in milliseconds (default `10_000`).

  ## Examples

      iex> PhoenixGenApiTui.ui()
      iex> PhoenixGenApiTui.ui(remote_node: :"app@remote-host")
      iex> PhoenixGenApiTui.ui(remote_node: :"app@remote-host", timeout: 5_000)

  ## Non-blocking usage

  To run the TUI in a background process (keeping IEx responsive),
  use `run/1` instead:

      iex> {:ok, pid} = PhoenixGenApiTui.run()
      iex> # TUI is running in background, IEx is still usable
      iex> # Press `q` in the TUI terminal to stop it
  """
  @spec ui(keyword()) :: :ok | {:error, term()}
  def ui(opts \\ []) do
    explore([{:transport, :local} | opts])
  end

  @doc """
  Starts the PhoenixGenApi TUI explorer in a background process.

  Unlike `ui/1`, this function returns immediately with the PID of the
  TUI process, keeping the calling process (IEx) responsive.

  The TUI runs in a separate process and takes over the terminal.
  Press `q` in the TUI to stop it, or call `stop/1` with the PID.

  ## Options

  Same as `ui/1`.

  ## Examples

      iex> {:ok, pid} = PhoenixGenApiTui.run()
      iex> PhoenixGenApiTui.stop(pid)
  """
  @spec run(keyword()) :: {:ok, pid()} | {:error, term()}
  def run(opts \\ []) do
    Task.start_link(fn -> explore([{:transport, :local} | opts]) end)
  end

  @doc """
  Stops a running TUI process started with `run/1`.

  ## Examples

      iex> {:ok, pid} = PhoenixGenApiTui.run()
      iex> PhoenixGenApiTui.stop(pid)
      :ok
  """
  @spec stop(pid()) :: :ok
  def stop(pid) do
    Process.exit(pid, :normal)
    :ok
  end

  @doc """
  Launches the PhoenixGenApi TUI explorer with options.

  Loads all PhoenixGenApi runtime config data via `PhoenixGenApiTui.Introspection`,
  then starts an interactive terminal interface.

  ## Options

    * `:transport` — `:local` (default), `:ssh`, or `:distributed`.
    * `:force_refresh` — bypass cache and fetch fresh data on start (default `false`).
    * `:remote_node` — query PhoenixGenApi data from a remote node via RPC.
    * `:timeout` — RPC timeout in milliseconds (default `10_000`).

  ### Local options

  Any extra options are forwarded to `PhoenixGenApiTui.App`
  (e.g. `test_mode: {80, 24}`, `name: nil`).

  ### SSH options

  When `transport: :ssh`, these options configure the SSH daemon:

    * `:port` — TCP port (default `4546`).
    * `:auto_host_key` — generate a host key automatically (default `true`).
    * `:auth_methods` — e.g. `~c"password"` (default).
    * `:user_passwords` — e.g. `[{~c"phoenix", ~c"tui"}]` (default).

  Any other keyword is forwarded to `:ssh.daemon/2`. See the
  [ExRatatui SSH guide](https://hexdocs.pm/ex_ratatui/ssh_transport.html)
  for the full option reference.

  ### Distributed options

  When `transport: :distributed`, the function starts a listener that
  remote nodes attach to via `ExRatatui.Distributed.attach/3`:

      ExRatatui.Distributed.attach(:"app@hostname", PhoenixGenApiTui.App)

  See the
  [ExRatatui Distribution guide](https://hexdocs.pm/ex_ratatui/distributed_transport.html)
  for details.

  ## Examples

      # Local (default)
      PhoenixGenApiTui.explore()

      # SSH with defaults (port 4546, phoenix:tui password)
      PhoenixGenApiTui.explore(transport: :ssh)

      # SSH with custom port and credentials
      PhoenixGenApiTui.explore(
        transport: :ssh,
        port: 4000,
        user_passwords: [{~c"admin", ~c"secret"}]
      )

      # Distributed listener
      PhoenixGenApiTui.explore(transport: :distributed)

      # Query remote node
      PhoenixGenApiTui.explore(remote_node: :"app@remote-host")

      # Force fresh data load
      PhoenixGenApiTui.explore(force_refresh: true)
  """
  @spec explore(keyword()) :: :ok | {:error, term()}
  def explore(opts \\ []) do
    try do
      do_explore(opts)
    catch
      :exit, reason ->
        IO.puts(
          :stderr,
          "[PhoenixGenApiTUI] Cannot connect to required process: #{inspect(reason)}"
        )

        IO.puts(
          :stderr,
          "[PhoenixGenApiTUI] Make sure PhoenixGenApi application is started before running the TUI."
        )

        {:error, {:exit, reason}}
    end
  end

  defp do_explore(opts) do
    force_refresh = Keyword.get(opts, :force_refresh, false)

    # Build introspection options (for remote node queries)
    intro_opts = Keyword.take(opts, [:node, :timeout])

    data =
      if force_refresh do
        PhoenixGenApiTui.Introspection.load!(intro_opts)
      else
        PhoenixGenApiTui.Introspection.load(intro_opts)
      end

    log_load_status(data)

    if data.services == [] do
      IO.puts(:stderr, """

      Warning: No PhoenixGenApi services found.

      Make sure your PhoenixGenApi application is running and
      PhoenixGenApi.ConfigDb has registered services.
      Press 'r' to refresh, 'q' to quit.
      """)
    end

    state = PhoenixGenApiTui.State.new(data)

    # The process starter is read from config so the boot path can be
    # exercised in tests without a real terminal; It defaults to the live
    # `PhoenixGenApiTui.App.start_link/1`.
    starter =
      Application.get_env(:phoenix_gen_api_tui, :app_starter, &PhoenixGenApiTui.App.start_link/1)

    case starter.(build_start_opts(state, opts)) do
      {:ok, pid} ->
        ref = Process.monitor(pid)

        receive do
          {:DOWN, ^ref, :process, ^pid, _reason} -> :ok
        end

      {:error, reason} ->
        IO.puts(:stderr, "[PhoenixGenApiTUI] Failed to start TUI: #{inspect(reason)}")
        {:error, reason}
    end
  end

  defp log_load_status(%PhoenixGenApiTui.Introspection{status: status}) do
    case status do
      :partial ->
        IO.puts(:stderr, "[PhoenixGenApiTUI] Warning: Some subsystems unavailable (partial data)")

      :error ->
        IO.puts(:stderr, "[PhoenixGenApiTUI] Error: Failed to load introspection data")

      _ ->
        :ok
    end
  end

  @doc false
  @spec build_start_opts(PhoenixGenApiTui.State.t(), keyword()) :: keyword()
  def build_start_opts(state, opts) do
    transport = Keyword.get(opts, :transport, :local)

    if transport == :local do
      [{:state, state} | opts]
    else
      app_opts = [{:state, state} | Keyword.get(opts, :app_opts, [])]
      opts = Keyword.put(opts, :app_opts, app_opts)

      case transport do
        :ssh -> ssh_defaults(opts)
        :distributed -> opts
      end
    end
  end

  @doc """
  Applies default SSH options to the given keyword list.

  Defaults (all overridable via `opts`):

    * `:port` — `4546`
    * `:auto_host_key` — `true`
    * `:auth_methods` — `~c"password"`
    * `:user_passwords` — `[{~c"phoenix", ~c"tui"}]`
  """
  @spec ssh_defaults(keyword()) :: keyword()
  def ssh_defaults(opts) do
    opts
    |> Keyword.put_new(:port, 4546)
    |> Keyword.put_new(:auto_host_key, true)
    |> Keyword.put_new(:auth_methods, ~c"password")
    |> Keyword.put_new(:user_passwords, [{~c"phoenix", ~c"tui"}])
  end
end