Skip to main content

lib/mix/tasks/bloccs.gen.node.ex

defmodule Mix.Tasks.Bloccs.Gen.Node do
  @shortdoc "Generate a node (manifest + implementation) in an existing app"

  @moduledoc """
      mix bloccs.gen.node <name> [--kind <kind>] [--module <Base>]

  Adds one node to the **current** Mix project: a manifest at
  `nodes/<name>.bloccs` and its implementation at `lib/nodes/<name>.ex` (a pure
  core and an effect shell). Unlike `mix bloccs.new`, which scaffolds a whole new
  project, this drops a node into an app you already have.

  ## Options

    * `--kind` — `transform` (default), `source`, `sink`, or `router`. Determines
      the port skeleton and the effect-shell return:
      transform/source/router emit, a sink consumes (`:drop`).
    * `--module` — the base module for the generated node
      (`<Base>.Nodes.<Name>`). Defaults to the current app's camelized name.

  ## Examples

      mix bloccs.gen.node enrich
      mix bloccs.gen.node ingest --kind source
      mix bloccs.gen.node route --kind router --module MyApp

  After generating, register the port schemas the node references (see your
  app's schema-registration module) and wire it into a network — write one by
  hand or run `mix bloccs.gen.network`.
  """

  use Mix.Task

  @kinds ~w(transform source sink router)

  @impl Mix.Task
  def run(args) do
    {opts, argv} = OptionParser.parse!(args, strict: [kind: :string, module: :string])

    name =
      case argv do
        [name | _] -> name
        [] -> Mix.raise("usage: mix bloccs.gen.node <name> [--kind <kind>] [--module <Base>]")
      end

    kind = opts[:kind] || "transform"

    unless kind in @kinds do
      Mix.raise("unknown --kind #{inspect(kind)}; one of: #{Enum.join(@kinds, ", ")}")
    end

    slug = Macro.underscore(name)
    node_mod = "#{base_module(opts)}.Nodes.#{Macro.camelize(slug)}"

    manifest_path = Path.join("nodes", "#{slug}.bloccs")
    impl_path = Path.join(["lib", "nodes", "#{slug}.ex"])

    for path <- [manifest_path, impl_path], File.exists?(path) do
      Mix.raise("path #{path} already exists — refusing to overwrite")
    end

    File.mkdir_p!("nodes")
    File.mkdir_p!(Path.join("lib", "nodes"))
    File.write!(manifest_path, manifest(slug, kind, node_mod))
    File.write!(impl_path, impl(slug, kind, node_mod))

    Mix.shell().info([
      :green,
      "✓ ",
      :reset,
      "generated node #{slug} (#{kind})",
      "\n  ",
      :cyan,
      manifest_path,
      :reset,
      " — manifest (ports, effects, contract)",
      "\n  ",
      :cyan,
      impl_path,
      :reset,
      " — #{node_mod} (pure core + effect shell)",
      "\n\nNext:",
      "\n  • register the port schemas it references (e.g. Input@1, Output@1)",
      "\n  • wire it into a network — by hand or `mix bloccs.gen.network`",
      "\n  • `mix bloccs.validate nodes/#{slug}.bloccs`"
    ])
  end

  defp base_module(opts) do
    case opts[:module] do
      nil ->
        case Mix.Project.config()[:app] do
          nil ->
            Mix.raise("not inside a Mix project — pass --module <Base>")

          app ->
            Macro.camelize(to_string(app))
        end

      mod ->
        mod
    end
  end

  # ── Manifest ────────────────────────────────────────────────────────────

  defp manifest(slug, kind, node_mod) do
    """
    [node]
    id      = "#{slug}"
    version = "0.1.0"
    kind    = "#{kind}"

    [doc]
    intent = "TODO: what this node is for."
    #{ports(kind)}
    # Declare only the capabilities this node needs; an undeclared axis is denied.
    # http = { allow = ["api.example.com"], methods = ["GET"] }
    [effects]

    [contract]
    pure_core    = "#{node_mod}.transform/2"
    effect_shell = "#{node_mod}.execute/2"
    """
  end

  # Every node declares both port sections (a source's in-port is its intake, a
  # sink's out-port section stays empty since it emits nothing). The parser
  # requires both headers to be present.
  defp ports("sink") do
    """

    [ports.in]
    input = { schema = "Input@1" }

    [ports.out]
    """
  end

  defp ports("router") do
    """

    [ports.in]
    input = { schema = "Input@1" }

    [ports.out]
    primary   = { schema = "Output@1" }
    secondary = { schema = "Output@1" }
    """
  end

  defp ports(_transform) do
    """

    [ports.in]
    input = { schema = "Input@1" }

    [ports.out]
    output = { schema = "Output@1" }
    """
  end

  # ── Implementation ──────────────────────────────────────────────────────

  defp impl(slug, kind, node_mod) do
    """
    defmodule #{node_mod} do
      @moduledoc "TODO: describe this node."

      use Bloccs.Node, manifest: "../../nodes/#{slug}.bloccs"

    #{body(kind)}
    end
    """
  end

  # transform / source: compute in the pure core, emit on the out-port.
  defp body(kind) when kind in ["transform", "source"] do
    """
      # Pure core: no IO, no clock, no randomness — deterministic and easy to test.
      @spec transform(map(), Bloccs.Context.t()) :: {:ok, map()} | {:error, term()}
      def transform(payload, _ctx), do: {:ok, payload}

      # Effect shell: the only place allowed to touch the world, and only through
      # declared effects. Emits the result on the `output` out-port.
      @spec execute(map(), Bloccs.Context.t()) :: {:emit, atom(), map()} | {:error, term()}
      def execute(payload, _ctx), do: {:emit, :output, payload}\
    """
  end

  # router: the pure core decides; the shell emits on the chosen out-port.
  defp body("router") do
    """
      # Pure core: decide where this message goes. Touches nothing.
      @spec transform(map(), Bloccs.Context.t()) :: {:ok, map()} | {:error, term()}
      def transform(payload, _ctx), do: {:ok, payload}

      # Effect shell: emit on one of the declared out-ports.
      @spec execute(map(), Bloccs.Context.t()) :: {:emit, atom(), map()} | {:error, term()}
      def execute(payload, _ctx) do
        # TODO: branch on the payload to pick a port.
        {:emit, :primary, payload}
      end\
    """
  end

  # sink: terminal — consume the message and emit nothing (`:drop`).
  defp body("sink") do
    """
      # Pure core: prepare the value to be written out.
      @spec transform(map(), Bloccs.Context.t()) :: {:ok, map()} | {:error, term()}
      def transform(payload, _ctx), do: {:ok, payload}

      # Effect shell: write to the outside world through a declared effect, then
      # `:drop` — a sink is terminal and emits nothing downstream.
      @spec execute(map(), Bloccs.Context.t()) :: :drop | {:error, term()}
      def execute(_payload, _ctx), do: :drop\
    """
  end
end