lib/cli_app_helper.ex

defmodule CliAppHelper do
  @moduledoc """
  Helps with the creation of basic CLI applications for Polymorfiq LLC
  """

  @type entrypoint :: module()
  @type command :: module()

  @doc """
  Runs the given CLI Application, utilizing the entrypoint's metadata.

  Uses `CliAppHelper.Entrypoint.helper_text/0` to get the application's help text.
  Uses `CliAppHelper.Entrypoint.commands/0` to get the application's available commands.
  """
  @spec run(entrypoint(), [String.t()]) :: nil
  def run(entrypoint, args) do
    cmd = Enum.at(args, 0, nil)

    resp =
      case {cmd, entrypoint.commands()} do
        {_, %{^cmd => command}} -> run_command(command, args)
        {nil, _} -> {:error, entrypoint.helper_text()}
        _ -> {:error, "#{err("Unknown command", cmd)}\n\n#{entrypoint.helper_text()}"}
      end

    case resp do
      {:error, error} ->
        IO.puts(:stderr, error)
        System.stop(1)

      :ok ->
        System.stop(0)
    end
  end

  @doc """
  Runs the given command with the given args, utilizing the command's metadata to parse options.

  Uses `CliAppHelper.Command.helper_text/0` to get the command's help text.
  Uses `CliAppHelper.Command.flags/0` to get the command's available command-line flags.
  Uses `CliAppHelper.Command.run/2` to run the command with the parsed args and flags.
  """
  @spec run_command(command(), [String.t()]) :: :ok | {:error, String.t()}
  def run_command(command, args) do
    with {:ok, {args, flags}} <- parse_args(args, command) do
      command.run(args, flags)
    end
  end

  @spec parse_args([String.t()], command()) ::
          {:ok, {args :: [String.t()], flags :: keyword()}} | {:error, String.t()}
  defp parse_args(cli_args, command) do
    switches =
      command.flags()
      |> Enum.reduce([], fn {name, props}, curr_switches ->
        Keyword.put(curr_switches, name, props[:type])
      end)

    aliases =
      command.flags()
      |> Enum.flat_map(fn {name, props} ->
        Keyword.get(props, :aliases, []) |> Enum.map(fn curr_alias -> {curr_alias, name} end)
      end)

    required_flags =
      command.flags()
      |> Enum.filter(fn {_, props} ->
        Keyword.get(props, :required, false)
      end)

    cli_opts =
      OptionParser.parse(cli_args,
        strict: switches,
        aliases: aliases
      )

    case cli_opts do
      {flags, args, []} ->
        with :ok <- validate_required_switches(required_flags, flags) do
          {:ok, {args, flags}}
        end

      {_, _, errors} ->
        {:error, get_parse_error(command, errors)}
    end
  end

  @spec validate_required_switches(keyword(), keyword()) :: :ok | {:error, String.t()}
  defp validate_required_switches(required_flags, flags) do
    required_flags
    |> Enum.reduce(:ok, fn {name, _}, curr_state ->
      with :ok <- curr_state do
        if Keyword.get(flags, name, nil) != nil,
          do: :ok,
          else: {:error, err("Missing required flag", "--#{name}")}
      end
    end)
  end

  @spec get_parse_error(command(), [term()]) :: String.t()
  defp get_parse_error(command, errors) do
    error_text =
      errors
      |> Enum.map_join("\n", fn error ->
        case error do
          message when is_binary(message) ->
            message

          {flag, nil} when is_binary(flag) ->
            err("Unknown flag", flag)

          {flag, val} when is_binary(flag) ->
            err("Invalid value", "#{flag} cannot be set to '#{val}'")

          err ->
            err("Unknown error", "#{inspect(err)}")
        end
      end)

    error_text <> "\n\n" <> command.helper_text()
  end

  @spec err(String.t(), String.t()) :: String.t()
  defp err(name, val) do
    IO.ANSI.bright() <> "#{name}: " <> IO.ANSI.reset() <> "#{val}"
  end
end