Skip to main content

lib/npm/dependency/conflict.ex

defmodule NPM.Dependency.Conflict do
  @moduledoc """
  Detects version conflicts between dependency groups.

  Finds packages that appear in multiple groups (dependencies,
  devDependencies, etc.) with different version ranges.
  """

  @dep_fields ~w(dependencies devDependencies peerDependencies optionalDependencies)

  @doc """
  Finds packages with conflicting ranges across dependency groups.
  """
  @spec find(map()) :: [map()]
  def find(pkg_data) do
    all_entries = collect_entries(pkg_data)

    all_entries
    |> Enum.group_by(& &1.name)
    |> Enum.flat_map(fn {name, entries} ->
      ranges = entries |> Enum.map(& &1.range) |> Enum.uniq()

      if length(ranges) > 1 do
        [%{name: name, entries: entries, ranges: ranges}]
      else
        []
      end
    end)
    |> Enum.sort_by(& &1.name)
  end

  @doc """
  Checks if there are any conflicts.
  """
  @spec conflicts?(map()) :: boolean()
  def conflicts?(pkg_data), do: find(pkg_data) != []

  @doc """
  Counts conflicts.
  """
  @spec count(map()) :: non_neg_integer()
  def count(pkg_data), do: find(pkg_data) |> length()

  @doc """
  Finds packages that appear in multiple groups (even with same range).
  """
  @spec duplicated(map()) :: [%{name: String.t(), groups: [String.t()]}]
  def duplicated(pkg_data) do
    all_entries = collect_entries(pkg_data)

    all_entries
    |> Enum.group_by(& &1.name)
    |> Enum.filter(fn {_name, entries} -> match?([_, _ | _], entries) end)
    |> Enum.map(fn {name, entries} -> %{name: name, groups: Enum.map(entries, & &1.group)} end)
    |> Enum.sort_by(& &1.name)
  end

  @doc """
  Formats conflict report.
  """
  @spec format([map()]) :: String.t()
  def format([]), do: "No version conflicts."

  def format(conflicts) do
    Enum.map_join(conflicts, "\n", fn c ->
      ranges = Enum.map_join(c.entries, ", ", fn e -> "#{e.group}: #{e.range}" end)
      "#{c.name}: #{ranges}"
    end)
  end

  defp collect_entries(pkg_data) do
    Enum.flat_map(@dep_fields, &entries_for_field(pkg_data, &1))
  end

  defp entries_for_field(pkg_data, field) do
    case Map.get(pkg_data, field) do
      deps when is_map(deps) ->
        Enum.map(deps, fn {name, range} -> %{name: name, range: range, group: field} end)

      _ ->
        []
    end
  end
end