lib/mix/tasks/uniform.eject.ex

defmodule Mix.Tasks.Uniform.Eject do
  @moduledoc """
  Ejects an [Ejectable App](how-it-works.html#ejectable-apps) to a
  standalone code repository.

  ## Usage

  ```bash
  $ mix uniform.eject trillo
  $ mix uniform.eject tweeter --confirm
  $ mix uniform.eject hatmail --confirm --destination ../../new/dir
  ```

  ## Command line options

    * `--destination` – output directory for the ejected code. Read the
      [Configuration section of the Getting Started
      guide](getting-started.html#configuration) to understand how the
      destination is chosen if this option is omitted.
    * `--confirm` – affirm "yes" to the prompt asking you whether you want to eject.

  ## Which files get ejected

  When you run `mix uniform.eject my_app`, these four rules determine which files
  are copied.

  1. [A few files](Uniform.Blueprint.html#module-files-that-are-always-ejected)
     common to Elixir projects are copied.
  2. All files in the Blueprint's
     [base_files](Uniform.Blueprint.html#base_files/1) section are copied.
  3. All files in `lib/my_app` and `test/my_app` are copied.
  4. For every [Lib Dependency](dependencies.html#lib-dependencies) of `my_app`:
      - All files in `lib/my_lib_dep` and `test/my_lib_dep` are copied.
      - All [associated
        files](Uniform.Blueprint.html#lib/2-3-including-files-outside-of-lib)
        tied to the Lib Dependency are copied.

  > If you need to apply exceptions to these rules, you can use these tools.
  >
  >   - Files in `(lib|test)/my_app` (rule 3) are subject to the
  >     [lib_app_except](Uniform.Blueprint.html#c:app_lib_except/1) callback.
  >   - Lib Dependency files (rule 4) are subject to
  >     [only](Uniform.Blueprint.html#only/1) and
  >     [except](Uniform.Blueprint.html#except/1) instructions.

  ## Ejection step by step

  When you eject an app by running `mix uniform.eject my_app`, the following happens:

  1. The destination directory is created if it doesn't exist.
  2. All files and directories in the destination are deleted, except for
     `.git`, `_build`, `deps`, and anything in the Blueprint's
     [`@preserve`](Uniform.Blueprint.html#module-preserving-files).
  3. All files required by the app are copied to the destination. (See [Which
     files get ejected](#module-which-files-get-ejected).)
  4. For each file copied, [a set of
     transformations](./code-transformations.html) are applied to the file
     contents – except for files specified with `cp` and `cp_r`.
  5. `mix deps.clean --unlock --unused` is ran on the ejected codebase.
  6. `mix format` is ran on the ejected codebase.

  In step 2, `.git` is kept to preserve the Git repository and history. `deps`
  is kept to avoid having to download all dependencies after ejection. `_build`
  is kept to avoid having to recompile the entire project after ejection.

  `mix deps.clean --unlock --unused` removes unused Mix Dependencies from
  `mix.lock` in the ejected codebase. This includes [deps removed from
  `mix.exs`](dependencies.html#mix-dependencies) as well as transitive
  dependencies of those deps.

  `mix format` tidies up things like chains of newlines that may appear from
  applying [Eject Fences](code-transformations.html#eject-fences). It also
  prevents you from having to think about code formatting in
  [modify](Uniform.Blueprint.html#modify/2).

  """

  use Mix.Task
  require Logger

  @doc false
  def run(args) do
    sample_syntax = "   Syntax is:   mix uniform.eject app_name [--destination path] [--confirm]"

    args
    |> OptionParser.parse!(strict: [destination: :string, confirm: :boolean])
    |> case do
      {opts, [app_name]} ->
        eject_app(app_name, opts)

      {_opts, []} ->
        IO.puts("")
        IO.puts(IO.ANSI.red() <> "  No app name provided." <> sample_syntax)
        IO.puts(IO.ANSI.yellow())
        IO.puts("  Available apps:")

        Uniform.ejectable_app_names() |> Enum.each(&IO.puts("      #{&1}"))

      _unknown_options ->
        IO.puts("")

        IO.puts(IO.ANSI.red() <> "  Too many options provided." <> sample_syntax)
    end
  end

  defp eject_app(app_name, opts) do
    app = Uniform.prepare(%{name: app_name, opts: opts})

    IO.puts("")
    IO.puts("🗺  Ejecting [#{app.name.camel}] to [#{app.destination}]")
    IO.puts("")
    IO.puts("🤖 Mix Dependencies")

    app.internal.deps.included.mix
    |> Enum.chunk_every(6)
    |> Enum.each(fn mix_deps ->
      IO.puts("   " <> Enum.join(mix_deps, " "))
    end)

    if Enum.empty?(app.internal.deps.included.mix) do
      IO.puts("   " <> "[NONE]")
    end

    IO.puts("")
    IO.puts("🤓 Lib Dependencies")

    app.internal.deps.included.lib
    |> Enum.chunk_every(6)
    |> Enum.each(fn lib_deps ->
      IO.puts("   " <> Enum.join(lib_deps, " "))
    end)

    if Enum.empty?(app.internal.deps.included.lib) do
      IO.puts("   " <> "[NONE]")
    end

    IO.puts("")

    if Enum.any?(app.extra) do
      IO.puts("📰 Extra:")

      app.extra
      |> inspect()
      |> Code.format_string!()
      |> to_string()
      |> String.replace(~r/^/m, "   ")
      |> IO.puts()
    end

    unless Keyword.get(opts, :confirm) == true do
      IO.puts("")

      IO.puts(
        IO.ANSI.yellow() <>
          "    ⚠️  Warning: contents of the destination directory will be deleted" <>
          IO.ANSI.reset()
      )
    end

    eject =
      if Keyword.get(opts, :confirm) == true do
        true
      else
        Mix.shell().yes?("\n\nClear destination directory and eject?")
      end

    if eject do
      IO.puts("")
      eject(app)
      IO.puts("✅ #{app.name.camel} ejected to #{app.destination}")
    end
  rescue
    e in Uniform.NotEjectableError ->
      message = Uniform.NotEjectableError.message(e)
      IO.puts(IO.ANSI.yellow() <> message <> IO.ANSI.reset())
  end

  # Ejects an app. Deletes the files in the destination and copies a fresh set
  # of files for the app.
  defp eject(app) do
    clear_destination(app)
    Logger.info("📂 #{app.destination}")
    File.mkdir_p!(app.destination)

    for file <- Uniform.File.all_for_app(app) do
      Logger.info("💾 [#{file.type}] #{file.destination}")
      Uniform.File.eject!(file, app)
    end

    # remove mix deps that are not needed for this project from mix.lock
    System.cmd("mix", ["deps.clean", "--unlock", "--unused"], cd: app.destination)
    System.cmd("mix", ["format"], cd: app.destination)
  end

  # Clear the destination folder where the app will be ejected.
  @doc false
  def clear_destination(app) do
    if File.exists?(app.destination) do
      {:module, _} = Code.ensure_loaded(app.internal.config.blueprint)

      preserve = app.internal.config.blueprint.__preserve__()
      preserve = [".git", "deps", "_build" | preserve]

      app.destination
      |> File.ls!()
      |> Enum.reject(&(&1 in preserve))
      |> Enum.each(fn file_or_folder ->
        path = Path.join(app.destination, file_or_folder)
        Logger.info("💥 #{path}")
        File.rm_rf(path)
      end)
    end
  end
end