defmodule Mix.Tasks.Reactor.Mermaid do @shortdoc "Generate Mermaid diagrams from Reactor modules" @moduledoc """ #{@shortdoc} The `reactor.mermaid` mix task generates Mermaid flowchart diagrams from reactor modules, making it easy to visualize workflow structure and dependencies. ## Examples # Generate diagram for a single reactor $ mix reactor.mermaid MyApp.UserRegistrationReactor # Save to a specific file $ mix reactor.mermaid MyApp.UserRegistrationReactor --output user_flow.mmd # Include descriptions and expand sub-reactors $ mix reactor.mermaid MyApp.PaymentReactor --describe --expand # Generate from multiple reactors $ mix reactor.mermaid MyApp.Reactor1 MyApp.Reactor2 --output combined.mmd ## Options * `--output` (`-o`) - Output file path. If not specified, saves as `.mmd` * `--describe` (`-d`) - Include step descriptions in the diagram * `--expand` (`-e`) - Expand composed sub-reactors inline * `--direction` - Diagram direction: `top_to_bottom` (default), `left_to_right`, `bottom_to_top`, `right_to_left` * `--format` (`-f`) - Output format: `mermaid` (default), `copy`, `url` ## Output Formats * `mermaid` - Saves the Mermaid diagram source code to a file * `copy` - Displays the diagram source for copy-pasting into Mermaid Live Editor * `url` - Generates a direct Mermaid Live Editor URL ## Usage Tips View generated diagrams at: * https://mermaid.live/edit (paste diagram source) * VS Code with Mermaid extension * GitHub (native Mermaid support in markdown) """ use Mix.Task @switches [ output: :string, describe: :boolean, expand: :boolean, direction: :string, format: :string ] @aliases [ o: :output, d: :describe, e: :expand, f: :format ] @directions ~w(top_to_bottom left_to_right bottom_to_top right_to_left) @formats ~w(mermaid copy url) @doc false @impl true def run([]), do: Mix.Task.run("help", ["reactor.mermaid"]) def run(args) do {opts, reactors, _} = OptionParser.parse(args, switches: @switches, aliases: @aliases) with :ok <- validate_options(opts), {:ok, reactor_modules} <- validate_reactors(reactors), {:ok, options} <- build_mermaid_options(opts), {:ok, output_file} <- determine_output_file(opts, reactor_modules), {:ok, diagram} <- generate_diagram(reactor_modules, options), {:ok, result} <- save_output(diagram, output_file, opts[:format] || "mermaid") do case opts[:format] do "copy" -> Mix.shell().info("✅ Mermaid diagram for copy-paste:") Mix.shell().info(result) "url" -> Mix.shell().info("✅ Mermaid Live URL generated:") Mix.shell().info("🌐 #{result}") _ -> Mix.shell().info("✅ Mermaid diagram generated: #{result}") Mix.shell().info("📊 View at: https://mermaid.live/edit") end else {:error, message} -> Mix.shell().error("❌ #{message}") System.stop(1) end end defp validate_options(opts) do with :ok <- validate_direction(opts[:direction]) do validate_format(opts[:format]) end end defp validate_direction(nil), do: :ok defp validate_direction(direction) when direction in @directions, do: :ok defp validate_direction(direction) do {:error, "Invalid direction '#{direction}'. Must be one of: #{Enum.join(@directions, ", ")}"} end defp validate_format(nil), do: :ok defp validate_format(format) when format in @formats, do: :ok defp validate_format(format) do {:error, "Invalid format '#{format}'. Must be one of: #{Enum.join(@formats, ", ")}"} end defp validate_reactors([]) do {:error, "Please specify at least one reactor module"} end defp validate_reactors(reactors) do reactors |> Enum.reduce_while({:ok, []}, fn reactor_name, {:ok, acc} -> case validate_reactor(reactor_name) do {:ok, reactor} -> {:cont, {:ok, [reactor | acc]}} {:error, _} = error -> {:halt, error} end end) |> case do {:ok, reactors} -> {:ok, Enum.reverse(reactors)} error -> error end end defp validate_reactor(reactor_name) do with {:ok, reactor} <- try_load_module(reactor_name), :ok <- reactor?(reactor) do {:ok, reactor} end end defp try_load_module(module) do module = Module.concat([module]) case Code.ensure_loaded(module) do {:module, module} -> {:ok, module} {:error, reason} -> {:error, "Unable to load reactor '#{inspect(module)}': #{inspect(reason)}"} end end defp reactor?(module) do if function_exported?(module, :spark_is, 0) && module.spark_is() == Reactor do :ok else {:error, "Module '#{inspect(module)}' is not a Reactor module"} end end defp build_mermaid_options(opts) do options = [ describe?: opts[:describe] || false, expand?: opts[:expand] || false ] options = case opts[:direction] do nil -> options direction -> Keyword.put(options, :direction, String.to_atom(direction)) end {:ok, options} end defp determine_output_file(opts, reactors) do case opts[:output] do nil when length(reactors) == 1 -> [reactor] = reactors name = reactor |> Module.split() |> List.last() |> Macro.underscore() {:ok, "#{name}.mmd"} nil -> {:ok, "reactors.mmd"} output_file -> {:ok, output_file} end end defp generate_diagram([reactor], options) do Reactor.Mermaid.to_mermaid(reactor, options) end defp generate_diagram(reactors, options) when length(reactors) > 1 do diagrams = Enum.map(reactors, fn reactor -> case Reactor.Mermaid.to_mermaid(reactor, options) do {:ok, diagram} -> name = reactor |> Module.split() |> List.last() "subgraph #{name}\n#{indent_diagram(diagram)}\nend" {:error, _} = error -> throw(error) end end) diagram = """ flowchart TD #{Enum.join(diagrams, "\n\n")} """ {:ok, diagram} catch {:error, _} = error -> error end defp indent_diagram(diagram) do diagram |> String.split("\n") # Remove the "flowchart TD" line |> Enum.drop(1) |> Enum.map_join("\n", &(" " <> &1)) end defp save_output(diagram, output_file, "mermaid") do case File.write(output_file, to_string(diagram)) do :ok -> {:ok, output_file} error -> error end end defp save_output(diagram, _output_file, "copy") do # For copy format, return instructions to copy-paste diagram_string = to_string(diagram) message = """ Copy the following Mermaid diagram and paste it at https://mermaid.live/edit: #{diagram_string} """ {:ok, message} end defp save_output(diagram, _output_file, "url") do # Generate Mermaid Live Editor URL with proper JSON state diagram_string = to_string(diagram) state = %{ "code" => diagram_string, "mermaid" => %{ "theme" => "default" }, "autoSync" => true, "rough" => false } json_state = Jason.encode!(state) encoded = json_state |> Base.encode64() url = "https://mermaid.live/edit#base64:#{encoded}" {:ok, url} end end