defmodule NPM.Changelog do @moduledoc """ Detects and reads changelog files from installed packages. Supports common changelog file names: CHANGELOG.md, HISTORY.md, CHANGES.md, and their case variations. """ @changelog_names ~w( CHANGELOG.md changelog.md Changelog.md HISTORY.md history.md History.md CHANGES.md changes.md Changes.md CHANGELOG HISTORY CHANGES CHANGELOG.txt changelog.txt ) @doc """ Finds the changelog file for a package. """ @spec find(String.t()) :: {:ok, String.t()} | :none def find(package_dir) do Enum.find_value(@changelog_names, :none, fn name -> path = Path.join(package_dir, name) if File.regular?(path), do: {:ok, path} end) end @doc """ Reads the changelog content for a package. """ @spec read(String.t()) :: {:ok, String.t()} | :none def read(package_dir) do case find(package_dir) do {:ok, path} -> {:ok, File.read!(path)} :none -> :none end end @doc """ Extracts the entry for a specific version from markdown changelog. """ @spec version_entry(String.t(), String.t()) :: String.t() | nil def version_entry(content, version) do lines = String.split(content, "\n") {entry_lines, _} = extract_version_section(lines, version) case entry_lines do [] -> nil lines -> Enum.join(lines, "\n") |> String.trim() end end @doc """ Lists all versions mentioned in a changelog. """ @spec versions(String.t()) :: [String.t()] def versions(content) do ~r/^##?\s+\[?v?(\d+\.\d+\.\d+(?:[^\]\s]*))\]?/m |> Regex.scan(content) |> Enum.map(fn [_, version] -> String.trim(version) end) end @doc """ Checks if a package has a changelog. """ @spec has_changelog?(String.t()) :: boolean() def has_changelog?(package_dir) do case find(package_dir) do {:ok, _} -> true :none -> false end end defp extract_version_section(lines, version) do {_, in_section, collected} = Enum.reduce(lines, {false, false, []}, fn line, {found, in_sec, acc} -> cond do not found and version_header?(line, version) -> {true, true, acc} in_sec and next_version_header?(line) -> {true, false, acc} in_sec -> {true, true, [line | acc]} true -> {found, in_sec, acc} end end) {Enum.reverse(collected), in_section} end defp version_header?(line, version) do String.contains?(line, version) and Regex.match?(~r/^##?\s/, line) end defp next_version_header?(line) do Regex.match?(~r/^##?\s+\[?\d/, line) end end