lib/mix/tasks/etcher.gen.migration.ex

defmodule Mix.Tasks.Etcher.Gen.Migration do
  @shortdoc "Generate the etcher_annotations migration"

  @moduledoc """
  Generates a migration that creates the `etcher_annotations` table used by
  `Etcher.Storage.Default`.

      mix etcher.gen.migration

  The migration is written into the consumer's `priv/repo/migrations/`
  directory with a timestamped filename, just like `mix ecto.gen.migration`.

  ## Options

    * `-r`, `--repo` — the Repo module to target. Defaults to the first repo
      in your Mix project's `:ecto_repos` config.

  ## After running

      mix ecto.migrate

  Consumers who roll their own annotation schema can skip this generator
  entirely and implement the `Etcher.Storage` behaviour against their own
  table.
  """

  use Mix.Task

  @impl Mix.Task
  def run(args) do
    {opts, _argv, _} =
      OptionParser.parse(args, switches: [repo: :string], aliases: [r: :repo])

    repo = resolve_repo(opts[:repo])
    migrations_dir = Path.join([source_repo_priv(repo), "migrations"])
    File.mkdir_p!(migrations_dir)

    timestamp = format_timestamp(NaiveDateTime.utc_now())
    name = "create_etcher_annotations"
    filename = "#{timestamp}_#{name}.exs"
    path = Path.join(migrations_dir, filename)

    if Enum.any?(File.ls!(migrations_dir), &String.ends_with?(&1, "_#{name}.exs")) do
      Mix.shell().info("[etcher] Skipping — migration already exists: #{name}")
    else
      module = Module.concat([app_module(repo), "Repo", "Migrations", Macro.camelize(name)])
      File.write!(path, migration_template(module))
      Mix.shell().info("[etcher] Created #{Path.relative_to_cwd(path)}")
    end
  end

  defp resolve_repo(nil) do
    case Mix.Project.config()[:app] |> Application.get_env(:ecto_repos) do
      [repo | _] -> repo
      _ -> Mix.raise("No Ecto repos configured. Pass --repo MyApp.Repo.")
    end
  end

  defp resolve_repo(name) when is_binary(name) do
    Module.concat([name])
  end

  defp source_repo_priv(repo) do
    config = repo.config()
    priv = config[:priv] || "priv/#{repo |> Module.split() |> List.last() |> Macro.underscore()}"
    Path.join(File.cwd!(), priv)
  rescue
    UndefinedFunctionError ->
      # Repo isn't loaded (e.g., generator run before deps compile). Fall
      # back to the conventional default.
      Path.join([File.cwd!(), "priv", "repo"])
  end

  defp app_module(repo) do
    repo |> Module.split() |> List.first()
  end

  defp format_timestamp(%NaiveDateTime{} = ts) do
    [ts.year, ts.month, ts.day, ts.hour, ts.minute, ts.second]
    |> Enum.map_join("", fn n -> n |> Integer.to_string() |> String.pad_leading(2, "0") end)
  end

  defp migration_template(module) do
    """
    defmodule #{inspect(module)} do
      use Ecto.Migration

      def up do
        execute(\"\"\"
        CREATE TABLE IF NOT EXISTS etcher_annotations (
          uuid uuid PRIMARY KEY,
          target_type varchar(64) NOT NULL,
          target_uuid uuid NOT NULL,
          creator_uuid uuid,
          kind varchar(32) NOT NULL,
          geometry jsonb NOT NULL,
          style jsonb,
          metadata jsonb,
          position integer NOT NULL DEFAULT 0,
          inserted_at timestamp(0) NOT NULL,
          updated_at timestamp(0) NOT NULL
        )
        \"\"\")

        execute(\"\"\"
        CREATE INDEX IF NOT EXISTS etcher_annotations_target_index
          ON etcher_annotations (target_type, target_uuid)
        \"\"\")
      end

      def down do
        execute("DROP INDEX IF EXISTS etcher_annotations_target_index")
        execute("DROP TABLE IF EXISTS etcher_annotations")
      end
    end
    """
  end
end