Skip to main content

lib/mix/tasks/skua.install.ex

# The Skua installer runs as a full Igniter task when Igniter is available — so
# `mix igniter.install skua` and `mix igniter.new my_app --with phx.new --install
# skua` work, with diff preview, `--yes`, and idempotent re-runs. Without Igniter
# it falls back to a self-contained Mix task, so `mix skua.install` still works
# and Skua compiles cleanly on apps that don't carry Igniter. Both paths share
# the pure transforms in `Skua.Install.Patches`.
if Code.ensure_loaded?(Igniter) do
  defmodule Mix.Tasks.Skua.Install do
    @shortdoc "Wire Skua into a Phoenix app and scaffold the starter home page"
    @moduledoc """
    Installs Skua into the current Phoenix project.

        mix igniter.install skua
        mix skua.install

    Wires `assets/css/skua.css` and the JS hooks bundle, imports Skua's
    components into `lib/<app>_web.ex` (Skua wins for `button`/`input`/`header`/
    `table`/`list`), routes flashes through Skua toasts, strips the default
    Phoenix navbar, and scaffolds an editable starter `HomeLive` at `/`.

    Every step is idempotent — re-run any time after a Skua upgrade and it only
    adds what's missing. Steps that don't match the default Phoenix layout are
    reported as a manual instruction rather than corrupting a file.
    """
    use Igniter.Mix.Task

    alias Skua.Install.Patches, as: P

    @impl Igniter.Mix.Task
    def info(_argv, _composing_task) do
      %Igniter.Mix.Task.Info{
        example: "mix igniter.install skua --theme greenfield --strip-daisy",
        schema: [strip_daisy: :boolean, theme: :string]
      }
    end

    @impl Igniter.Mix.Task
    def igniter(igniter) do
      case P.check_requirements() do
        {:error, msg} ->
          # Refuse cleanly — adding an issue aborts the run with no partial apply.
          Igniter.add_issue(igniter, msg)

        :ok ->
          {css_ref, js_ref} = P.asset_refs()
          {web_mod, web_dir, web_ex} = P.web_context()
          layouts = Path.join([web_dir, "components", "layouts.ex"])
          # nil (flag omitted) means "auto" — strip when daisyUI is present; the
          # Patches transforms self-skip when there's nothing to strip.
          strip_daisy = Keyword.get(igniter.args.options, :strip_daisy, :auto)
          theme = Keyword.get(igniter.args.options, :theme)

          igniter
          |> patch("assets/css/app.css", &P.css(&1, css_ref))
          |> patch("assets/js/app.js", &P.app_js(&1, js_ref))
          |> patch(web_ex, &P.web_imports(&1, web_mod))
          |> patch(layouts, &P.layout_flash/1)
          |> patch(layouts, &P.strip_chrome/1)
          |> gen_home(web_mod, web_dir)
          |> patch(Path.join(web_dir, "router.ex"), &P.router/1)
          |> patch(
            Path.join([web_dir, "components", "layouts", "root.html.heex"]),
            &P.root_layout/1
          )
          |> maybe_strip_daisy(strip_daisy, layouts)
          |> maybe_apply_theme(theme)
          |> Igniter.add_notice("Skua installed. Run `mix phx.server` and open `/`.")
      end
    end

    defp maybe_apply_theme(igniter, nil), do: igniter

    defp maybe_apply_theme(igniter, name) do
      if Skua.Themes.exists?(name) do
        igniter
        |> patch("assets/css/app.css", &P.theme_css(&1, Skua.Themes.css(name)))
        |> Igniter.add_notice(
          "Applied the \"#{name}\" theme to assets/css/app.css — edit the tokens there, " <>
            "or re-run with a different --theme."
        )
      else
        Igniter.add_issue(
          igniter,
          "Unknown --theme #{inspect(name)}. Run `mix skua.themes` to list all " <>
            "#{length(Skua.Themes.names())}, or omit --theme to keep the default look."
        )
      end
    end

    defp maybe_strip_daisy(igniter, false, _layouts), do: igniter

    defp maybe_strip_daisy(igniter, _strip, layouts) do
      igniter
      |> patch("assets/css/app.css", &P.strip_daisy_css/1)
      |> patch(layouts, &P.strip_daisy_theme_toggle/1)
      |> rm_if_exists(P.daisy_vendor_files())
      |> Igniter.add_notice(
        "daisyUI removed where present — vendored files deleted; base-*/primary/error " <>
          "bridged to Skua tokens. Pass --no-strip-daisy to keep daisyUI."
      )
    end

    defp rm_if_exists(igniter, paths) do
      Enum.reduce(paths, igniter, fn path, acc ->
        if Igniter.exists?(acc, path), do: Igniter.rm(acc, path), else: acc
      end)
    end

    # Apply a `Skua.Install.Patches` transform to a file through Igniter's
    # rewrite pipeline. `:skip` is a no-op, `{:manual, msg}` degrades to a
    # warning instead of corrupting the file.
    defp patch(igniter, path, transform) do
      if Igniter.exists?(igniter, path) do
        Igniter.update_file(igniter, path, fn source ->
          case transform.(Rewrite.Source.get(source, :content)) do
            :skip -> source
            {:ok, new} -> Rewrite.Source.update(source, :content, new)
            {:manual, msg} -> {:warning, msg}
          end
        end)
      else
        Igniter.add_warning(igniter, "#{path} not found — apply the Skua step here manually.")
      end
    end

    defp gen_home(igniter, web_mod, web_dir) do
      # Igniter derives an Elixir source's path from its module name
      # (FooWeb.HomeLive -> lib/foo_web/home_live.ex), so we generate there.
      # Idempotent across both that path and the legacy live/ path Skua 0.1.0
      # used, so re-running never produces a duplicate module.
      dest = Path.join(web_dir, "home_live.ex")
      legacy = Path.join([web_dir, "live", "home_live.ex"])

      cond do
        Igniter.exists?(igniter, dest) or Igniter.exists?(igniter, legacy) ->
          igniter

        true ->
          case P.home_body(web_mod) do
            {:ok, body} -> Igniter.create_new_file(igniter, dest, body)
            {:manual, msg} -> Igniter.add_warning(igniter, msg)
          end
      end
    end
  end
