lib/git_ops/version.ex

defmodule GitOps.Version do
  @moduledoc """
  Functionality around parsing and comparing versions contained in git tags
  """

  alias GitOps.Commit

  @spec last_valid_non_rc_version([String.t()], String.t()) :: String.t() | nil
  def last_valid_non_rc_version(versions, prefix) do
    versions
    |> Enum.reject(fn tag -> parse(prefix, tag) == :error end)
    |> Enum.find(fn version ->
      match?({:ok, %{pre: []}}, parse(prefix, version))
    end)
  end

  @spec last_valid_version([String.t()], String.t()) :: String.t() | nil
  def last_valid_version(versions, prefix) do
    versions
    |> Enum.reject(fn tag -> parse(prefix, tag) == :error end)
    |> Enum.find(fn version ->
      match?({:ok, %{}}, parse(prefix, version))
    end)
  end

  def determine_new_version(current_version, prefix, commits, last_valid_non_rc_version, opts) do
    parsed = parse!(prefix, prefix <> current_version)

    rc? = opts[:rc]

    build = opts[:build]

    last_valid_non_rc_version =
      if last_valid_non_rc_version && prefix && prefix != "" do
        String.trim_leading(last_valid_non_rc_version, prefix)
      else
        last_valid_non_rc_version
      end

    new_version =
      new_version(
        commits,
        parsed,
        rc?,
        last_valid_non_rc_version,
        opts
      )

    if versions_equal?(new_version, parsed) && build == parsed.build do
      raise """
      No changes should result in a new release version.

      Options:

      * If no fixes or features were added, then perhaps you don't need to release.
      * If a fix or feature commit was not correctly annotated, you could alter your git
        history to fix it and run this command again, or create an empty commit via
        `git commit --allow-empty` that contains an appropriate message.
      * If you don't care and want a new version, you can use `--force-patch` which
        will update the patch version regardless.
      * You can add build metadata using `--build` that will signify that something was
        unique about this build.
      """
    end

    unprefixed =
      new_version
      |> Map.put(:build, build)
      |> to_string()

    prefix <> unprefixed
  end

  def last_version_greater_than(versions, last_version, prefix) do
    Enum.find(versions, fn version ->
      case parse(prefix, version) do
        {:ok, version} ->
          Version.compare(version, parse!(prefix, last_version)) == :gt

        _ ->
          false
      end
    end)
  end

  defp new_version(commits, parsed, rc?, last_valid_non_rc_version, opts) do
    pre = default_pre_release(rc?, opts[:pre_release])

    last_valid_non_rc_version =
      last_valid_non_rc_version && Version.parse!(last_valid_non_rc_version)

    new_version =
      cond do
        Enum.any?(commits, &Commit.breaking?/1) &&
            !(rc? && last_valid_non_rc_version &&
                  last_valid_non_rc_version.major != parsed.major) ->
          if opts[:no_major] do
            %{parsed | minor: parsed.minor + 1, patch: 0, pre: pre}
          else
            %{parsed | major: parsed.major + 1, minor: 0, patch: 0, pre: pre}
          end

        Enum.any?(commits, &Commit.feature?/1) &&
            !(rc? && last_valid_non_rc_version &&
                  (last_valid_non_rc_version.major != parsed.major ||
                     last_valid_non_rc_version.minor != parsed.minor)) ->
          if match?(["rc" <> _ | _], parsed.pre) && !rc? do
            parsed
          else
            %{parsed | minor: parsed.minor + 1, patch: 0, pre: pre}
          end

        Enum.any?(commits, &Commit.fix?/1) || opts[:force_patch] ->
          if match?(["rc" <> _], parsed.pre) && rc? do
            %{parsed | pre: increment_rc!(parsed.pre)}
          else
            new_version_patch(parsed, pre, rc?)
          end

        true ->
          parsed
      end

    if match?(["rc" <> _ | _], parsed.pre) && !rc? do
      %{new_version | pre: List.wrap(opts[:pre_release])}
    else
      new_version
    end
  end

  defp default_pre_release(true, _pre_release), do: ["rc.0"]
  defp default_pre_release(_rc?, pre_release), do: List.wrap(pre_release)

  defp new_version_patch(parsed, pre, rc?) do
    case {parsed, pre, rc?} do
      {parsed, [], _} ->
        %{parsed | patch: parsed.patch + 1, pre: []}

      {parsed = %{pre: []}, pre, _} ->
        %{parsed | patch: parsed.patch + 1, pre: pre}

      {parsed = %{pre: ["rc." <> _]}, pre, nil} ->
        %{parsed | patch: parsed.patch + 1, pre: pre}

      {parsed = %{pre: ["rc" <> _]}, pre, nil} ->
        %{parsed | patch: parsed.patch + 1, pre: pre}

      {parsed, _pre, true} ->
        %{parsed | pre: increment_rc!(parsed.pre)}

      {parsed, pre, _} ->
        %{parsed | pre: pre}
    end
  end

  defp increment_rc!(nil), do: ["rc", "0"]
  defp increment_rc!([]), do: ["rc", "0"]
  defp increment_rc!([rc]), do: List.wrap(increment_rc!(rc))
  defp increment_rc!([rc, int]) when is_integer(int), do: [rc, int + 1]

  defp increment_rc!("rc" <> rc) do
    case Integer.parse(rc) do
      {int, ""} ->
        "rc#{int + 1}"

      :error ->
        raise "Found an rc version that could not be parsed: rc#{rc}"
    end
  end

  defp increment_rc!(rc) do
    raise "Found an rc version that could not be parsed: #{rc}"
  end

  defp versions_equal?(left, right) do
    Version.compare(left, right) == :eq
  end

  defp parse(_, version = %Version{}), do: {:ok, version}
  defp parse("", text), do: Version.parse(text)

  defp parse(prefix, text) do
    if String.starts_with?(text, prefix) do
      text
      |> String.trim_leading(prefix)
      |> Version.parse()
    else
      :error
    end
  end

  defp parse!(prefix, text) do
    case parse(prefix, text) do
      {:ok, parsed} ->
        parsed

      :error ->
        raise ArgumentError, "Expected: #{text} to be parseable as a version, but it was not."
    end
  end
end