lib/mix/tasks/coh.clean.ex

defmodule Mix.Tasks.Coh.Clean do
  @moduledoc """
  This task will clean most of the files installed by the `mix coh.install` task.

  Projects created with both `coh.install` and `coherence.install` can be
  cleaned with this task. It will auto detect the project structure and remove
  the appropriate files.

  ## Examples

      # Clean all the installed files
      mix coh.clean --all

      # Clean only the installed view and template files
      mix coh.clean --views --templates

      # Clean all but the models
      mix coh.clean --all --no-models

      # Prompt once to confirm the removal
      mix coh.clean --all --confirm-once

      # Clean installed options
      mix coh.clean --options rememberable
      mix coh.clean --options "registerable invitable trackable"

  The following options are supported:

  * `--all` -- clean all files
  * `--views` -- clean view files
  * `--templates` -- clean template files
  * `--models` -- clean models files
  * `--controllers` -- clean controller files
  * `--email` -- clean email files
  * `--web` -- clean the web/coherence_web.ex file
  * `--messages` -- clean the web/coherence_messages.ex file
  * `--migrations` -- clean the migration files
  * `--options` -- Clean one or more specific options
  * `--dry-run` -- Show what will be removed, but don't actually remove any files
  * `--confirm-once` -- confirm once before removing all selected files

  Disable options:

  * `--no-confirm` - don't confirm before removing files
  """
  use Mix.Task

  import Coherence.Mix.Utils
  import Mix.Ecto

  alias Mix.Tasks.Coh.Install

  @shortdoc "Clean files created by the coherence installer."

  @dialyzer [
    {:nowarn_function, raise_options_error!: 2}
  ]

  @config_file "config/config.exs"
  @remove_opts ~w(views templates models controllers emails web messages migrations config)a
  @default_opts ~w(confirm)a
  @clean_opts [{:options, :string}]
  @switches Enum.map([:all, :confirm_once | @remove_opts] ++ @default_opts, &{&1, :boolean}) ++
              @clean_opts ++ [dry_run: :boolean]

  @spec run([String.t()] | []) :: any
  def run(args) do
    args
    |> OptionParser.parse(switches: @switches)
    |> do_config
    |> do_clean
    |> do_clean_options
  end

  defp do_clean(config) do
    confirm_once(config, fn config ->
      Enum.reduce(@remove_opts, config, &remove!(&2, &1))
    end)
  end

  defp do_clean_options(config) do
    confirm_once(config, fn config ->
      remove!(config, :options)
    end)
  end

  defp confirm_once(%{confirm_once: true} = config, fun) do
    config = Map.put(config, :confirm, false)

    if Mix.shell().yes?("Are you sure you want to delete coherence files?") do
      fun.(config)
    end

    config
  end

  defp confirm_once(config, fun) do
    fun.(config)
  end

  defp valid_option?(all_options, option) do
    Enum.any?(all_options, &(&1 == option))
  end

  defp validate_options!(options, all_options) do
    case Enum.filter(options, &(not valid_option?(all_options, &1))) do
      [] -> :ok
      list -> raise_options_error!(list, all_options)
    end
  end

  defp option_string(options) do
    options
    |> Enum.map(&Atom.to_string/1)
    |> Enum.join(", ")
  end

  defp raise_options_error!(list, all_options) do
    Mix.raise("""
    Invalid Option(s): '#{option_string(list)}' are not valid option(s)

    Please choose from list:
    '#{option_string(all_options)}'
    """)
  end

  defp get_current_options(options) do
    :coherence
    |> Application.get_env(:opts)
    |> log_invalid_options(options)
  end

  defp log_invalid_options(current_options, options) do
    case Enum.filter(options, &(not valid_option?(current_options, &1))) do
      [] ->
        options

      invalid_list ->
        Mix.shell().info(
          "Option(s) #{option_string(invalid_list)} where not found in your configuration. They will not be removed."
        )

        options -- invalid_list
    end
  end

  defp remove_view_file!(option, config) do
    view_files = Install.view_files()

    case view_files[option] do
      nil ->
        :ok

      file_name ->
        path = web_path(["views", "coherence", file_name])
        confirm(config, path, fn -> rm!(path) end)
    end

    option
  end

  defp remove_template_files!(option, config) do
    files = Install.template_files()

    files
    |> Enum.find(fn {_, {opt, _}} -> opt == option end)
    |> case do
      nil ->
        :ok

      {path, _} ->
        path = web_path(["templates", "coherence", "#{path}"])
        confirm(config, path, fn -> rm_dir!(path) end)
    end

    option
  end

  defp remove_controller_files!(option, config) do
    case controller_files()[option] do
      nil ->
        :ok

      file_name ->
        path = web_path(["controllers", "coherence", file_name])
        confirm(config, path, fn -> rm!(path) end)
    end

    option
  end

  defp remove_config_options!(options, config) do
    with contents when is_binary(contents) <- File.read!(@config_file),
         [_, opts_string] when is_binary(opts_string) <-
           Regex.run(~r/opts:\s*(\[.*?\])/, contents) do
      remove_config_options!(config, contents, options, opts_string)
    else
      error ->
        Mix.raise("""
        Problem updating config, error: #{inspect(error)}
        """)
    end
  end

  defp remove_config_options!(config, contents, options, opts_string) do
    new_opts_string =
      Enum.reduce(options, opts_string, fn option, acc ->
        String.replace(acc, ~r/(,\s*:#{option})|(:#{option}\s*,?\s*)/, "")
      end)

    message = ~s'"opts: #{stringify(opts_string)}" with "opts: #{stringify(new_opts_string)}"'

    confirm_config(config, message, fn ->
      File.write!(@config_file, String.replace(contents, opts_string, new_opts_string))
    end)

    config
  end

  defp stringify(item) do
    item
    |> inspect
    |> String.replace("\"", "")
  end

  defp remove_option!(config, option) do
    option
    |> remove_view_file!(config)
    |> remove_template_files!(config)
    |> remove_controller_files!(config)
  end

  defp remove!(%{options: options} = config, :options) do
    all_options = Install.all_options()

    options =
      options
      |> String.split(" ", trim: true)
      |> Enum.map(&String.replace(&1, "-", "_"))
      |> Enum.map(&String.to_atom/1)

    validate_options!(options, all_options)

    options
    |> get_current_options
    |> Enum.map(&remove_option!(config, &1))
    |> remove_config_options!(config)

    config
  end

  defp remove!(%{templates: true} = config, :templates) do
    path = web_path("templates/coherence")
    confirm(config, path, fn -> rm_dir!(path) end)
  end

  defp remove!(%{views: true} = config, :views) do
    path = web_path("views/coherence")
    confirm(config, path, fn -> rm_dir!(path) end)
  end

  defp remove!(%{controllers: true} = config, :controllers) do
    path = web_path("controllers/coherence")
    confirm(config, path, fn -> rm_dir!(path) end)
  end

  defp remove!(%{models: true} = config, :models) do
    path = lib_path("coherence")
    confirm(config, path, fn -> rm_dir!(path) end)
  end

  defp remove!(%{web: true} = config, :web) do
    path = web_path("coherence_web.ex")
    confirm(config, path, fn -> rm!(path) end)
  end

  defp remove!(%{messages: true} = config, :messages) do
    path = web_path("coherence_messages.ex")
    confirm(config, path, fn -> rm!(path) end)
  end

  defp remove!(%{emails: true} = config, :emails) do
    path = web_path(~w(emails coherence))
    confirm(config, path, fn -> rm_dir!(path) end)
  end

  defp remove!(%{migrations: true} = config, :migrations) do
    case Application.get_env(:coherence, :repo) do
      nil ->
        Mix.shell().error("Config.repo not configured. Skipping migration removal!")

      repo ->
        do_remove!(config, repo)
    end
  end

  defp remove!(%{config: true} = config, :config) do
    confirm(config, "coherence config", fn ->
      regex = ~r/# %% Coherence Configuration %%.+?# %% End Coherence Configuration %%/s
      conf = File.read!(@config_file)
      File.write!(@config_file, String.replace(conf, regex, ""))
    end)
  end

  defp remove!(config, _), do: config

  defp do_remove!(config, repo) do
    ensure_repo(repo, [])
    path = Path.relative_to(migrations_path(repo), Mix.Project.app_path())

    case Path.wildcard(path <> "/*coherence*") do
      [] ->
        config

      files ->
        confirmed? =
          if config[:confirm] do
            Mix.shell().info("Found migrations: " <> Enum.join(files, ", "))
            Mix.shell().yes?("Delete them?")
          else
            true
          end

        if confirmed? do
          Enum.each(files, &rm!(&1))
        end

        config
    end
  end

  defp confirm_config(%{dry_run: true} = config, message, _fun) do
    Mix.shell().info("Update config " <> message)
    config
  end

  defp confirm_config(%{confirm: true} = config, message, fun) do
    if Mix.shell().yes?("Update config " <> message <> "?") do
      Mix.shell().info("Updating config " <> message)
      fun.()
    end

    config
  end

  defp confirm_config(config, message, fun) do
    Mix.shell().info("Updating config " <> message)
    fun.()
    config
  end

  defp confirm(%{dry_run: true} = config, path, _fun) do
    Mix.shell().info("Delete #{path}")
    config
  end

  defp confirm(%{confirm: true} = config, path, fun) do
    if File.exists?(path) do
      if Mix.shell().yes?("Delete #{path}?") do
        fun.()
      end
    else
      Mix.shell().error("Problem removing #{path}")
    end

    config
  end

  defp confirm(config, _path, fun) do
    fun.()
    config
  end

  ###############
  # configuration

  defp do_config({opts, parsed, unknown}) do
    opts
    |> verify_opts(parsed, unknown)
    |> Enum.into(%{})
    |> do_all_config
    |> do_default_config
  end

  defp do_all_config(%{all: true} = config) do
    Enum.reduce(@remove_opts, config, &Map.put_new(&2, &1, true))
  end

  defp do_all_config(config), do: config

  defp do_default_config(config) do
    Enum.reduce(@default_opts, config, fn opt, config ->
      Map.put(config, opt, Map.get(config, opt, true))
    end)
  end

  defp verify_opts(opts, parsed, unknown) do
    verify_args!(parsed, unknown)

    switch_keys = Keyword.keys(@switches)

    case opts |> Keyword.keys() |> Enum.filter(&(not (&1 in switch_keys))) do
      [] -> opts
      list -> raise_option_errors(list)
    end
  end

  defp coh? do
    not File.exists?("web")
  end

  defp web_path(path) when is_binary(path), do: Path.join(web_path(), path)
  defp web_path(paths), do: Path.join([web_path() | paths])

  defp web_path do
    if coh?(), do: Path.join("lib", otp_app() <> "_web"), else: "web"
  end

  defp lib_path, do: Path.join("lib", otp_app())
  defp lib_path(path) when is_binary(path), do: Path.join(lib_path(), path)
  # defp lib_path(paths), do: Path.join([lib_path() | paths])

  defp otp_app do
    Mix.Project.config()[:app] |> to_string
  end
end