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