lib/omni/tools/bash/runner.ex

defmodule Omni.Tools.Bash.Runner do
  @moduledoc """
  Executes shell commands via a Port and returns captured output.

  Each invocation spawns a new shell process, captures merged stdout/stderr,
  and returns the output alongside the exit code. No state carries over
  between calls.

      Runner.run("echo hello", {"/bin/bash", ["-c"]}, dir: "/tmp")
      #=> {:ok, %{output: "hello\\n", exit_code: 0}}

      Runner.run("exit 1", {"/bin/bash", ["-c"]}, dir: "/tmp")
      #=> {:error, :nonzero, %{output: "", exit_code: 1}}

  The runner executes arbitrary shell commands with full system access. It is
  not a security boundary — OS-level sandboxing (containers, restricted users)
  is the caller's responsibility.

  ## Options

    * `:dir` (required) — working directory for the command
    * `:env` — extra environment variables as `[{String.t(), String.t()}]`,
      merged additively with the inherited environment. Default `[]`
    * `:timeout` — execution timeout in milliseconds. Default `30_000`
    * `:max_output` — output truncation limit in bytes. Tail-biased, snapped
      to line boundaries. Default `50_000`
    * `:command_prefix` — string prepended to every command. Default `nil`

  ## Return values

      {:ok, %{output: "hello\\n", exit_code: 0}}
      {:error, :nonzero, %{output: "error msg\\n", exit_code: 1}}
      {:error, :timeout, %{output: "partial..."}}

  On success, `exit_code` is always `0`. On a non-zero exit, the output
  captured up to that point is included. On timeout, partial output
  collected before the deadline is returned.
  """

  @default_timeout 30_000
  @default_max_output 50_000

  @typedoc "Result of a command execution — success, non-zero exit, or timeout."
  @type result ::
          {:ok, %{output: String.t(), exit_code: 0}}
          | {:error, :nonzero, %{output: String.t(), exit_code: pos_integer()}}
          | {:error, :timeout, %{output: String.t()}}

  @doc """
  Executes `command` in the given `shell` and returns captured output.

  The `shell` argument is a `{executable, args}` tuple — the command string
  is appended to `args` when spawning the port.
  """
  @spec run(String.t(), {String.t(), [String.t()]}, keyword()) :: result()
  def run(command, {shell_exe, shell_args}, opts \\ []) do
    dir = Keyword.fetch!(opts, :dir)
    env = opts |> Keyword.get(:env, []) |> to_port_env()
    timeout = Keyword.get(opts, :timeout, @default_timeout)
    max_output = Keyword.get(opts, :max_output, @default_max_output)
    prefix = Keyword.get(opts, :command_prefix)

    full_command = build_command(command, prefix)

    port =
      Port.open({:spawn_executable, shell_exe}, [
        :binary,
        :exit_status,
        :stderr_to_stdout,
        {:args, shell_args ++ [full_command]},
        {:cd, dir},
        {:env, env}
      ])

    deadline = System.monotonic_time(:millisecond) + timeout

    case collect(port, deadline, []) do
      {:ok, output, exit_code} ->
        output = truncate_tail(output, max_output)

        if exit_code == 0 do
          {:ok, %{output: output, exit_code: 0}}
        else
          {:error, :nonzero, %{output: output, exit_code: exit_code}}
        end

      {:timeout, acc} ->
        close_port(port)
        remaining = drain_port(port, [])
        all = Enum.reverse(acc) ++ remaining
        output = IO.iodata_to_binary(all)
        output = truncate_tail(output, max_output)
        {:error, :timeout, %{output: output}}
    end
  end

  @doc """
  Resolves the shell to use for command execution.

  Checks in order: explicit `:shell` option, `/bin/bash`, `/bin/sh`.
  Returns a `{executable, args}` tuple suitable for `Port.open/2`.

  Raises `ArgumentError` if no usable shell is found or the option is invalid.

      Runner.resolve_shell([])
      #=> {"/bin/bash", ["-c"]}

      Runner.resolve_shell(shell: {"/bin/zsh", ["-c"]})
      #=> {"/bin/zsh", ["-c"]}
  """
  @spec resolve_shell(keyword()) :: {String.t(), [String.t()]}
  def resolve_shell(opts) do
    case Keyword.get(opts, :shell) do
      {exe, args} when is_binary(exe) and is_list(args) ->
        {exe, args}

      nil ->
        cond do
          File.exists?("/bin/bash") -> {"/bin/bash", ["-c"]}
          File.exists?("/bin/sh") -> {"/bin/sh", ["-c"]}
          true -> raise ArgumentError, "no shell found: /bin/bash and /bin/sh both missing"
        end

      other ->
        raise ArgumentError,
              "invalid :shell option: expected {executable, args} tuple, got: #{inspect(other)}"
    end
  end

  # ── Port communication ───────────────────────────────────────────

  defp collect(port, deadline, acc) do
    remaining = max(deadline - System.monotonic_time(:millisecond), 0)

    receive do
      {^port, {:data, chunk}} ->
        collect(port, deadline, [chunk | acc])

      {^port, {:exit_status, code}} ->
        output = acc |> Enum.reverse() |> IO.iodata_to_binary()
        {:ok, output, code}
    after
      remaining ->
        {:timeout, acc}
    end
  end

  defp drain_port(port, acc) do
    receive do
      {^port, {:data, chunk}} -> drain_port(port, [chunk | acc])
      {^port, {:exit_status, _}} -> acc |> Enum.reverse()
    after
      0 -> acc |> Enum.reverse()
    end
  end

  defp close_port(port) do
    Port.close(port)
  catch
    :error, :badarg -> :ok
  end

  # ── Command building ─────────────────────────────────────────────

  defp build_command(command, nil), do: command
  defp build_command(command, prefix), do: prefix <> "\n" <> command

  defp to_port_env(env) do
    Enum.map(env, fn {k, v} -> {String.to_charlist(k), String.to_charlist(v)} end)
  end

  # ── Output truncation ───────────────────────────────────────────

  defp truncate_tail(output, :infinity), do: output
  defp truncate_tail(output, max) when byte_size(output) <= max, do: output

  defp truncate_tail(output, max) do
    total = byte_size(output)
    skip = total - max
    tail = binary_part(output, skip, max)

    snapped =
      case :binary.match(tail, "\n") do
        {pos, 1} -> binary_part(tail, pos + 1, byte_size(tail) - pos - 1)
        :nomatch -> tail
      end

    "...(truncated, showing last #{format_bytes(byte_size(snapped))} of #{format_bytes(total)})\n#{snapped}"
  end

  defp format_bytes(bytes) when bytes < 1_024, do: "#{bytes}B"

  defp format_bytes(bytes) when bytes < 1_048_576 do
    kb = Float.round(bytes / 1_024, 1)
    "#{kb}KB"
  end

  defp format_bytes(bytes) do
    mb = Float.round(bytes / 1_048_576, 1)
    "#{mb}MB"
  end
end