Skip to main content

lib/npm/dependency/outdated.ex

defmodule NPM.Dependency.Outdated do
  @moduledoc """
  Checks for outdated packages by comparing installed versions
  against the latest available versions on the registry.
  """

  @type outdated_entry :: %{
          name: String.t(),
          current: String.t(),
          wanted: String.t(),
          latest: String.t(),
          type: :major | :minor | :patch | :current
        }

  @doc """
  Checks a lockfile against wanted ranges and latest versions.

  Given a lockfile (installed versions) and deps (wanted ranges from package.json),
  and a map of latest versions from the registry, returns a list of outdated entries.
  """
  @spec check(map(), map(), map()) :: [outdated_entry()]
  def check(lockfile, deps, latest_versions) do
    deps
    |> Enum.flat_map(&check_package(&1, lockfile, latest_versions))
    |> Enum.sort_by(& &1.name)
  end

  defp check_package({name, range}, lockfile, latest_versions) do
    case Map.get(lockfile, name) do
      nil -> []
      entry -> build_entry(name, entry.version, range, latest_versions)
    end
  end

  defp build_entry(name, current, range, latest_versions) do
    latest = Map.get(latest_versions, name, current)

    wanted =
      case NPM.SemverUtil.max_satisfying(available_versions(latest), range) do
        {:ok, v} -> v
        _ -> current
      end

    case NPM.SemverUtil.update_type(current, latest) do
      :none -> []
      type -> [%{name: name, current: current, wanted: wanted, latest: latest, type: type}]
    end
  end

  @doc """
  Classifies an update type.
  """
  @spec update_type(String.t(), String.t()) :: :major | :minor | :patch | :current
  def update_type(current, latest) do
    case NPM.SemverUtil.update_type(current, latest) do
      :none -> :current
      type when type in [:major, :minor, :patch] -> type
      _ -> :current
    end
  end

  @doc """
  Computes all available updates from `{name, current, latest}` tuples.
  """
  @spec compute([{String.t(), String.t(), String.t()}]) :: [map()]
  def compute(packages) do
    packages
    |> Enum.map(fn {name, current, latest} ->
      %{name: name, current: current, latest: latest, type: update_type(current, latest)}
    end)
    |> Enum.reject(&(&1.type == :current))
    |> Enum.sort_by(fn update -> {type_order(update.type), update.name} end)
  end

  @doc """
  Groups updates by type.
  """
  @spec group_by_type([map()]) :: map()
  def group_by_type(updates) do
    Enum.group_by(updates, & &1.type)
  end

  @doc """
  Filters outdated entries by update type.
  """
  @spec filter_by_type([outdated_entry()], :major | :minor | :patch) :: [outdated_entry()]
  def filter_by_type(entries, type) do
    Enum.filter(entries, &(&1.type == type))
  end

  @doc """
  Formats an outdated entry as a human-readable string.
  """
  @spec format_entry(outdated_entry()) :: String.t()
  def format_entry(entry) do
    "#{entry.name}  #{entry.current}#{entry.latest} (wanted: #{entry.wanted})"
  end

  @doc """
  Returns a summary of outdated packages.
  """
  @spec summary([outdated_entry()] | [map()]) :: %{
          total: non_neg_integer(),
          major: non_neg_integer(),
          minor: non_neg_integer(),
          patch: non_neg_integer()
        }
  def summary(entries) do
    grouped = group_by_type(entries)

    %{
      total: length(entries),
      major: length(Map.get(grouped, :major, [])),
      minor: length(Map.get(grouped, :minor, [])),
      patch: length(Map.get(grouped, :patch, []))
    }
  end

  @doc """
  Formats updates for display.
  """
  @spec format([map()]) :: String.t()
  def format([]), do: "All packages are up to date."

  def format(updates) do
    Enum.map_join(updates, "\n", fn update ->
      "#{update.name}: #{update.current}#{update.latest} (#{update.type})"
    end)
  end

  defp available_versions(latest) do
    [latest]
  end

  defp type_order(:major), do: 0
  defp type_order(:minor), do: 1
  defp type_order(:patch), do: 2
  defp type_order(_), do: 3
end