Skip to main content

lib/npm/patch.ex

defmodule NPM.Patch do
  @moduledoc """
  Manages patched packages (patch-package style).

  Tracks patches applied to node_modules packages and
  verifies they are still applied after install.
  """

  @patches_dir "patches"

  @doc """
  Lists all patch files in the patches directory.
  """
  @spec list(String.t()) :: [%{package: String.t(), file: String.t()}]
  def list(project_dir \\ ".") do
    dir = Path.join(project_dir, @patches_dir)

    case File.ls(dir) do
      {:ok, files} ->
        files
        |> Enum.filter(&String.ends_with?(&1, ".patch"))
        |> Enum.map(fn file ->
          %{package: extract_package_name(file), file: file}
        end)
        |> Enum.sort_by(& &1.package)

      _ ->
        []
    end
  end

  @doc """
  Checks if a package has patches.
  """
  @spec patched?(String.t(), String.t()) :: boolean()
  def patched?(package_name, project_dir \\ ".") do
    list(project_dir)
    |> Enum.any?(&(&1.package == package_name))
  end

  @doc """
  Returns the count of patches.
  """
  @spec count(String.t()) :: non_neg_integer()
  def count(project_dir \\ "."), do: list(project_dir) |> length()

  @doc """
  Extracts the package name from a patch filename.

  Supports formats like `lodash+4.17.21.patch` and `@scope+pkg+1.0.0.patch`.
  """
  @spec extract_package_name(String.t()) :: String.t()
  def extract_package_name(filename) do
    base = String.trim_trailing(filename, ".patch")

    case String.split(base, "+") do
      ["@" <> scope, name | _rest] -> "@#{scope}/#{name}"
      [name | _rest] -> name
      _ -> base
    end
  end

  @doc """
  Generates a patch filename for a package.
  """
  @spec filename(String.t(), String.t()) :: String.t()
  def filename(package_name, version) do
    safe_name = String.replace(package_name, "/", "+")
    "#{safe_name}+#{version}.patch"
  end

  @doc """
  Returns all patched package names.
  """
  @spec patched_packages(String.t()) :: [String.t()]
  def patched_packages(project_dir \\ ".") do
    list(project_dir) |> Enum.map(& &1.package) |> Enum.uniq()
  end
end