lib/mix/pow.ex

defmodule Mix.Pow do
  @moduledoc """
  Utilities module for mix tasks.
  """
  alias Mix.{Dep.Converger, Project}

  @doc """
  Raises an exception if the project is an umbrella app.
  """
  @spec no_umbrella!(binary()) :: :ok
  def no_umbrella!(task) do
    if Project.umbrella?() do
      Mix.raise("mix #{task} can only be run inside an application directory")
    end

    :ok
  end

  # TODO: Remove by 1.1.0
  @doc false
  @deprecated "Use `ensure_ecto!` or `ensure_phoenix!` instead"
  @spec ensure_dep!(binary(), atom(), OptionParser.argv()) :: :ok
  def ensure_dep!(task, dep, _args) do
    []
    |> __dependencies__()
    |> top_level_dep_in_deps?(dep)
    |> case do
      true ->
        :ok

      false ->
        Mix.raise("mix #{task} can only be run inside an application directory that has #{inspect dep} as dependency")
    end
  end

  @doc false
  def __dependencies__(opts) do
    Converger.converge(nil, nil, opts, &{&1, &2, &3}) |> elem(0)
  end

  @doc """
  Raises an exception if application doesn't have Ecto as dependency.
  """
  @spec ensure_ecto!(binary(), OptionParser.argv()) :: :ok
  def ensure_ecto!(task, _args) do
    deps = __dependencies__([])

    cond do
      top_level_dep_in_deps?(deps, :ecto) -> :ok
      top_level_dep_in_deps?(deps, :ecto_sql) -> :ok
      true -> Mix.raise("mix #{task} can only be run inside an application directory that has :ecto or :ecto_sql as dependency")
    end
  end

  defp top_level_dep_in_deps?(deps, dep) do
    Enum.any?(deps, fn
      %Mix.Dep{app: ^dep, top_level: true} -> true
      _any -> false
    end)
  end

  @doc """
  Raises an exception if application doesn't have Phoenix as dependency.
  """
  @spec ensure_phoenix!(binary(), OptionParser.argv()) :: :ok
  def ensure_phoenix!(task, _args) do
    []
    |> __dependencies__()
    |> top_level_dep_in_deps?(:phoenix)
    |> case do
      true -> :ok
      false -> Mix.raise("mix #{task} can only be run inside an application directory that has :phoenix as dependency")
    end
  end

  @doc """
  Parses argument options into a map.
  """
  @spec parse_options(OptionParser.argv(), Keyword.t(), Keyword.t()) :: {map(), OptionParser.argv(), OptionParser.errors()}
  def parse_options(args, switches, default_opts) do
    generator_opts = parse_context_app(Application.get_env(otp_app(), :generators, []))
    default_opts   = Keyword.merge(default_opts, generator_opts)

    {opts, parsed, invalid} = OptionParser.parse(args, switches: switches)
    default_opts            = to_map(default_opts)
    opts                    = context_app_to_atom(to_map(opts))
    config                  = Map.merge(default_opts, opts)

    {config, parsed, invalid}
  end

  defp parse_context_app(options) do
    case options[:context_app] do
      {context_app, _path} -> Keyword.put(options, :context_app, context_app)
      _context_app         -> options
    end
  end

  defp to_map(keyword) do
    Enum.reduce(keyword, %{}, fn {key, value}, map ->
      case Map.get(map, key) do
        nil ->
          Map.put(map, key, value)

        existing_value ->
          value = List.wrap(existing_value) ++ [value]
          Map.put(map, key, value)
      end
    end)
  end

  defp context_app_to_atom(%{context_app: context_app} = config),
    do: Map.put(config, :context_app, String.to_atom(context_app))
  defp context_app_to_atom(config),
    do: config

  @doc """
  Parses arguments into schema name and schema plural.
  """
  @spec schema_options_from_args([binary()]) :: map()
  def schema_options_from_args(_opts \\ [])
  def schema_options_from_args([schema, plural | _rest]), do: %{schema_name: schema, schema_plural: plural}
  def schema_options_from_args(_any), do: %{schema_name: "Users.User", schema_plural: "users"}

  @doc false
  @spec validate_schema_args!([binary()], binary()) :: map()
  def validate_schema_args!([schema, plural | _rest] = args, task) do
    cond do
      not schema_valid?(schema) ->
        raise_invalid_schema_args_error!("Expected the schema argument, #{inspect schema}, to be a valid module name", task)
      not plural_valid?(plural) ->
        raise_invalid_schema_args_error!("Expected the plural argument, #{inspect plural}, to be all lowercase using snake_case convention", task)
      true ->
        schema_options_from_args(args)
    end
  end
  def validate_schema_args!([_schema | _rest], task) do
    raise_invalid_schema_args_error!("Invalid arguments", task)
  end
  def validate_schema_args!([], _task), do: schema_options_from_args()

  defp schema_valid?(schema), do: schema =~ ~r/^[A-Z]\w*(\.[A-Z]\w*)*$/

  defp plural_valid?(plural), do: plural =~ ~r/^[a-z\_]*$/

  @spec raise_invalid_schema_args_error!(binary(), binary()) :: no_return()
  defp raise_invalid_schema_args_error!(msg, task) do
    Mix.raise("""
    #{msg}

    mix #{task} accepts both a module name and the plural of the resource:
        mix #{task} Users.User users
    """)
  end

  # TODO: Remove by 1.1.0
  @doc false
  @deprecated "Please use `Pow.Phoenix.parse_structure/1` instead"
  @spec context_app :: atom()
  def context_app do
    this_app = otp_app()

    this_app
    |> Application.get_env(:generators, [])
    |> Keyword.get(:context_app)
    |> case do
      nil          -> this_app
      false        -> Mix.raise("No context_app configured for current application")
      {app, _path} -> app
      app          -> app
    end
  end

  @doc false
  @spec otp_app :: atom()
  def otp_app do
    Keyword.fetch!(Mix.Project.config(), :app)
  end

  # TODO: Remove by 1.1.0
  @doc false
  @deprecated "Use `app_base/1` instead"
  @spec context_base(atom()) :: atom()
  def context_base(app), do: app_base(app)

  @doc """
  Fetches the context base module for the app.
  """
  @spec app_base(atom()) :: atom()
  def app_base(app) do
    case Application.get_env(app, :namespace, app) do
      ^app ->
        app
        |> to_string()
        |> Macro.camelize()
        |> List.wrap()
        |> Module.concat()

      mod ->
        mod
    end
  end

  @doc """
  Fetches the library path for the context app.
  """
  @spec context_lib_path(atom(), Path.t()) :: Path.t()
  def context_lib_path(ctx_app, rel_path) do
    context_app_path(ctx_app, Path.join(["lib", to_string(ctx_app), rel_path]))
  end

  defp context_app_path(ctx_app, rel_path) when is_atom(ctx_app) do
    this_app = otp_app()

    if ctx_app == this_app do
      rel_path
    else
      app_path =
        case Application.get_env(this_app, :generators)[:context_app] do
          {^ctx_app, path} -> Path.relative_to_cwd(path)
          _ -> mix_app_path(ctx_app, this_app)
        end

      Path.join(app_path, rel_path)
    end
  end

  defp mix_app_path(app, this_otp_app) do
    case Mix.Project.deps_paths() do
      %{^app => path} ->
        Path.relative_to_cwd(path)

      _deps ->
        Mix.raise("No directory for context_app #{inspect(app)} found in #{this_otp_app}'s deps.")
    end
  end

  @doc """
  Injects files with inline content
  """
  @spec inject_files([map()]) :: :ok | :error
  def inject_files(file_injections) do
    case process_file_injections(file_injections) do
      {:error, {:missing_files, file_injections}} ->
        Mix.shell().error("""
        Could not find the following file(s):

        #{Enum.map_join(file_injections, "\n", &Path.relative_to_cwd(&1.file))}
        """)

        :error

      {:error, {:invalid_files, file_injections}} ->
        Mix.shell().error("""
        Could not configure the following files:

        #{Enum.map_join(file_injections, "\n", &Path.relative_to_cwd(&1.file))}
        """)

        Mix.shell.info("""
        To complete please do the following:

        #{Enum.map_join(file_injections, "\n", & &1.instructions)}
        """)

        :ok

      {:ok, file_injections} ->
        Enum.each(file_injections, fn
          %{touched?: false, file: file} ->
            relative_path = Path.relative_to_cwd(file)

            Mix.shell().info([:yellow, "* already configured ", :reset, relative_path])

          %{touched?: true, file: file, content: content} ->
            relative_path = Path.relative_to_cwd(file)

            Mix.shell().info([:green, "* injecting ", :reset, relative_path])

            File.write!(file, content)
        end)

        :ok
    end
  end

  defp process_file_injections(file_injections) do
    with :ok             <- check_all_files_exists(file_injections),
         file_injections <- read_files(file_injections),
         :ok             <- check_all_files_can_be_updated(file_injections) do
      {:ok, Enum.map(file_injections, &prepare_content/1)}
    end
  end

  defp check_all_files_exists(file_injections) do
    case Enum.reject(file_injections, &File.exists?(&1.file)) do
      [] ->
        :ok

      file_injections ->
        {:error, {:missing_files, file_injections}}
    end
  end

  defp read_files(file_injections) do
    Enum.map(file_injections, &Map.put(&1, :content, File.read!(&1.file)))
  end

  defp check_all_files_can_be_updated(file_injections) do
    file_injections
    |> Enum.reject(fn file_injection ->
      injections = Enum.reject(file_injection.injections, & file_injection.content =~ &1.needle)

      injections == []
    end)
    |> case do
      [] -> :ok
      file_injections -> {:error, {:invalid_files, file_injections}}
    end
  end

  defp prepare_content(file_injection) do
    file_injection = Map.put(file_injection, :touched?, false)

    case Enum.reject(file_injection.injections, & file_injection.content =~ &1.test) do
      [] ->
        file_injection

      injections ->
        content = inject_content(injections, file_injection.content)

        file_injection
        |> Map.put(:content, content)
        |> Map.put(:touched?, true)
    end
  end

  defp inject_content(injections, content) do
    Enum.reduce(injections, content, fn injection, content ->
      content_lines = String.split(content, "\n")
      index = Enum.find_index(content_lines, & &1 =~ injection.needle)

      index =
        case injection[:prepend] do
          true -> previous_line_until_before_comments(content_lines, index - 1)
          _any -> index
        end

      {content_lines_before, content_lines_after} = Enum.split(content_lines, index + 1)

      [content_lines_before, [injection.content], content_lines_after]
      |> Enum.concat()
      |> Enum.join("\n")
    end)
  end

  defp previous_line_until_before_comments(content_lines, index) do
    case Enum.at(content_lines, index) do
      nil -> Mix.raise("Invalid line")
      "#" <> _string -> previous_line_until_before_comments(content_lines, index - 1)
      _string -> index
    end
  end
end