lib/combo_new.ex

defmodule ComboNew do
  @moduledoc """
  An opinionated project generator for Phoenix.
  """

  @app Mix.Project.config()[:app]
  @version Mix.Project.config()[:version]
  @elixir_requirement Mix.Project.config()[:elixir]

  @support_options [
    app: :string,
    module: :string
  ]

  @doc """
  Runs a generator with Mix task.
  """
  def run(_mix_task_module, _generator, [option] = _agrv)
      when option in ~w(-v --version) do
    Mix.shell().info("#{@app} v#{@version}")
  end

  def run(mix_task_module, generator, argv) do
    elixir_version_check!()

    case OptionParser.parse!(argv, strict: @support_options) do
      {opts, [base_path | _]} ->
        generate(generator, base_path, opts)

      _ ->
        task_name = to_mix_task_name(mix_task_module)
        Mix.Tasks.Help.run([task_name])
    end
  end

  defp elixir_version_check!() do
    unless Version.match?(System.version(), @elixir_requirement) do
      Mix.raise(
        "#{@app} v#{@version} requires Elixir #{@elixir_requirement}. " <>
          "But, you have Elixir #{System.version()}. \n" <> "Please update accordingly."
      )
    end
  end

  defp to_mix_task_name(mix_task_module) do
    "Mix.Tasks." <> module = inspect(mix_task_module)

    module
    |> String.split(".")
    |> Enum.map(&Macro.underscore/1)
    |> Enum.join(".")
  end

  @doc """
  Generates a project.
  """
  def generate(generator, base_path, opts) do
    base_path = Path.expand(base_path)

    app = String.to_atom(opts[:app] || Path.basename(base_path))
    module = Module.concat([opts[:module] || camelize(app)])
    env_prefix = upcase(app)

    check_app_name!(app, !!opts[:app])
    check_module_name!(module)
    check_directory_existence!(base_path)

    :ok = generator.generate(base_path, app: app, module: module, env_prefix: env_prefix)

    print_next_steps(base_path)
  end

  defp check_app_name!(name, from_app_option) do
    name = to_string(name)

    unless name =~ Regex.recompile!(~r/^[a-z][\w_]*$/) do
      extra =
        if !from_app_option do
          ". The application name is inferred from the path, if you'd like to " <>
            "explicitly name the application then use the `--app APP` option."
        else
          ""
        end

      Mix.raise(
        "Application name must start with a letter and have only lowercase " <>
          "letters, numbers and underscore, got: #{inspect(name)}" <> extra
      )
    end
  end

  defp check_module_name!(name) do
    unless inspect(name) =~ Regex.recompile!(~r/^[A-Z]\w*(\.[A-Z]\w*)*$/) do
      Mix.raise(
        "Module name must be a valid Elixir alias (for example: Foo.Bar), got: #{inspect(name)}"
      )
    end
  end

  defp check_directory_existence!(path) do
    if File.dir?(path) and
         not Mix.shell().yes?(
           "The directory #{path} already exists. Are you sure you want to continue?"
         ) do
      Mix.raise("Please select another directory for installation.")
    end
  end

  defp camelize(term) when is_atom(term) do
    term
    |> Atom.to_string()
    |> Macro.camelize()
  end

  defp upcase(term) when is_atom(term) do
    term
    |> Atom.to_string()
    |> String.upcase()
  end

  defp print_next_steps(base_path) do
    Mix.shell().info("""

    We are almost there! The following steps are missing:

        $ cd #{relative_path(base_path)}
        $ mix setup

    Then, start the app with:

        $ mix phx.server

    Or, run the app inside IEx (Interactive Elixir) as:

        $ iex -S mix phx.server

    """)
  end

  defp relative_path(path) do
    case Path.relative_to_cwd(path) do
      ^path -> Path.basename(path)
      rel -> rel
    end
  end
end