lib/mix/tasks/phoenix_micro.gen.saga.ex

defmodule Mix.Tasks.PhoenixMicro.Gen.Saga do
  use Mix.Task

  @shortdoc "Generates a PhoenixMicro saga module"

  @moduledoc """
  Generates a PhoenixMicro saga module with stub steps and compensations.

  ## Usage

      mix phoenix_micro.gen.saga MyApp.PlaceOrderSaga reserve_inventory charge_payment

  ## Options

      --no-compensate   Generate steps without compensation stubs

  ## Example

      mix phoenix_micro.gen.saga MyApp.Checkout.PlaceOrderSaga \\
        reserve_inventory \\
        charge_payment \\
        create_shipment \\
        notify_customer

  Generates `lib/my_app/checkout/place_order_saga.ex` and a matching test file.
  """

  @switches [no_compensate: :boolean]

  @spec run([String.t()]) :: any()
  @impl Mix.Task
  def run(argv) do
    {opts, args, _bad_opts} = OptionParser.parse(argv, switches: @switches)

    case args do
      [module_name | [_first | _rest] = steps] ->
        generate(module_name, steps, opts)

      [_module_name] ->
        IO.puts(
          :stderr,
          "Provide at least one step name.\n\n" <>
            "Usage: mix phoenix_micro.gen.saga <Module> <step1> [step2 ...]"
        )

        exit({:shutdown, 1})

      [] ->
        IO.puts(:stderr, "Usage: mix phoenix_micro.gen.saga <Module> <step1> [step2 ...]")
        exit({:shutdown, 1})
    end
  end

  # ---------------------------------------------------------------------------
  # Private
  # ---------------------------------------------------------------------------

  defp generate(module_name, steps, opts) do
    no_compensate = Keyword.get(opts, :no_compensate, false)
    file_path = module_to_path(module_name)
    test_path = module_to_test_path(module_name)

    create_file(file_path, render_saga(module_name, steps, no_compensate))
    create_file(test_path, render_saga_test(module_name, steps))

    IO.puts("""

    Saga generated successfully!

    Files created:
      #{file_path}
      #{test_path}

    Run the saga:

      {:ok, ctx} = PhoenixMicro.Saga.run(#{module_name}, %{your: "context"})
    """)
  end

  defp render_saga(module_name, steps, no_compensate) do
    step_blocks =
      Enum.map_join(steps, "\n\n", fn step_name ->
        build_step_block(step_name, no_compensate)
      end)

    steps_list = Enum.map_join(steps, "\n", fn s -> "    #{s}" end)

    IO.iodata_to_binary([
      "defmodule ",
      module_name,
      " do\n",
      "  @moduledoc \"\"\"\n",
      "  Saga: ",
      module_name,
      "\n\n",
      "  Generated by `mix phoenix_micro.gen.saga`.\n\n",
      "  Steps (in order):\n",
      steps_list,
      "\n\n",
      "  If any step fails, completed steps are compensated in reverse order.\n",
      "  \"\"\"\n\n",
      "  use PhoenixMicro.Saga\n\n",
      step_blocks,
      "\n",
      "end\n"
    ])
  end

  defp build_step_block(step_name, no_compensate) do
    compensate_part =
      if no_compensate do
        ""
      else
        IO.iodata_to_binary([
          ",\n",
          "    compensate: fn context ->\n",
          "      # TODO: undo ",
          step_name,
          "\n",
          "      _ = context\n",
          "      :ok\n",
          "    end"
        ])
      end

    IO.iodata_to_binary([
      "  step :",
      step_name,
      ",\n",
      "    execute: fn context ->\n",
      "      # TODO: implement ",
      step_name,
      "\n",
      "      # Return {:ok, updated_context} or {:error, reason}\n",
      "      _ = context\n",
      "      {:ok, context}\n",
      "    end",
      compensate_part,
      ",\n",
      "    timeout: 30_000\n"
    ])
  end

  defp render_saga_test(module_name, steps) do
    step_atoms = Enum.map_join(steps, ", ", fn s -> ":" <> s end)

    IO.iodata_to_binary([
      "defmodule ",
      module_name,
      "Test do\n",
      "  use ExUnit.Case, async: false\n\n",
      "  alias PhoenixMicro.Saga\n\n",
      "  @saga ",
      module_name,
      "\n\n",
      "  setup do\n",
      "    start_supervised!({Registry, keys: :unique, name: PhoenixMicro.Registry})\n",
      "    start_supervised!(PhoenixMicro.Saga.Supervisor)\n",
      "    :ok\n",
      "  end\n\n",
      "  describe \"__saga_steps__/0\" do\n",
      "    test \"defines expected steps in order\" do\n",
      "      steps = @saga.__saga_steps__()\n",
      "      names = Enum.map(steps, & &1.name)\n",
      "      assert names == [",
      step_atoms,
      "]\n",
      "    end\n",
      "  end\n\n",
      "  describe \"Saga.run/3\" do\n",
      "    test \"completes successfully with stub implementations\" do\n",
      "      result =\n",
      "        Task.async(fn -> Saga.run(@saga, %{}, timeout: 10_000) end)\n",
      "        |> Task.await(15_000)\n\n",
      "      assert {:ok, _context} = result\n",
      "    end\n",
      "  end\n",
      "end\n"
    ])
  end

  defp module_to_path(module_name) do
    path =
      module_name
      |> String.replace(".", "/")
      |> Macro.underscore()

    "lib/#{path}.ex"
  end

  defp module_to_test_path(module_name) do
    path =
      module_name
      |> String.replace(".", "/")
      |> Macro.underscore()

    "test/#{path}_test.exs"
  end

  defp create_file(path, content) do
    File.mkdir_p!(Path.dirname(path))

    if File.exists?(path) do
      if String.downcase(IO.gets("#{path} already exists. Overwrite? [y/N] ")) =~ ~r/^y/i do
        File.write!(path, content)
        IO.puts("  * overwrite #{path}")
      end
    else
      File.write!(path, content)
      IO.puts("  * create #{path}")
    end
  end
end