lib/states_language/graph.ex

defmodule StatesLanguage.Graph do
  @moduledoc """
  Functions for creating a Graph structure from deserialized JSON. This is used by Serializers and the core library. See `StatesLanguage.Serializer.D3Graph`.
  """
  alias StatesLanguage.{Catch, Choice, Edge, Node}

  defstruct [:comment, :edges, :nodes, :start]

  @type t :: %__MODULE__{
          comment: String.t(),
          start: String.t(),
          nodes: %{required(String.t()) => Node.t()},
          edges: [Edge.t()]
        }

  @spec serialize(map()) :: t()
  def serialize(data) do
    {comment, start, nodes} = parse_data(data)
    edges = get_edges(nodes)
    %__MODULE__{comment: comment, edges: edges, nodes: nodes, start: start}
  end

  defp parse_data(data) do
    nodes =
      data
      |> Map.get("States")
      |> to_states()

    {
      Map.get(data, "Comment"),
      Map.get(data, "StartAt"),
      nodes
    }
  end

  defp to_states(%{} = states) do
    Enum.reduce(states, %{}, fn {k, v}, acc ->
      Map.put(acc, k, %Node{
        type: Map.get(v, "Type"),
        default: Map.get(v, "Default"),
        next: Map.get(v, "Next"),
        iterator: Map.get(v, "Iterator") |> encode_module_name(),
        items_path: Map.get(v, "ItemsPath"),
        branches: Map.get(v, "Branches", []) |> encode_module_name(),
        seconds: Map.get(v, "Seconds"),
        timestamp: Map.get(v, "Timestamp") |> get_timestamp(),
        seconds_path: Map.get(v, "SecondsPath"),
        timestamp_path: Map.get(v, "TimestampPath"),
        catch: Map.get(v, "Catch", []) |> parse_catches(),
        choices: Map.get(v, "Choices", []) |> parse_choices(),
        resource: Map.get(v, "Resource"),
        parameters: Map.get(v, "Parameters", %{}) |> Macro.escape(),
        input_path: Map.get(v, "InputPath", "$"),
        resource_path: Map.get(v, "ResourcePath", "$"),
        output_path: Map.get(v, "OutputPath", "$"),
        is_end: Map.get(v, "End", false),
        event: Map.get(v, "TransitionEvent", ":transition") |> escape_string()
      })
    end)
  end

  defp encode_module_name(list) when is_list(list) do
    Enum.map(list, &encode_module_name/1)
  end

  defp encode_module_name(mod) do
    Module.safe_concat([mod])
  end

  def get_edges(nodes) do
    nodes
    |> Enum.flat_map(fn {k, %Node{} = v} ->
      get_edges_for_type(k, v)
    end)
  end

  defp get_edges_for_type(
         name,
         %Node{type: "Choice", choices: choices, event: event, default: default}
       ) do
    choices = get_choices(name, choices)

    case default do
      nil -> choices
      default -> [add_edge(name, default, event) | choices]
    end
  end

  defp get_edges_for_type(
         name,
         %Node{next: next, event: event, catch: catches, is_end: false}
       ) do
    catches =
      Enum.flat_map(catches, fn %Catch{next: cat, error_equals: ee} ->
        Enum.map(ee, fn event ->
          add_edge(name, cat, event)
        end)
      end)

    [add_edge(name, next, event) | catches]
  end

  defp get_edges_for_type(_, %Node{is_end: true}), do: []

  defp get_choices(name, choices) do
    Enum.map(choices, fn %Choice{next: choice, string_equals: event} ->
      add_edge(name, choice, event)
    end)
  end

  defp add_edge(name, transition, event) do
    %Edge{source: name, target: transition, event: escape_string(event)}
  end

  defp parse_choices(choices) do
    Enum.map(choices, fn %{"StringEquals" => se, "Next" => next} ->
      %Choice{string_equals: escape_string(se), next: next}
    end)
  end

  defp parse_catches(catches) do
    Enum.map(catches, fn %{"ErrorEquals" => ee, "Next" => next} ->
      %Catch{error_equals: Enum.map(ee, &escape_string/1), next: next}
    end)
  end

  defp get_timestamp(nil), do: nil

  defp get_timestamp(data) do
    {:ok, dt, _off} = DateTime.from_iso8601(data)
    dt
  end

  def escape_string(string) when is_binary(string) do
    string
    |> Code.eval_string()
    |> elem(0)
    |> Macro.escape()
  end

  def escape_string(ast) when not is_binary(ast), do: ast
end