Skip to main content

lib/npm/lockfile/merge.ex

defmodule NPM.Lockfile.Merge do
  @moduledoc """
  Merge two lockfiles, preferring entries from the newer lockfile.

  Used for workspace scenarios where multiple `package.json` files
  produce separate lockfiles that need to be combined.
  """

  @doc """
  Merge two lockfiles. Entries in `newer` override entries in `base`.

  Returns the merged lockfile map.
  """
  @spec merge(NPM.Lockfile.t(), NPM.Lockfile.t()) :: NPM.Lockfile.t()
  def merge(base, newer) do
    Map.merge(base, newer)
  end

  @doc """
  Merge with a custom conflict resolver.

  The `resolver` function receives `(name, base_entry, newer_entry)`
  and returns the winning entry.
  """
  @spec merge(NPM.Lockfile.t(), NPM.Lockfile.t(), function()) :: NPM.Lockfile.t()
  def merge(base, newer, resolver) do
    Map.merge(base, newer, fn name, base_entry, newer_entry ->
      resolver.(name, base_entry, newer_entry)
    end)
  end

  @doc """
  Find packages that differ between two lockfiles.

  Returns `{added, removed, changed}` where:
  - `added` — packages in `newer` but not `base`
  - `removed` — packages in `base` but not `newer`
  - `changed` — packages in both with different versions
  """
  @spec diff(NPM.Lockfile.t(), NPM.Lockfile.t()) ::
          {[String.t()], [String.t()], [{String.t(), String.t(), String.t()}]}
  def diff(base, newer) do
    base_keys = MapSet.new(Map.keys(base))
    newer_keys = MapSet.new(Map.keys(newer))

    added = MapSet.difference(newer_keys, base_keys) |> MapSet.to_list() |> Enum.sort()
    removed = MapSet.difference(base_keys, newer_keys) |> MapSet.to_list() |> Enum.sort()

    changed =
      MapSet.intersection(base_keys, newer_keys)
      |> Enum.filter(fn name -> base[name].version != newer[name].version end)
      |> Enum.map(fn name -> {name, base[name].version, newer[name].version} end)
      |> Enum.sort()

    {added, removed, changed}
  end
end