Skip to main content

lib/npm/package/manifest/diff.ex

defmodule NPM.Package.Manifest.Diff do
  @moduledoc """
  Diffs two package.json manifests to determine what changed.
  """

  @dep_fields ~w(dependencies devDependencies peerDependencies optionalDependencies)

  @doc """
  Computes a diff between two package.json maps.
  """
  @spec diff(map(), map()) :: map()
  def diff(old, new) do
    %{
      name_changed: old["name"] != new["name"],
      version_changed: version_change(old["version"], new["version"]),
      deps: diff_all_deps(old, new),
      scripts: diff_map(old["scripts"] || %{}, new["scripts"] || %{}),
      fields: diff_top_level(old, new)
    }
  end

  @doc """
  Diffs a specific dependency group.
  """
  @spec diff_deps(map(), map()) :: map()
  def diff_deps(old_deps, new_deps) when is_map(old_deps) and is_map(new_deps) do
    diff_map(old_deps, new_deps)
  end

  @doc """
  Checks if two manifests are equivalent.
  """
  @spec equivalent?(map(), map()) :: boolean()
  def equivalent?(old, new) do
    d = diff(old, new)

    not d.name_changed and d.version_changed == nil and
      empty_diff?(d.deps) and empty_diff?(d.scripts)
  end

  @doc """
  Formats diff for display.
  """
  @spec format(map()) :: String.t()
  def format(diff_result) do
    parts = []
    parts = if diff_result.name_changed, do: ["Name changed" | parts], else: parts

    parts =
      if diff_result.version_changed,
        do: ["Version: #{format_change(diff_result.version_changed)}" | parts],
        else: parts

    parts = format_section(parts, "Dependencies", diff_result.deps)
    parts = format_section(parts, "Scripts", diff_result.scripts)

    case Enum.reverse(parts) do
      [] -> "No changes."
      list -> Enum.join(list, "\n")
    end
  end

  defp diff_all_deps(old, new) do
    @dep_fields
    |> Enum.map(fn field -> {field, diff_map(old[field] || %{}, new[field] || %{})} end)
    |> Enum.reject(fn {_, d} -> empty_diff?(d) end)
    |> Map.new()
  end

  defp diff_map(old, new) do
    old_keys = Map.keys(old) |> MapSet.new()
    new_keys = Map.keys(new) |> MapSet.new()

    %{
      added: MapSet.difference(new_keys, old_keys) |> MapSet.to_list() |> Enum.sort(),
      removed: MapSet.difference(old_keys, new_keys) |> MapSet.to_list() |> Enum.sort(),
      changed:
        MapSet.intersection(old_keys, new_keys)
        |> Enum.filter(fn k -> old[k] != new[k] end)
        |> Enum.map(fn k -> {k, old[k], new[k]} end)
        |> Enum.sort_by(&elem(&1, 0))
    }
  end

  defp empty_diff?(%{added: [], removed: [], changed: []}), do: true
  defp empty_diff?(map) when is_map(map), do: map_size(map) == 0
  defp empty_diff?(_), do: true

  defp version_change(same, same), do: nil
  defp version_change(old, new), do: {old, new}

  defp format_change({old, new}), do: "#{old}#{new}"

  defp format_section(parts, label, diff) do
    if empty_diff?(diff), do: parts, else: ["#{label} changed" | parts]
  end

  defp diff_top_level(old, new) do
    skip = MapSet.new(@dep_fields ++ ~w(name version scripts))
    old_keys = old |> Map.keys() |> Enum.reject(&MapSet.member?(skip, &1)) |> MapSet.new()
    new_keys = new |> Map.keys() |> Enum.reject(&MapSet.member?(skip, &1)) |> MapSet.new()

    %{
      added: MapSet.difference(new_keys, old_keys) |> MapSet.to_list() |> Enum.sort(),
      removed: MapSet.difference(old_keys, new_keys) |> MapSet.to_list() |> Enum.sort()
    }
  end
end