defmodule NPM.SnapshotDiff do
@moduledoc """
Compares two lockfile snapshots to determine what changed.
"""
@doc """
Computes the diff between two lockfiles.
"""
@spec diff(map(), map()) :: map()
def diff(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()
updated =
MapSet.intersection(old_keys, new_keys)
|> Enum.filter(fn name ->
version(old[name]) != version(new[name])
end)
|> Enum.map(fn name ->
%{name: name, from: version(old[name]), to: version(new[name])}
end)
|> Enum.sort_by(& &1.name)
%{added: added, removed: removed, updated: updated, unchanged: unchanged_count(old, new)}
end
@doc """
Checks if two lockfiles are identical.
"""
@spec identical?(map(), map()) :: boolean()
def identical?(old, new) do
d = diff(old, new)
d.added == [] and d.removed == [] and d.updated == []
end
@doc """
Returns a summary of changes.
"""
@spec summary(map()) :: String.t()
def summary(diff_result) do
parts = []
parts =
if diff_result.added != [], do: ["#{length(diff_result.added)} added" | parts], else: parts
parts =
if diff_result.removed != [],
do: ["#{length(diff_result.removed)} removed" | parts],
else: parts
parts =
if diff_result.updated != [],
do: ["#{length(diff_result.updated)} updated" | parts],
else: parts
case Enum.reverse(parts) do
[] -> "No changes."
list -> Enum.join(list, ", ")
end
end
@doc """
Formats a detailed diff for display.
"""
@spec format(map()) :: String.t()
def format(diff_result) do
lines = []
lines =
diff_result.added
|> Enum.reduce(lines, fn name, acc -> ["+ #{name}" | acc] end)
lines =
diff_result.removed
|> Enum.reduce(lines, fn name, acc -> ["- #{name}" | acc] end)
lines =
diff_result.updated
|> Enum.reduce(lines, fn %{name: n, from: f, to: t}, acc ->
["~ #{n}: #{f} → #{t}" | acc]
end)
case Enum.reverse(lines) do
[] -> "No changes."
list -> Enum.join(list, "\n")
end
end
defp version(%{version: v}), do: v
defp version(%{"version" => v}), do: v
defp version(_), do: nil
defp unchanged_count(old, new) do
old_keys = Map.keys(old) |> MapSet.new()
new_keys = Map.keys(new) |> MapSet.new()
MapSet.intersection(old_keys, new_keys)
|> Enum.count(fn name -> version(old[name]) == version(new[name]) end)
end
end