Skip to main content

lib/arrea/command/command.ex

defmodule Arrea.Command do
  @moduledoc """
  Ejecución síncrona de comandos con soporte para gestión de versiones.

  Proporciona una interfaz limpia para ejecutar comandos shell con validación,
  selección de shell, detección automática de archivos de configuración,
  integración con asdf/mise y parseo estructurado de resultados.

  Para ejecución en paralelo, usar `Arrea.Parallel` o `Arrea.Leader`.

  ## Resolución de shell

  El shell se resuelve con la siguiente prioridad (de menor a mayor):
  1. `Arrea.Config.get(:shell)` — config del proyecto consumidor o runtime
  2. Variable de entorno `$SHELL`
  3. Login shell del usuario actual (`/etc/passwd`)
  4. Opción `:shell` pasada a `execute/2` — **máxima prioridad**
  5. `"sh"` como último recurso si ninguno de los anteriores es válido

  Se aceptan tanto nombres (`"zsh"`) como rutas (`"/bin/zsh"`).
  Los nombres se resuelven a rutas mediante `System.find_executable/1`.
  Si el shell configurado no existe, cae al default del sistema (`$SHELL` o `"sh"`).

  Según el shell detectado, se fuerza la carga de su archivo de configuración
  (ej: `~/.bashrc` para bash, `~/.zshrc` para zsh, `~/.config/fish/config.fish` para fish).

  ## Timeout real

  El timeout se aplica **durante** la ejecución: si el comando no termina
  en `timeout` ms, el proceso de ejecución es cancelado. El proceso OS subyacente
  recibe SIGKILL cuando el puerto de Erlang es cerrado al morir el proceso propietario.

  ## Gestión de versiones asdf/mise

  Se pueden forzar versiones de runtimes mediante dos mecanismos:

  * **asdf** — opción `asdf_<lenguaje>`: genera `export ASDF_<LANG>_VERSION=<version>`
    antes del comando. Funciona tanto con asdf como con mise.

  * **mise** — opción `mise_<lenguaje>`: envuelve el comando con
    `mise exec <lenguaje>@<version> -- <comando>`.

  ## Ejemplos

      iex> Arrea.Command.execute("echo hello")
      {:ok, %{stdout: "hello\\n", exit_code: 0, duration_ms: 3}}

      iex> Arrea.Command.execute("mix test", asdf_elixir: "1.18.0")
      {:ok, %{stdout: "...", exit_code: 0, duration_ms: 1200}}

      iex> Arrea.Command.execute("node -v", mise_node: "20.0.0")
      {:ok, %{stdout: "v20.0.0\\n", exit_code: 0, duration_ms: 80}}

      iex> Arrea.Command.execute("sleep 60", timeout: 500)
      {:error, :timeout}
  """

  alias Arrea.Validation.Validator

  @type result :: %{
          stdout: String.t(),
          exit_code: non_neg_integer(),
          duration_ms: non_neg_integer()
        }

  @default_timeout 30_000

  @shell_configs %{
    "bash" => "~/.bashrc",
    "zsh" => "~/.zshrc",
    "sh" => "~/.profile",
    "fish" => "~/.config/fish/config.fish",
    "dash" => "/etc/profile",
    "ksh" => "~/.kshrc"
  }

  @doc """
  Resuelve el nombre de un shell a su ruta absoluta.

  Si ya es una ruta (contiene `/`), se devuelve tal cual si existe.
  Si es solo un nombre, se busca en PATH via `System.find_executable/1`.
  Retorna `nil` si no se encuentra el ejecutable.
  """
  @spec resolve_shell_path(String.t()) :: String.t() | nil
  def resolve_shell_path(shell) do
    if String.contains?(shell, "/") do
      if File.exists?(shell), do: shell, else: nil
    else
      System.find_executable(shell)
    end
  end

  @doc """
  Resuelve el shell a usar según la prioridad (de menor a mayor):
  1. `Arrea.Config.get(:shell)` (config del proyecto o `Config.set/2`)
  2. Variable de entorno `$SHELL`
  3. Login shell del usuario en `/etc/passwd`
  4. Opción `:shell` pasada en opts — máxima prioridad
  5. `"sh"` como fallback si ninguno es válido

  Si el shell resuelto es un nombre (ej: `"zsh"`), lo busca en PATH.
  Si no se encuentra, cae al default del sistema.
  """
  @spec resolve_shell(keyword()) :: String.t()
  def resolve_shell(opts \\ []) do
    candidate =
      Keyword.get(opts, :shell) ||
        Arrea.Config.get(:shell) ||
        default_user_login_shell() ||
        "sh"

    case resolve_shell_path(candidate) do
      nil -> System.get_env("SHELL") || "sh"
      path -> path
    end
  end

  @doc """
  Resuelve la ruta al archivo de configuración del shell.

  Retorna la ruta expandida al archivo de config (ej: `~/.zshrc` para zsh)
  o `nil` si el shell no tiene un archivo de config conocido.
  """
  @spec resolve_shell_config(String.t()) :: String.t() | nil
  def resolve_shell_config(shell) do
    shell_name = Path.basename(shell)

    case Map.get(@shell_configs, shell_name) do
      nil -> nil
      path -> expand_path(path)
    end
  end

  @doc """
  Ejecuta una cadena de comando de forma síncrona con configuración opcional.

  El comando se valida antes de la ejecución. Comandos inválidos o peligrosos
  retornan `{:error, razon}` sin ejecutar nada.

  El timeout es **real**: si el comando no termina dentro del límite,
  el proceso de ejecución es cancelado activamente (no post-hoc).

  ## Opciones

  - `:timeout` — Tiempo máximo de ejecución en ms (default: `30_000`)
  - `:cd` — Directorio de trabajo (default: directorio actual)
  - `:shell` — Shell a usar — tiene prioridad máxima sobre config y env
  - `:shell_config` — Ruta al archivo de configuración del shell a cargar (opcional)
  - `:env` — Variables de entorno adicionales como mapa (opcional)
  - `:quiet` — Si es true, suprime la captura de stderr (default: false)
  - `:asdf_elixir` — Forzar versión de Elixir via asdf/mise
  - `asdf_<lang>` — Forzar versión de cualquier lenguaje via asdf/mise
  - `mise_<lang>` — Forzar versión via `mise exec`

  ## Retorna

  - `{:ok, result}` — Mapa con `:stdout`, `:exit_code`, `:duration_ms`
  - `{:error, :timeout}` — El comando fue cancelado por exceder el timeout
  - `{:error, reason}` — Error de validación o ejecución
  """
  @spec execute(String.t(), keyword()) :: {:ok, result()} | {:error, term()}
  def execute(cmd, opts \\ []) do
    with {:ok, validated_cmd} <- Validator.validate_command(cmd) do
      shell = resolve_shell(opts)
      opts = enrich_opts(opts, shell)
      timeout = Keyword.get(opts, :timeout, @default_timeout)
      cd = Keyword.get(opts, :cd, ".")
      env = build_env(opts)
      full_cmd = build_full_command(validated_cmd, opts)

      do_execute(shell, full_cmd, cd, env, timeout)
    end
  end

  @doc """
  Ejecuta un comando con una versión de lenguaje gestionada por ASDF.

  Envoltorio conveniente para `execute/2` que antepone la activación del shim de asdf.

  ## Ejemplos

      iex> Command.execute_with_asdf("mix test", :elixir, "1.18.0")
      {:ok, %{stdout: "...", exit_code: 0, duration_ms: 1200}}
  """
  @spec execute_with_asdf(String.t(), atom(), String.t(), keyword()) ::
          {:ok, result()} | {:error, term()}
  def execute_with_asdf(cmd, language, version, opts \\ []) do
    lang_key = String.to_atom("asdf_#{language}")
    execute(cmd, Keyword.put(opts, lang_key, version))
  end

  @doc """
  Parsea un mapa de resultado crudo a una forma estructurada.

  Detecta patrones comunes de error y retorna resultados etiquetados.
  """
  @spec parse_result(result()) :: {:ok, result()} | {:error, {:exit_code, non_neg_integer()}}
  def parse_result(%{exit_code: 0} = result), do: {:ok, result}
  def parse_result(%{exit_code: code} = _result), do: {:error, {:exit_code, code}}

  # ── Ejecución interna ─────────────────────────────────────────────────────

  # Lanza el comando en un Task y aplica timeout real con Task.yield/2.
  # Si el timeout expira, Task.shutdown/2 con :brutal_kill mata el proceso Elixir.
  # Al morir el proceso propietario del puerto, Erlang cierra el puerto y el
  # proceso OS subyacente recibe SIGKILL.
  @spec do_execute(String.t(), String.t(), String.t(), [{String.t(), String.t()}], pos_integer()) ::
          {:ok, result()} | {:error, term()}
  defp do_execute(shell, cmd, cd, env, timeout) do
    started_at = System.monotonic_time(:millisecond)
    task = Task.async(fn -> exec_with_shell_safe(shell, cmd, cd, env, started_at) end)

    case Task.yield(task, timeout) || Task.shutdown(task, :brutal_kill) do
      {:ok, result} -> result
      nil -> {:error, :timeout}
      {:exit, reason} -> {:error, {:exit, reason}}
    end
  end

  # Intenta ejecutar con el shell configurado; si no existe (:enoent),
  # cae al shell del sistema como último recurso.
  @spec exec_with_shell_safe(String.t(), String.t(), String.t(), list(), integer()) ::
          {:ok, result()} | {:error, term()}
  defp exec_with_shell_safe(shell, cmd, cd, env, started_at) do
    case exec_with_shell(shell, cmd, cd, env, started_at) do
      {:error, :enoent} ->
        fallback = System.get_env("SHELL") || "sh"
        exec_with_shell(fallback, cmd, cd, env, started_at)

      other ->
        other
    end
  end

  @spec exec_with_shell(
          String.t(),
          String.t(),
          String.t(),
          [{String.t(), String.t()}],
          integer()
        ) :: {:ok, result()} | {:error, term()}
  defp exec_with_shell(shell, cmd, cd, env, started_at) do
    system_opts =
      [stderr_to_stdout: true, cd: cd]
      |> then(fn opts -> if env == [], do: opts, else: Keyword.put(opts, :env, env) end)

    case System.cmd(shell, ["-c", cmd], system_opts) do
      {output, exit_code} ->
        duration = System.monotonic_time(:millisecond) - started_at

        {:ok,
         %{
           stdout: output,
           exit_code: exit_code,
           duration_ms: duration
         }}
    end
  rescue
    error ->
      case error do
        %ErlangError{original: :enoent} -> {:error, :enoent}
        _ -> {:error, {:execution_error, error}}
      end
  end

  # ── Construcción del comando ──────────────────────────────────────────────

  @doc false
  @spec build_full_command(String.t(), keyword()) :: String.t()
  def build_full_command(cmd, opts) do
    asdf_prefix = build_asdf_prefix(opts)
    shell_source = build_shell_source(opts)

    inner_parts =
      [shell_source, asdf_prefix, cmd]
      |> Enum.reject(&(&1 == ""))

    inner_cmd = Enum.join(inner_parts, "; ")

    case build_mise_args(opts) do
      [] -> inner_cmd
      args -> "mise exec #{Enum.join(args, " ")} -- #{inner_cmd}"
    end
  end

  @doc """
  Extrae los argumentos `mise_<lang>` de las opciones y los formatea
  como `"lang@version"` para el comando `mise exec`.
  """
  @spec build_mise_args(keyword()) :: [String.t()]
  def build_mise_args(opts) do
    opts
    |> Enum.filter(fn {key, _} -> key |> to_string() |> String.starts_with?("mise_") end)
    |> Enum.map(fn {key, version} ->
      lang = key |> to_string() |> String.replace_prefix("mise_", "")
      "#{lang}@#{version}"
    end)
  end

  @doc false
  @spec build_asdf_prefix(keyword()) :: String.t()
  def build_asdf_prefix(opts) do
    if Keyword.get(opts, :asdf_local, false) do
      "asdf local"
    else
      opts
      |> Enum.filter(fn {key, _} -> key |> to_string() |> String.starts_with?("asdf_") end)
      |> Enum.map(fn {key, version} ->
        lang = key |> to_string() |> String.replace_prefix("asdf_", "")
        "export ASDF_#{String.upcase(lang)}_VERSION=#{version}"
      end)
      |> Enum.map_join("; ", & &1)
    end
  end

  @spec build_shell_source(keyword()) :: String.t()
  defp build_shell_source(opts) do
    config =
      case Keyword.get(opts, :shell_config) do
        nil ->
          if shell = Keyword.get(opts, :shell), do: resolve_shell_config(shell)

        path ->
          path
      end

    case config do
      nil -> ""
      path -> "source #{path}"
    end
  end

  defp enrich_opts(opts, shell) do
    opts
    |> Keyword.put(:shell, shell)
    |> Keyword.put(:shell_config, resolve_shell_config(shell))
  end

  @spec build_env(keyword()) :: [{String.t(), String.t()}]
  defp build_env(opts) do
    opts
    |> Keyword.get(:env, %{})
    |> Enum.map(fn {k, v} -> {to_string(k), to_string(v)} end)
  end

  defp expand_path("~" <> rest) do
    Path.join(System.get_env("HOME", "/root"), rest)
  end

  defp expand_path(path), do: path

  defp default_user_login_shell do
    case System.get_env("SHELL") do
      nil -> get_shell_from_passwd()
      val -> val
    end
  end

  defp get_shell_from_passwd do
    user = System.get_env("USER") || "root"

    case File.read("/etc/passwd") do
      {:ok, content} ->
        line = content |> String.split("\n") |> Enum.find(&String.starts_with?(&1, "#{user}:"))
        parse_passwd_shell(line)

      _ ->
        "/bin/sh"
    end
  end

  defp parse_passwd_shell(line) when is_binary(line) do
    shell_path = line |> String.split(":") |> List.last()
    if File.exists?(shell_path), do: shell_path, else: "/bin/sh"
  end

  defp parse_passwd_shell(_), do: "/bin/sh"
end