lib/mix/tasks/mod.relocate.ex

defmodule Mix.Tasks.Mod.Relocate do
  alias Modkit.Mount
  alias Modkit.Mod
  alias Modkit.CLI
  use Mix.Task

  @shortdoc "Relocate all modules in the current application"

  @command [
    module: __MODULE__,
    options: [
      interactive: [
        type: :boolean,
        short: :i,
        doc:
          "This flag will make the command prompt for confirmation whenever a file can be relocated. Takes precedences over `--force`."
      ],
      force: [
        type: :boolean,
        short: :f,
        doc: "This flag will make the command actually relocate the files."
      ]
    ]
  ]

  @usage CLI.format_usage(@command, format: :moduledoc)

  @moduledoc """
  Relocates modules in application according to the `:mount` option in `:modkit`
  configuration defined in the project file (`mix.exs`).

  If not defined, default mount points are defined as follows:

      [
        {App, "lib/app"}
        {Mix.Tasks, "lib/mix/tasks", flavor: :mix_task}
      ]

  #{@usage}
  """

  @impl Mix.Task
  def run(argv) do
    command =
      CLI.parse_or_halt!(
        argv,
        @command
      )

    {__MODULE__, _, cpath} = :code.get_object_code(__MODULE__)
    dir = Path.dirname(cpath)
    Mix.Task.run("app.config")
    Code.prepend_path(dir)

    %{mount: mount, otp_app: otp_app} = Modkit.load_current_project()
    %{options: options} = command

    otp_app
    |> Mod.list_by_file()
    |> Stream.filter(&filter_dep/1)
    |> Stream.map(&build_move(&1, mount))
    |> Enum.filter(&(&1 != :skip))
    |> case do
      [] -> CLI.success("Nothing to do")
      moves -> Enum.each(moves, &apply_move(&1, options))
    end
  end

  defp filter_dep({file, _}) do
    # some modules are found defined in deps. This is the case for modules
    # generated by libraries such as NimbleCsv parsers.
    not String.contains?(file, "deps/")
  end

  defp build_move({file, [module]}, mount) do
    case Mount.preferred_path(mount, module) do
      {:ok, ^file} ->
        :skip

      {:ok, pref} ->
        {:move, module, file, pref}

      :ignore ->
        :skip

      {:error, :not_mounted} ->
        warn_no_path(module)
        :skip
    end
  end

  defp build_move({file, modules}, mount) do
    case Mod.local_root(modules) do
      nil ->
        warn_many(file, modules)
        :skip

      root ->
        build_move({file, [root]}, mount)
    end
  end

  defp apply_move({:move, module, cur_path, pref_path}, options) do
    %{interactive: itr?, force: force?} = options
    print_move(module, cur_path, pref_path)

    cond do
      File.exists?(pref_path) ->
        {:exists, module, pref_path}

      itr? ->
        if(ask_move(module, cur_path, pref_path)) do
          move(module, cur_path, pref_path)
        else
          :cancel
        end

      force? ->
        move(module, cur_path, pref_path)

      :other ->
        # just printed
        :ignore
    end
    |> print_result()
  end

  defp move(module, cur_path, pref_path) do
    with :ok <- File.mkdir_p(Path.dirname(pref_path)),
         :ok <- File.rename(cur_path, pref_path) do
      :moved
    else
      {:error, reason} -> {:error, {:move_error, module, pref_path, reason}}
    end
  end

  defp print_move(module, cur_path, pref_path) do
    {bad_rest, good_rest, common} = deviate_path(cur_path, pref_path)

    CLI.writeln([
      [inspect(module)],
      [
        "\n  move ",
        common,
        CLI.color(:red, bad_rest),
        "\n  to   ",
        common,
        CLI.color(:green, good_rest)
      ]
    ])
  end

  defp deviate_path(from, to) do
    deviate_path(Path.split(from), Path.split(to), [])
  end

  defp deviate_path([same | from], [same | to], acc) do
    deviate_path(from, to, [same | acc])
  end

  defp deviate_path(from_rest, to_rest, acc) do
    common_path =
      case acc do
        [] -> ""
        list -> [Path.join(:lists.reverse(list)), ?/]
      end

    {Path.join(from_rest), Path.join(to_rest), common_path}
  end

  defp warn_many(file, _list_of_mods) do
    CLI.warn("Several modules defined in #{file} without a common local root module")
  end

  defp ask_move(_module, _cur_path, _pref_path) do
    Mix.Shell.IO.yes?("  confirm?")
  end

  defp print_result(:moved) do
    CLI.success("✔ ok")
    :moved
  end

  defp print_result({:exists, _, path}) do
    CLI.error("✘ file #{path} already exists")
  end

  defp print_result(:cancel) do
    CLI.writeln("✘ cancelled")
  end

  defp print_result(:ignore) do
    []
  end

  defp print_result({:error, {:move_error, module, path, reason}}) do
    CLI.error(
      "✘ could not write module #{inspect(module)} to #{path}, got error: #{inspect(reason)}"
    )
  end

  defp warn_no_path(module) do
    CLI.warn("Module #{inspect(module)} has no mount point in config")
  end
end