lib/k8s/version.ex

defmodule K8s.Version do
  @moduledoc """
  Kubernetes [API Versioning](https://kubernetes.io/docs/concepts/overview/kubernetes-api/#api-versioning)
  """
  alias K8s.Version

  @type t :: %__MODULE__{major: pos_integer, minor: binary, patch: pos_integer}
  defstruct [:major, :minor, :patch]

  @pattern ~r/v(?<major>\d{1,})(?<minor>(alpha|beta)?)(?<patch>\d{0,})/

  @doc """
  Returns the recommended version from a list of versions.

  It will return the most stable version if any, otherwise the newest "*edge*" version.

  ## Examples

      iex> K8s.Version.recommended(["v1beta1", "v1", "v2"])
      "v2"

      iex> K8s.Version.recommended(["v1beta1", "v1"])
      "v1"

      iex> K8s.Version.recommended(["v1beta2", "v1beta1"])
      "v1beta2"

      iex> K8s.Version.recommended(["v1alpha1", "v1alpha2", "v1beta1"])
      "v1beta1"

  """
  @spec recommended(list(binary)) :: binary
  def recommended(versions) do
    versions
    |> parse_list
    |> sort
    |> List.first()
    |> serialize
  end

  @doc """
  Sorts a list of `K8s.Version`s

  ## Examples

      iex> raw_versions = Enum.shuffle(["v1", "v2beta1", "v2", "v1alpha1", "v1alpha2"])
      iex> versions = raw_versions |> Enum.map(&(K8s.Version.parse(&1)))
      iex> sorted_versions = versions |> K8s.Version.sort()
      iex> sorted_versions |> Enum.map(&(K8s.Version.serialize(&1)))
      ["v2", "v1", "v2beta1", "v1alpha2", "v1alpha1"]

  """
  @spec sort(list(t)) :: list(t)
  def sort(versions), do: Enum.sort(versions, &compare/2)

  @spec compare(t, t) :: boolean
  defp compare(%Version{minor: "stable", major: a}, %Version{minor: "stable", major: b}),
    do: a >= b

  defp compare(%Version{minor: "stable"}, _), do: true
  defp compare(_, %Version{minor: "stable"}), do: false

  defp compare(%Version{minor: "beta"}, %Version{minor: "alpha"}), do: true
  defp compare(%Version{minor: "alpha"}, %Version{minor: "beta"}), do: false

  # Guard here catches case where minor is the same and major is greater.
  # If using >= it will get a flakey result when everything is the same except patch level
  defp compare(%Version{minor: m, major: a}, %Version{minor: m, major: b}) when a > b, do: true

  defp compare(%Version{patch: a}, %Version{patch: b}), do: a >= b
  defp compare(_, _), do: false

  @doc """
  Returns only stable versions

  ## Examples

      iex> K8s.Version.serialize(%K8s.Version{major: 1, minor: "stable", patch: nil})
      "v1"

      iex> K8s.Version.serialize(%K8s.Version{major: 1, minor: "beta", patch: 1})
      "v1beta1"

      iex> K8s.Version.serialize(%K8s.Version{major: 1, minor: "alpha", patch: 1})
      "v1alpha1"

  """
  @spec serialize(Version.t()) :: binary
  def serialize(%Version{major: major, minor: "stable"}), do: "v#{major}"

  def serialize(%Version{major: major, minor: minor, patch: patch}),
    do: "v#{major}#{minor}#{patch}"

  @doc """
  Returns only stable versions

  ## Examples

      iex> K8s.Version.stable(["v1alpha1", "v1", "v2beta1"])
      [%K8s.Version{major: 1, minor: "stable", patch: nil}]

  """
  @spec stable(list(binary)) :: list(Version.t())
  def stable(versions), do: parse_filter(versions, "stable")

  @doc """
  Returns only alpha versions

  ## Examples

      iex> K8s.Version.alpha(["v1alpha1", "v1", "v2beta1"])
      [%K8s.Version{major: 1, minor: "alpha", patch: 1}]

  """
  @spec alpha(list(binary)) :: list(Version.t())
  def alpha(versions), do: parse_filter(versions, "alpha")

  @doc """
  Returns only beta versions

  ## Examples

      iex> K8s.Version.beta(["v1alpha1", "v1", "v2beta1"])
      [%K8s.Version{major: 2, minor: "beta", patch: 1}]

  """
  @spec beta(list(binary)) :: list(Version.t())
  def beta(versions), do: parse_filter(versions, "beta")

  @doc """
  Retuns all non-stable versions

  ## Examples

      iex> K8s.Version.edge(["v1alpha1", "v1", "v2beta1"])
      [%K8s.Version{major: 1, minor: "alpha", patch: 1}, %K8s.Version{major: 2, minor: "beta", patch: 1}]

  """
  @spec edge(list(binary)) :: list(Version.t())
  def edge(versions), do: alpha(versions) ++ beta(versions)

  @spec parse_filter(list(binary), binary) :: list(Version.t())
  defp parse_filter(versions, keep) do
    versions
    |> parse_list
    |> Enum.filter(&is(&1, keep))
  end

  @spec is(Version.t(), binary) :: boolean()
  defp is(%Version{minor: minor}, minor), do: true
  defp is(_, _), do: false

  @doc """
  Parses a Kubernetes API version into a `K8s.Version` struct

  ## Examples

      iex> K8s.Version.parse("v1")
      %K8s.Version{major: 1, minor: "stable", patch: nil}

      iex> K8s.Version.parse("v1beta2")
      %K8s.Version{major: 1, minor: "beta", patch: 2}

      iex> K8s.Version.parse("v1alpha1")
      %K8s.Version{major: 1, minor: "alpha", patch: 1}

  """
  @spec parse(binary()) :: Version.t()
  def parse(version) do
    %{"major" => major, "minor" => minor, "patch" => patch} =
      Regex.named_captures(@pattern, version)

    format(major, minor, patch)
  end

  @spec format(binary | integer, binary, binary | integer | nil) :: t
  defp format(major, "", "") when is_integer(major), do: format(major, "stable", nil)

  defp format(major, minor, patch) when is_binary(major),
    do: major |> String.to_integer() |> format(minor, patch)

  defp format(major, minor, patch) when is_binary(patch),
    do: format(major, minor, String.to_integer(patch))

  defp format(major, minor, patch), do: %__MODULE__{major: major, minor: minor, patch: patch}

  # Parse a list of string versions
  @spec parse_list(list(binary)) :: list(K8s.Version.t())
  defp parse_list(versions), do: parse_list(versions, [])
  @spec parse_list(list(binary), list(K8s.Version.t()) | nil) :: list(K8s.Version.t())
  defp parse_list([], acc), do: acc
  defp parse_list([version | tail], acc), do: parse_list(tail, [parse(version) | acc])
end