lib/boundary/mix/tasks/visualize.ex

defmodule Mix.Tasks.Boundary.Visualize do
  @shortdoc "Generates a graphviz dot file for each non-empty boundary."
  @moduledoc "Generates a graphviz dot file for each non-empty boundary."

  use Boundary, classify_to: Boundary.Mix
  use Mix.Task

  alias Boundary.Graph

  @output_folder "boundary"

  @impl Mix.Task
  def run(_argv) do
    Mix.Task.run("compile")
    Boundary.Mix.load_app()

    File.mkdir(@output_folder)

    view = Boundary.Mix.View.build()

    view
    |> Boundary.all()
    |> Enum.group_by(&Boundary.parent(view, &1))
    |> Enum.each(fn {main_boundary, boundaries} ->
      nodes = build_nodes(view, main_boundary, boundaries)
      edges = build_edges(view, main_boundary, boundaries)
      title = title(main_boundary)
      graph = graph(main_boundary, title, nodes, edges)

      file_path = format_file_path(main_boundary)
      File.write!(file_path, graph)
    end)

    Mix.shell().info([:green, "Files successfully generated in the `#{@output_folder}` folder."])

    :ok
  end

  defp build_nodes(view, main_boundary, boundaries) do
    boundaries
    |> Stream.flat_map(&[&1.name | Enum.map(&1.deps, fn {name, _type} -> name end)])
    |> Stream.uniq()
    |> Stream.filter(&include?(view, main_boundary, Boundary.fetch!(view, &1)))
    |> Enum.sort()
  end

  defp build_edges(view, main_boundary, boundaries) do
    for %{name: name, deps: deps} <- boundaries,
        {dep_name, mode} <- deps,
        include?(view, main_boundary, Boundary.fetch!(view, dep_name)),
        do: {name, dep_name, mode}
  end

  defp include?(view, main_boundary, boundary),
    do: boundary.app == Boundary.Mix.app_name() and Boundary.parent(view, boundary) == main_boundary

  defp format_file_path(boundary) do
    name = if is_nil(boundary), do: "app", else: inspect(boundary.name)
    Path.join([File.cwd!(), @output_folder, "#{name}.dot"])
  end

  defp title(nil), do: "#{Boundary.Mix.app_name()} application"
  defp title(boundary), do: "#{inspect(boundary.name)} boundary"

  defp graph(main_boundary, title, nodes, edges) do
    Graph.new(title)
    |> add_nodes(main_boundary, nodes)
    |> add_edges(main_boundary, edges)
    |> Graph.dot()
  end

  defp add_nodes(graph, main_boundary, nodes),
    do: Enum.reduce(nodes, graph, &Graph.add_node(&2, node_name(main_boundary, &1)))

  defp add_edges(graph, main_boundary, edges) do
    Enum.reduce(edges, graph, fn {from, to, attributes}, graph ->
      Graph.add_dependency(
        graph,
        node_name(main_boundary, from),
        node_name(main_boundary, to),
        edge_attributes(attributes)
      )
    end)
  end

  defp node_name(nil, module), do: inspect(module)
  defp node_name(main_boundary, module), do: String.replace(inspect(module), ~r/^#{inspect(main_boundary.name)}\./, "")

  defp edge_attributes(:runtime), do: []
  defp edge_attributes(:compile), do: [label: "compile"]
end