Skip to main content

lib/mix/tasks/crosswake.install.ex

defmodule Mix.Tasks.Crosswake.Install do
  use Mix.Task

  alias Crosswake.Install.Manifest
  alias Crosswake.Install.Patcher

  @shortdoc "Additively installs Crosswake into a Phoenix host"

  @moduledoc """
  Bootstraps a Phoenix host for Crosswake with explicit markers, a host-owned policy
  module, and a machine-readable install manifest.
  """

  @switches [
    target: :string,
    router: :string,
    web_module: :string,
    policy_module: :string,
    manifest_path: :string
  ]

  @impl Mix.Task
  def run(args) do
    Mix.shell().info("Crosswake installer: additive, idempotent, and marker-driven.")

    {opts, _argv, invalid} = OptionParser.parse(args, strict: @switches)

    if invalid != [] do
      Mix.raise("invalid options: #{inspect(invalid)}")
    end

    target = Path.expand(opts[:target] || File.cwd!())
    router_path = Path.expand(opts[:router] || infer_router_path!(target), target)
    web_module = opts[:web_module] || infer_web_module!(router_path)
    policy_module = opts[:policy_module] || "#{web_module}.Crosswake.Policy"
    policy_path = policy_path(target, router_path)

    manifest_path =
      Path.expand(opts[:manifest_path] || "priv/crosswake/install_manifest.json", target)

    {:ok, router_result} = Patcher.patch_router(router_path, policy_module)

    {policy_action, _policy_contents} =
      ensure_policy_module(policy_path, policy_module, router_path)

    {:ok, manifest_action} =
      Manifest.write(manifest_path, %{
        crosswake_version: Mix.Project.config()[:version] || "dev",
        router_path: Path.relative_to(router_path, target),
        web_module: web_module,
        policy_module: policy_module,
        files: %{
          created_or_reused: [
            Path.relative_to(router_path, target),
            Path.relative_to(policy_path, target),
            Path.relative_to(manifest_path, target)
          ]
        },
        markers: Patcher.marker_lines()
      })

    Mix.shell().info("""
    Crosswake install complete for #{Path.basename(target)}
      router: #{Path.relative_to(router_path, target)} (#{format_router_actions(router_result.actions)})
      policy module: #{Path.relative_to(policy_path, target)} (#{policy_action})
      install manifest: #{Path.relative_to(manifest_path, target)} (#{manifest_action})
    """)
  end

  defp infer_router_path!(target) do
    case Path.wildcard(Path.join([target, "lib", "*_web", "router.ex"])) do
      [router_path] ->
        router_path

      [] ->
        Mix.raise("could not find lib/*_web/router.ex under #{target}")

      paths ->
        Mix.raise(
          "found multiple router files, pass --router explicitly: #{Enum.join(paths, ", ")}"
        )
    end
  end

  defp infer_web_module!(router_path) do
    router_path
    |> Path.dirname()
    |> Path.basename()
    |> Macro.camelize()
  end

  defp policy_path(target, router_path) do
    router_dir = Path.dirname(router_path)
    Path.join([target, Path.relative_to(router_dir, target), "crosswake", "policy.ex"])
  end

  defp ensure_policy_module(path, policy_module, router_path) do
    File.mkdir_p!(Path.dirname(path))
    contents = policy_module_template(policy_module, router_path)

    case File.read(path) do
      {:ok, existing} ->
        {:reused, existing}

      {:error, :enoent} ->
        File.write!(path, contents)
        {:created, contents}

      {:error, reason} ->
        Mix.raise("could not write policy module #{path}: #{:file.format_error(reason)}")
    end
  end

  defp policy_module_template(policy_module, router_path) do
    module_template_path =
      Application.app_dir(:crosswake, "priv/templates/crosswake/policy_module.ex")

    router_module = router_module_from_path(router_path)

    EEx.eval_file(module_template_path,
      assigns: [policy_module: policy_module, router_module: router_module]
    )
  end

  defp router_module_from_path(router_path) do
    router_path
    |> Path.dirname()
    |> Path.basename()
    |> Kernel.<>(".Router")
    |> then(&Macro.camelize(&1))
  end

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