Skip to main content

lib/crosswake/manifest/serializer.ex

defmodule Crosswake.Manifest.Serializer do
  @moduledoc """
  Deterministic JSON renderer and idempotent writer for the canonical manifest artifact.
  """

  alias Crosswake.Manifest.Types

  @type action :: :created | :reused | :updated

  @spec render(Types.Root.t()) :: String.t()
  def render(%Types.Root{} = manifest) do
    manifest
    |> Types.to_map()
    |> ordered()
    |> Jason.encode_to_iodata!(pretty: true)
    |> IO.iodata_to_binary()
    |> Kernel.<>("\n")
  end

  @spec write(String.t(), Types.Root.t()) :: {:ok, action()}
  def write(path, %Types.Root{} = manifest) do
    File.mkdir_p!(Path.dirname(path))

    contents = render(manifest)

    case File.read(path) do
      {:ok, ^contents} ->
        {:ok, :reused}

      {:ok, _previous} ->
        File.write!(path, contents)
        {:ok, :updated}

      {:error, :enoent} ->
        File.write!(path, contents)
        {:ok, :created}

      {:error, reason} ->
        raise "could not persist Crosswake manifest #{path}: #{:file.format_error(reason)}"
    end
  end

  defp ordered(map) when is_map(map) do
    values =
      map
      |> Enum.sort_by(fn {key, _value} -> key end)
      |> Enum.map(fn {key, value} -> {key, ordered(value)} end)

    %Jason.OrderedObject{values: values}
  end

  defp ordered(list) when is_list(list), do: Enum.map(list, &ordered/1)
  defp ordered(value), do: value
end