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