else
  defmodule Mix.Tasks.Skua.Install do
    @shortdoc "Wire Skua into a Phoenix app and scaffold the starter home page"
    @moduledoc """
    Installs Skua into the current Phoenix project.

        mix skua.install

    Wires `assets/css/skua.css` and the JS hooks bundle, imports Skua's
    components into `lib/<app>_web.ex`, routes flashes through Skua toasts,
    strips the default Phoenix navbar, and scaffolds an editable starter
    `HomeLive` at `/`. Every step is idempotent.

    Add `{:igniter, "~> 0.8", only: [:dev, :test]}` (or run `mix igniter.install
    skua`) to get the one-command Igniter installer with diff preview.
    """
    use Mix.Task

    alias Skua.Install.Patches, as: P

    @requirements ["app.config"]

    @impl Mix.Task
    def run(args) do
      # Refuse cleanly on an incompatible host (no partial apply).
      with {:error, msg} <- P.check_requirements(), do: Mix.raise(msg)

      {opts, _, _} = OptionParser.parse(args, switches: [strip_daisy: :boolean, theme: :string])
      # nil (flag omitted) means "auto" — strip when daisyUI is present; the
      # Patches transforms self-skip when there's nothing to strip.
      strip_daisy = Keyword.get(opts, :strip_daisy, :auto)

      theme = Keyword.get(opts, :theme)

      if theme && not Skua.Themes.exists?(theme) do
        Mix.raise(
          "Unknown --theme #{inspect(theme)}. Run `mix skua.themes` to list all " <>
            "#{length(Skua.Themes.names())}, or omit --theme to keep the default look."
        )
      end

      app = Mix.Project.config()[:app]
      {web_mod, web_dir, web_ex} = P.web_context()
      layouts = Path.join([web_dir, "components", "layouts.ex"])
      {css_ref, js_ref} = P.asset_refs()

      Mix.shell().info([:cyan, "\n  Installing Skua into #{app}\n", :reset])

      results =
        [
          file_patch("assets/css/app.css", &P.css(&1, css_ref), "add @import for skua.css"),
          file_patch("assets/js/app.js", &P.app_js(&1, js_ref), "import + register Skua hooks"),
          file_patch(
            web_ex,
            &P.web_imports(&1, web_mod),
            "import Skua components (except button/input)"
          ),
          file_patch(layouts, &P.layout_flash/1, "render flashes as Skua toasts"),
          file_patch(layouts, &P.strip_chrome/1, "strip default Phoenix chrome"),
          gen_home(web_mod, web_dir),
          file_patch(
            Path.join(web_dir, "router.ex"),
            &P.router/1,
            "route / to the Skua home LiveView"
          ),
          file_patch(
            Path.join([web_dir, "components", "layouts", "root.html.heex"]),
            &P.root_layout/1,
            "add pre-paint theme script"
          )
        ] ++ strip_daisy_results(strip_daisy, layouts) ++ theme_results(theme)

      print_summary(results)

      Mix.shell().info([
        :green,
        "\n  Done. ",
        :reset,
        "Run ",
        :bright,
        "mix phx.server",
        :reset,
        " and open ",
        :bright,
        "/",
        :reset,
        ".\n"
      ])
    end

    defp file_patch(path, transform, action) do
      cond do
        not File.exists?(path) ->
          {:manual, path, "file not found — apply this step manually"}

        true ->
          case transform.(File.read!(path)) do
            :skip ->
              {:skip, path, action}

            {:ok, new} ->
              File.write!(path, new)
              {:ok, path, action}

            {:manual, msg} ->
              {:manual, path, msg}
          end
      end
    end

    defp gen_home(web_mod, web_dir) do
      # Match the Igniter branch: module-convention path, idempotent across the
      # legacy live/ path Skua 0.1.0 used.
      dest = Path.join(web_dir, "home_live.ex")
      legacy = Path.join([web_dir, "live", "home_live.ex"])

      cond do
        File.exists?(dest) or File.exists?(legacy) ->
          {:skip, dest, "generate starter home LiveView"}

        true ->
          case P.home_body(web_mod) do
            {:ok, body} ->
              File.mkdir_p!(Path.dirname(dest))
              File.write!(dest, body)
              {:ok, dest, "generate starter home LiveView"}

            {:manual, msg} ->
              {:manual, dest, msg}
          end
      end
    end

    defp theme_results(nil), do: []

    defp theme_results(name) do
      [
        file_patch(
          "assets/css/app.css",
          &P.theme_css(&1, Skua.Themes.css(name)),
          "apply the #{name} theme"
        )
      ]
    end

    defp strip_daisy_results(false, _layouts), do: []

    defp strip_daisy_results(_strip, layouts) do
      [
        file_patch(
          "assets/css/app.css",
          &P.strip_daisy_css/1,
          "strip daisyUI + add token bridge"
        ),
        file_patch(layouts, &P.strip_daisy_theme_toggle/1, "swap theme toggle to Skua")
      ] ++ Enum.map(P.daisy_vendor_files(), &rm_vendor/1)
    end

    defp rm_vendor(path) do
      if File.exists?(path) do
        File.rm!(path)
        {:ok, path, "remove vendored daisyUI"}
      else
        {:skip, path, "remove vendored daisyUI"}
      end
    end

    defp print_summary(results) do
      Enum.each(results, fn
        {:ok, path, action} ->
          Mix.shell().info([
            :green,
            "  ✓ ",
            :reset,
            "#{action || "patched"} — ",
            :bright,
            path,
            :reset
          ])

        {:skip, path, action} ->
          Mix.shell().info([:faint, "  • already done — #{action || path}", :reset])

        {:manual, path, msg} ->
          Mix.shell().info([:yellow, "  ! ", :reset, "#{path}: ", msg])
      end)
    end
  end
end