lib/mix/tasks/install.ex

defmodule Mix.Tasks.Lvn.Install do
  @moduledoc "Installer Mix task for LiveView Native: `mix lvn.install`"
  use Mix.Task

  @requirements ["app.config"]

  @template_projects_repo "https://github.com/liveview-native/liveview-native-template-projects"
  @template_projects_version "0.0.1"

  @shortdoc "Installs LiveView Native."
  def run(args) do
    {parsed_args, _, _} = OptionParser.parse(args, strict: [namespace: :string])

    # Define some paths for the host project
    current_path = File.cwd!()
    mix_config_path = Path.join(current_path, "mix.exs")
    app_config_path = Path.join(current_path, "/config/config.exs")
    app_namespace = parsed_args[:namespace] || infer_app_namespace(mix_config_path)
    build_path = Path.join(current_path, "_build")
    libs_path = Path.join(build_path, "dev/lib")

    # Ask the user some questions about their app
    preferred_route_input =
      IO.gets(
        "What path should native clients connect to by default? Leave blank for default: \"/\")\n"
      )

    preferred_prod_url_input =
      IO.gets("What URL will you use in production? Leave blank for default: \"example.com\")\n")

    preferred_route = String.trim(preferred_route_input)
    _preferred_route = if preferred_route == "", do: "/", else: preferred_route
    preferred_prod_url = String.trim(preferred_prod_url_input)
    _preferred_prod_url = if preferred_prod_url == "", do: "example.com", else: preferred_prod_url

    # Get a list of compiled libraries
    libs = File.ls!(libs_path)

    # Clone the liveview-native-template-projects repo. This repo contains
    # templates for various native platforms in their respective tools
    # (Xcode, Android Studio, etc.)
    clone_template_projects()
    template_projects_path = Path.join(build_path, "lvn_tmp/liveview-native-template-projects")
    template_libs = File.ls!(template_projects_path)

    # Find libraries compiled for the host project that have available
    # template projects
    supported_libs = Enum.filter(libs, &(&1 in template_libs))

    # Run the install script for each template project. Install scripts are
    # responsible for generating platform-specific template projects and return
    # information about that platform to be applied to the host project's Mix
    # configuration.
    platform_names =
      Enum.map(supported_libs, fn lib ->
        status_message("configuring", "#{lib}")

        # Run the project-specific install script, passing info about the host
        # Phoenix project.
        lib_path = Path.join(template_projects_path, "/#{lib}")
        script_path = Path.join(lib_path, "/install.exs")

        cmd_opts = [
          script_path,
          "--app-name",
          app_namespace,
          "--app-path",
          current_path,
          "--platform-lib-path",
          lib_path
        ]

        with {platform_name, 0} <- System.cmd("elixir", cmd_opts) do
          String.trim(platform_name)
        end
      end)

    generate_native_exs_if_needed(current_path, platform_names)
    update_config_exs_if_needed(app_config_path)

    # Clear _build path to ensure it's rebuilt with new Config
    File.rm_rf(build_path)

    IO.puts("\nYour Phoenix app is ready to use LiveView Native!\n")
    IO.puts("Platform-specific project files have been placed in the \"native\" directory\n")

    :ok
  end

  ###

  defp clone_template_projects do
    with {:ok, current_path} <- File.cwd(),
         tmp_path <- Path.join(current_path, "_build/lvn_tmp"),
         _ <- File.rm_rf(tmp_path),
         :ok <- File.mkdir(tmp_path) do
      status_message("downloading", "template project files")

      System.cmd("git", [
        "clone",
        "-b",
        @template_projects_version,
        @template_projects_repo,
        tmp_path <> "/liveview-native-template-projects"
      ])
    end
  end

  def infer_app_namespace(config_path) do
    with {:ok, config} <- File.read(config_path),
         {:ok, mix_project_ast} <- Code.string_to_quoted(config),
         {:ok, namespace} <- find_mix_project_namespace(mix_project_ast) do
      "#{namespace}"
    else
      _ ->
        raise "Could not infer Mix project namespace from mix.exs. Please provide it manually using the --namespace argument."
    end
  end

  defp find_mix_project_namespace(ast) do
    case ast do
      ast when is_list(ast) ->
        ast
        |> Enum.reduce_while({:error, :cannot_infer_app_name}, fn node, _acc ->
          {status, result} = find_mix_project_namespace(node)
          acc_op = if status == :ok, do: :halt, else: :cont

          {acc_op, {status, result}}
        end)

      {:defmodule, _, [{:__aliases__, _, [namespace, :MixProject]} | _rest]} ->
        {:ok, namespace}

      {:__block__, _, contents} ->
        find_mix_project_namespace(contents)

      _ ->
        {:error, :cannot_infer_app_name}
    end
  end

  defp generate_native_exs_if_needed(current_path, platform_names) do
    platform_names_string = Enum.join(platform_names, ",")
    native_config_path = Path.join(current_path, "/config/native.exs")

    if File.exists?(native_config_path) do
      IO.puts("native.exs already exists, skipping...")
    else
      status_message("creating", "config/native.exs")

      # Generate native.exs and write it to config path
      lvn_configuration = native_exs_body(platform_names_string)
      {:ok, native_config} = File.open(native_config_path, [:write])
      IO.binwrite(native_config, lvn_configuration)
      File.close(native_config)

      :ok
    end
  end

  defp update_config_exs_if_needed(app_config_path) do
    # Update project's config.exs to import native.exs if needed.
    import_string = "import_config \"native.exs\""
    {:ok, app_config_body} = File.read(app_config_path)

    if String.contains?(app_config_body, import_string) do
      IO.puts("config.exs already imports native.exs, skipping...")
    else
      status_message("updating", "config/config.exs")

      {:ok, app_config} = File.open(app_config_path, [:write])
      updated_app_config_body = app_config_body <> "\n" <> import_string

      IO.binwrite(app_config, updated_app_config_body)
      File.close(app_config)
    end
  end

  defp native_exs_body(platform_names_string) do
    """
    # This file is responsible for configuring LiveView Native.
    # It is auto-generated when running `mix lvn.install`.
    import Config

    config :live_view_native, plugins: [#{platform_names_string}]
    """
  end

  defp status_message(label, message) do
    formatted_message = IO.ANSI.green() <> "* #{label} " <> IO.ANSI.reset() <> message

    IO.puts(formatted_message)
  end
end