defmodule Mix.Tasks.NebulaGraphEx.Gen.Graph do @shortdoc "Generates a NebulaGraphEx graph module for your application" @moduledoc """ The recommended way to set up `nebula_graph_ex` in your application. Generates a `NebulaGraphEx.Graph` module scoped to your OTP app, adds a default config block to `config/config.exs`, and registers the module in your application's supervision tree. ## Usage mix nebula_graph_ex.gen.graph MyApp.Graph The module name becomes both the Elixir module and the registered connection pool name. Running the generator: 1. Creates `lib/my_app/graph.ex` — the graph module 2. Inserts a default config block into `config/config.exs` 3. Injects the module as the first child in `lib/my_app/application.ex` 4. Prints a `config/runtime.exs` snippet for secrets All three file modifications are idempotent — re-running the generator on an already-configured app skips each step that is already in place. The file path for the graph module is derived automatically from the module name. Use `--path` to override it if your project layout differs from the standard convention: mix nebula_graph_ex.gen.graph MyApp.Graph --path lib/my_app/connections/graph.ex ## Multiple connections Run the generator once per graph module: mix nebula_graph_ex.gen.graph MyApp.PrimaryGraph mix nebula_graph_ex.gen.graph MyApp.AnalyticsGraph Each module manages its own connection pool and reads its config from `Application.get_env(:my_app, MyApp.PrimaryGraph)` independently. """ use Mix.Task @switches [path: :string] @impl Mix.Task def run(args) do {opts, positional, _invalid} = OptionParser.parse(args, strict: @switches) module_name = case positional do [name | _] -> name [] -> Mix.raise("Expected a module name. Usage: mix nebula_graph_ex.gen.graph MyApp.Graph") end otp_app = Mix.Project.config()[:app] file_path = Keyword.get_lazy(opts, :path, fn -> module_to_path(module_name) end) generate_module(module_name, otp_app, file_path) inject_config(module_name, otp_app) inject_supervision(module_name, otp_app) print_runtime_instructions(module_name, otp_app) end # ─── File path derivation ────────────────────────────────────────────────── defp module_to_path(module_name) do parts = module_name |> String.split(".") |> Enum.map(&Macro.underscore/1) "lib/#{Enum.join(parts, "/")}.ex" end # ─── Graph module ────────────────────────────────────────────────────────── defp generate_module(module_name, otp_app, file_path) do dir = Path.dirname(file_path) unless File.exists?(dir) do File.mkdir_p!(dir) Mix.shell().info([:green, "* creating ", :reset, dir <> "/"]) end if File.exists?(file_path) do Mix.shell().info([:yellow, "* skipping ", :reset, file_path, " (already exists)"]) else File.write!(file_path, module_content(module_name, otp_app)) Mix.shell().info([:green, "* creating ", :reset, file_path]) end end defp module_content(module_name, otp_app) do """ defmodule #{module_name} do use NebulaGraphEx.Graph, otp_app: :#{otp_app} end """ end # ─── config/config.exs ──────────────────────────────────────────────────── defp inject_config(module_name, otp_app) do path = "config/config.exs" marker = "config :#{otp_app}, #{module_name}" cond do not File.exists?(path) -> Mix.shell().info([:yellow, "* skipping ", :reset, path, " (file not found)"]) File.read!(path) |> String.contains?(marker) -> Mix.shell().info([:yellow, "* skipping ", :reset, path, " (config already present)"]) true -> snippet = """ config :#{otp_app}, #{module_name}, hostname: "localhost", port: 9669, username: "root", pool_size: 10 """ updated = insert_config_snippet(File.read!(path), snippet) File.write!(path, updated) Mix.shell().info([:green, "* updating ", :reset, path]) end end defp insert_config_snippet(content, snippet) do case String.split(content, "\n", trim: false) do [] -> String.trim_trailing(snippet) <> "\n" lines -> case import_config_index(lines) do nil -> String.trim_trailing(content) <> "\n" <> String.trim_trailing(snippet) <> "\n" index -> insert_at = comment_block_start(lines, index) {head, tail} = Enum.split(lines, insert_at) Enum.join( head ++ [String.trim_trailing(snippet), ""] ++ tail, "\n" ) end end end defp import_config_index(lines) do Enum.find_index(lines, &String.match?(&1, ~r/^\s*import_config\s+.+$/)) end defp comment_block_start(lines, import_index) do lines |> Enum.take(import_index) |> Enum.with_index() |> Enum.reverse() |> Enum.reduce_while(import_index, fn {line, index}, acc -> cond do String.trim(line) == "" -> {:cont, index} String.match?(line, ~r/^\s*#/) -> {:cont, index} true -> {:halt, acc} end end) end # ─── lib//application.ex ───────────────────────────────────────── defp inject_supervision(module_name, otp_app) do path = "lib/#{otp_app}/application.ex" cond do not File.exists?(path) -> Mix.shell().info([:yellow, "* skipping ", :reset, path, " (file not found)"]) File.read!(path) |> String.contains?(module_name) -> Mix.shell().info([ :yellow, "* skipping ", :reset, path, " (#{module_name} already present)" ]) true -> content = File.read!(path) case insert_child(content, module_name) do {:ok, updated} -> File.write!(path, updated) Mix.shell().info([:green, "* updating ", :reset, path]) :error -> Mix.shell().info([ :yellow, "* skipping ", :reset, path, " (could not locate children list — add #{module_name} manually)" ]) end end end # Injects module_name as the first entry in the `children = [...]` list. # Handles both inline (`children = []`) and multiline (`children = [\n`) forms. defp insert_child(content, module_name) do lines = String.split(content, "\n") {lines_out, done} = Enum.flat_map_reduce(lines, false, fn line, done -> if done do {[line], true} else case Regex.run(~r/^(\s*).*children\s*=\s*\[/, line) do [_, indent] -> child_indent = indent <> " " if String.contains?(line, "]") do # Inline empty list: children = [] new_line = String.replace(line, "[]", "[\n#{child_indent}#{module_name}\n#{indent}]") {[new_line], true} else # Multiline: children = [ {[line, "#{child_indent}#{module_name},"], true} end nil -> {[line], false} end end end) if done, do: {:ok, Enum.join(lines_out, "\n")}, else: :error end # ─── Runtime secrets reminder ────────────────────────────────────────────── defp print_runtime_instructions(module_name, otp_app) do Mix.shell().info(""" To configure secrets at runtime, add the following to config/runtime.exs: import Config config :#{otp_app}, #{module_name}, hostname: System.get_env("NEBULA_HOST", "localhost"), password: fn -> System.fetch_env!("NEBULA_PASS") end Passing :password as a function ensures it is never captured in compiled modules or logged in crash reports. """) end end