lib/archeometer/analysis/dsm/console_render.ex

defmodule Archeometer.Analysis.DSM.ConsoleRender do
  @moduledoc """
  Functions to render an `Archeometer.Analysis.DSM` struct into plain text.
  """

  alias Archeometer.Analysis.DSM

  @v_sep "|"
  @h_sep "-"
  @ln_sep "\n"
  @space " "
  @left_margin 1

  @doc """
  Renders a `DSM` struct into a plain text, suitable for printing in a console.

  Returns the string representing the `DSM` in human readable format.

  ## Parameters

  - `dsm`. The `DSM` to be rendered.
  - `mod_names`. A map from module ids to their corresponding names.

  """
  def render(mtx, mod_names) do
    mtx_str(mtx) <>
      @ln_sep <>
      modules_str(mtx.nodes, mod_names) <>
      @ln_sep <>
      dependencies(mtx.groups, mod_names)
  end

  def mtx_str(mtx) do
    hd_str = "Design Structure Matrix (DSM):" <> @ln_sep <> @ln_sep
    mtx_str = to_string(mtx)
    hd_str <> mtx_str <> @ln_sep
  end

  def mtx_header(%DSM{nodes: nodes}, padding) do
    ln_prefix = String.duplicate(@space, padding + @left_margin) <> @v_sep

    lines =
      nodes
      |> Stream.map(fn n ->
        n |> to_string() |> String.pad_leading(padding) |> String.graphemes()
      end)
      # No Enum.zip_with/2 in Elixir 1.10
      |> Stream.zip()
      |> Stream.map(fn t -> ln_prefix <> (t |> Tuple.to_list() |> to_string()) end)

    line_size = length(nodes) + padding + 1 + @left_margin
    header_sep = String.duplicate(@h_sep, line_size)

    Enum.join(lines, @ln_sep) <> @ln_sep <> header_sep
  end

  def mtx_rows(%DSM{nodes: nodes, edges: edges}, padding) do
    Enum.map_join(nodes, @ln_sep, fn n ->
      prefix = (n |> to_string() |> String.pad_leading(padding + @left_margin)) <> @v_sep
      # This is sensible to the DSM edges representation
      xrefs = Enum.map_join(nodes, fn c -> Map.get(edges, {n, c}, @space) |> to_string() end)
      prefix <> xrefs
    end)
  end

  defp modules_str(nodes, mod_names) do
    hd_str = "Modules (#{length(nodes)}):" <> @ln_sep <> @ln_sep
    node_str = nodes |> modules_detail_str(mod_names)
    hd_str <> node_str <> @ln_sep
  end

  defp modules_detail_str(nodes, mod_names) do
    padding = padding(nodes) + 1
    Enum.map_join(nodes, @ln_sep, fn m -> module_str(m, mod_names, padding) end)
  end

  defp module_str(m, mod_names, padding) do
    (m |> to_string() |> String.pad_leading(padding)) <> @v_sep <> Map.get(mod_names, m)
  end

  defp dependencies(groups, mod_names) do
    cycles_str(groups, mod_names)
  end

  def cycles_str(groups, mod_names) do
    case map_size(groups) do
      0 ->
        "No cycles!" <> @ln_sep

      _ ->
        n_groups = groups |> Map.keys() |> length()
        hd_str = "Cycles (#{n_groups}):" <> @ln_sep <> @ln_sep
        groups_str = cycles_detail_str(groups, mod_names)
        hd_str <> groups_str <> @ln_sep
    end
  end

  defp cycles_detail_str(groups, mod_names) do
    groups
    |> Map.values()
    |> Enum.map(fn modules ->
      Enum.map_join(modules, ", ", fn m -> Map.get(mod_names, m) end)
    end)
    |> Enum.with_index()
    |> Enum.map_join(@ln_sep, fn {k, v} -> "Group #{v + 1}: " <> k end)
  end

  def padding(nodes), do: Enum.max(nodes) |> to_string() |> String.length()

  def ln_sep(), do: @ln_sep

  defimpl String.Chars, for: DSM do
    alias Archeometer.Analysis.DSM.ConsoleRender, as: Render

    def to_string(mtx) do
      padding = Render.padding(mtx.nodes)
      header_str = Render.mtx_header(mtx, padding)
      rows_str = Render.mtx_rows(mtx, padding)
      header_str <> Render.ln_sep() <> rows_str
    end
  end
end