defmodule Mix.Tasks.Semver.Increment do @moduledoc """ The semver mix task: `mix semver.increment` """ use Mix.Task @semver_line_rx ~r/version: "(\d+)\.(\d+)\.(\d+)(-.*)?",/ @semver_field_pos 0 @semver_field_default "patch" @increment_amount_pos 1 @increment_amount_default 1 @valid_semver_fields ["major", "minor", "patch"] @shortdoc "Default task will increment the semver patch version" @doc """ Increment the appropriate field on your project version using Semantic Versioning. MAJOR.MINOR.PATCH Incrementing a greater value will reset the lower fields unless the --preserve-lower flag is set. ## Options * `patch` (default) * `minor` * `major` * --preserve [optional csv separated list to preserve (currently only `patch` and `minor`)] Used on its own, only the specified field will be incremented. ## Examples $ mix semver.increment Incremented 0.1.0 -> 0.1.1 $ mix semver.increment patch Incremented 0.1.1 -> 0.1.2 $ mix semver.increment minor 3 Incremented 0.1.2 -> 0.4.0 $ mix semver.increment patch 2 Incremented 0.4.0 -> 0.4.2 $ mix semver.increment major --preserve patch Incremented 0.4.2 -> 1.0.2 """ def run(args) do {field, amt, opts} = parse_semver_args(args) |> dbg() {:ok, mix_data} = File.read("mix.exs") semver = get_semver(mix_data) next_semver = increment(semver, field, amt, opts) new_mix_data = String.replace(mix_data, @semver_line_rx, "version: \"#{Version.to_string(next_semver)}\",") IO.puts("Incremented #{semver} -> #{next_semver}") File.write!("mix.exs", new_mix_data) :ok end @spec increment(Version.t(), atom(), integer(), keyword()) :: Version.t() defp increment(semver, field, amount, opts) defp increment(semver = %Version{}, :patch, amount, _opts) do %Version{semver | patch: semver.patch + amount} end defp increment(semver = %Version{}, :minor, amount, opts) do next_patch = maybe_reset(semver, :patch, Keyword.get(opts, :preserve)) next_minor = semver.minor + amount %Version{semver | minor: next_minor, patch: next_patch} end defp increment(semver = %Version{}, :major, amount, opts) do next_patch = maybe_reset(semver, :patch, Keyword.get(opts, :preserve)) next_minor = maybe_reset(semver, :minor, Keyword.get(opts, :preserve)) next_major = semver.major + amount %Version{semver | major: next_major, patch: next_patch, minor: next_minor} end defp coerce_custom_opts({p, nil}) when p in ["--preserve", "-p"], do: [preserve: [:patch, :minor]] defp coerce_custom_opts({p, fields}) when p in ["--preserve", "-p"] do IO.puts("handling custom") fields = fields |> String.split(",", trim: true) |> Enum.map(&String.downcase/1) |> Enum.map(&String.to_atom/1) [preserve: fields] end defp coerce_custom_opts(opt), do: opt defp get_semver(mix_data) do [semver_line | _] = Regex.run(@semver_line_rx, mix_data) semver_line |> String.replace("version: ", "") |> String.replace("\"", "") |> String.replace(",", "") |> Version.parse!() end defp maybe_reset(_semver, _field, nil), do: 0 defp maybe_reset(semver, field, preserve_list) do if field in preserve_list, do: Map.get(semver, field), else: 0 end defp parse_semver_args(args) do {parsed, args, invalid} = OptionParser.parse(args, strict: [preserve: :string], aliases: [p: :preserve] ) opts = invalid |> Enum.map(&coerce_custom_opts/1) |> Enum.reduce(parsed, &Keyword.merge/2) |> Keyword.update!(:preserve, fn nil -> [] fields when is_binary(fields) -> fields |> String.downcase() |> String.split(",", trim: true) |> Enum.map(&String.to_atom/1) fields when is_list(fields) -> Enum.map(fields, fn s when is_binary(s) -> s |> String.downcase() |> String.to_atom() a when is_atom(a) -> a end) end) field = case Enum.at(args, @semver_field_pos, @semver_field_default) do field when field in @valid_semver_fields -> String.to_atom(field) _ -> raise("Invalid semver field selected, must be one of #{inspect(@valid_semver_fields)}") end amt = case Enum.at(args, @increment_amount_pos) do delta when is_binary(delta) -> String.to_integer(delta) _ -> @increment_amount_default end {field, amt, opts} end end