Skip to main content

lib/npm/release_notes.ex

defmodule NPM.ReleaseNotes do
  @moduledoc """
  Extracts release notes from changelog content for specific version ranges.
  """

  @version_header ~r/^(#+\s*\[?v?)(\d+\.\d+\.\d+(?:-[a-zA-Z0-9.]+)?)/m

  @doc """
  Extracts all version sections from changelog content.
  """
  @spec sections(String.t()) :: [{String.t(), String.t()}]
  def sections(content) do
    matches = Regex.scan(@version_header, content, return: :index)

    versions =
      Regex.scan(@version_header, content) |> Enum.map(fn [_, _, v] -> clean_version(v) end)

    matches
    |> Enum.with_index()
    |> Enum.zip(versions)
    |> Enum.flat_map(fn {{[{start, _} | _], idx}, version} ->
      next_start = next_section_start(matches, idx, byte_size(content))
      body = binary_part(content, start, next_start - start) |> String.trim()

      case NPM.VersionUtil.parse_triple(version) do
        {:ok, _} -> [{version, body}]
        :error -> []
      end
    end)
  end

  @doc """
  Extracts notes for a specific version.
  """
  @spec for_version(String.t(), String.t()) :: String.t() | nil
  def for_version(content, version) do
    sections(content)
    |> Enum.find_value(fn {v, body} -> if v == version, do: body end)
  end

  @doc """
  Extracts notes between two versions (inclusive).
  """
  @spec between(String.t(), String.t(), String.t()) :: [String.t()]
  def between(content, from_version, to_version) do
    sections(content)
    |> Enum.filter(fn {v, _} ->
      NPM.VersionUtil.compare(v, from_version) in [:gt, :eq] and
        NPM.VersionUtil.compare(v, to_version) in [:lt, :eq]
    end)
    |> Enum.map(&elem(&1, 1))
  end

  @doc """
  Counts versions in the changelog.
  """
  @spec version_count(String.t()) :: non_neg_integer()
  def version_count(content), do: sections(content) |> length()

  @doc """
  Returns the latest version mentioned.
  """
  @spec latest_version(String.t()) :: String.t() | nil
  def latest_version(content) do
    case sections(content) do
      [{version, _} | _] -> version
      _ -> nil
    end
  end

  defp next_section_start(matches, idx, total) do
    case Enum.at(matches, idx + 1) do
      [{start, _} | _] -> start
      _ -> total
    end
  end

  defp clean_version(v), do: v |> String.trim_trailing("]") |> String.trim()
end