Skip to main content

src/version_bump@branch.erl

-module(version_bump@branch).
-compile([no_auto_import, nowarn_unused_vars, nowarn_unused_function, nowarn_nomatch, inline]).
-define(FILEPATH, "src/version_bump/branch.gleam").
-export([resolve/3, last_release/3, next_version/4]).
-export_type([branch_type/0, branch/0]).

-if(?OTP_RELEASE >= 27).
-define(MODULEDOC(Str), -moduledoc(Str)).
-define(DOC(Str), -doc(Str)).
-else.
-define(MODULEDOC(Str), -compile([])).
-define(DOC(Str), -compile([])).
-endif.

?MODULEDOC(
    " The branching / channel model (MVP).\n"
    "\n"
    " semantic-release resolves the configured branches against the branches that\n"
    " actually exist in the repository, classifying each into one of three kinds:\n"
    "\n"
    "   * `ReleaseBranch`     — a normal release line (e.g. `main`, `master`,\n"
    "     `next`). The first such branch is the \"main\" branch.\n"
    "   * `MaintenanceBranch` — a branch whose name is a version range such as\n"
    "     `1.x`, `1.2.x` or `1.x.x`. Releases here are constrained to that range.\n"
    "   * `PrereleaseBranch`  — a branch with a `prerelease` identifier (e.g.\n"
    "     `beta`, `alpha`). Releases here carry a `-id.N` prerelease suffix.\n"
    "\n"
    " This module is pure: it never shells out to git. The caller passes in the\n"
    " list of branch names discovered in the repository (`git_branches`) and the\n"
    " list of tags (`tags`); everything here is string / semver manipulation.\n"
).

-type branch_type() :: release_branch | maintenance_branch | prerelease_branch.

-type branch() :: {branch,
        binary(),
        branch_type(),
        gleam@option:option(binary()),
        gleam@option:option(binary()),
        gleam@option:option(binary()),
        boolean()}.

-file("src/version_bump/branch.gleam", 248).
?DOC(
    " Parse a maintenance branch name into a range string.\n"
    "\n"
    " `1.x` / `1.x.x` -> `\">=1.0.0 <2.0.0\"`, `1.2.x` -> `\">=1.2.0 <1.3.0\"`.\n"
).
-spec parse_maintenance_name(binary()) -> {ok, binary()} | {error, nil}.
parse_maintenance_name(Name) ->
    Major_only = case gleam@regexp:from_string(<<"^(\\d+)\\.x(\\.x)?$"/utf8>>) of
        {ok, Re} ->
            gleam@regexp:scan(Re, Name);

        {error, _} ->
            []
    end,
    Major_minor = case gleam@regexp:from_string(
        <<"^(\\d+)\\.(\\d+)\\.x$"/utf8>>
    ) of
        {ok, Re@1} ->
            gleam@regexp:scan(Re@1, Name);

        {error, _} ->
            []
    end,
    case {Major_only, Major_minor} of
        {[{match, _, [{some, Maj} | _]} | _], _} ->
            case gleam_stdlib:parse_int(Maj) of
                {ok, M} ->
                    {ok,
                        <<<<<<<<">="/utf8,
                                        (erlang:integer_to_binary(M))/binary>>/binary,
                                    ".0.0 <"/utf8>>/binary,
                                (erlang:integer_to_binary(M + 1))/binary>>/binary,
                            ".0.0"/utf8>>};

                {error, _} ->
                    {error, nil}
            end;

        {_, [{match, _, [{some, Maj@1}, {some, Min}]} | _]} ->
            case {gleam_stdlib:parse_int(Maj@1), gleam_stdlib:parse_int(Min)} of
                {{ok, M@1}, {ok, N}} ->
                    {ok,
                        <<<<<<<<<<<<<<<<">="/utf8,
                                                        (erlang:integer_to_binary(
                                                            M@1
                                                        ))/binary>>/binary,
                                                    "."/utf8>>/binary,
                                                (erlang:integer_to_binary(N))/binary>>/binary,
                                            ".0 <"/utf8>>/binary,
                                        (erlang:integer_to_binary(M@1))/binary>>/binary,
                                    "."/utf8>>/binary,
                                (erlang:integer_to_binary(N + 1))/binary>>/binary,
                            ".0"/utf8>>};

                {_, _} ->
                    {error, nil}
            end;

        {_, _} ->
            {error, nil}
    end.

-file("src/version_bump/branch.gleam", 231).
?DOC(
    " If `name` looks like a maintenance range (`N.x`, `N.x.x`, `N.N.x`), return\n"
    " the normalised range string. An explicit configured `range` takes priority.\n"
).
-spec maintenance_range(binary(), gleam@option:option(binary())) -> gleam@option:option(binary()).
maintenance_range(Name, Configured) ->
    case Configured of
        {some, R} ->
            {some, R};

        none ->
            case parse_maintenance_name(Name) of
                {ok, Range} ->
                    {some, Range};

                {error, _} ->
                    none
            end
    end.

-file("src/version_bump/branch.gleam", 177).
?DOC(" Classify a single configured branch into a resolved `Branch`.\n").
-spec classify(
    version_bump@config:branch_config(),
    gleam@option:option(binary())
) -> branch().
classify(Bc, Main_name) ->
    Is_main = Main_name =:= {some, erlang:element(2, Bc)},
    case erlang:element(4, Bc) of
        {some, Id} ->
            {branch,
                erlang:element(2, Bc),
                prerelease_branch,
                gleam@option:'or'(erlang:element(3, Bc), {some, Id}),
                {some, Id},
                erlang:element(5, Bc),
                false};

        none ->
            case maintenance_range(erlang:element(2, Bc), erlang:element(5, Bc)) of
                {some, Range} ->
                    {branch,
                        erlang:element(2, Bc),
                        maintenance_branch,
                        gleam@option:'or'(
                            erlang:element(3, Bc),
                            {some, erlang:element(2, Bc)}
                        ),
                        none,
                        {some, Range},
                        false};

                none ->
                    {branch,
                        erlang:element(2, Bc),
                        release_branch,
                        erlang:element(3, Bc),
                        none,
                        erlang:element(5, Bc),
                        Is_main}
            end
    end.

-file("src/version_bump/branch.gleam", 217).
?DOC(
    " The name of the first configured branch that is a plain release branch\n"
    " (neither prerelease nor maintenance), i.e. the \"main\" branch.\n"
).
-spec first_main_name(list(version_bump@config:branch_config())) -> gleam@option:option(binary()).
first_main_name(Branches) ->
    _pipe = Branches,
    _pipe@1 = gleam@list:find(_pipe, fun(Bc) -> case erlang:element(4, Bc) of
                {some, _} ->
                    false;

                none ->
                    maintenance_range(
                        erlang:element(2, Bc),
                        erlang:element(5, Bc)
                    )
                    =:= none
            end end),
    _pipe@2 = gleam@result:map(
        _pipe@1,
        fun(Bc@1) -> erlang:element(2, Bc@1) end
    ),
    gleam@option:from_result(_pipe@2).

-file("src/version_bump/branch.gleam", 59).
?DOC(
    " Resolve the configured branches against the branches that exist in the\n"
    " repository, returning the resolved `current` branch together with every\n"
    " resolved branch.\n"
    "\n"
    " Each `BranchConfig` whose name appears in `git_branches` is classified into\n"
    " a `Branch`. Configured branches that do not exist in the repository are\n"
    " dropped (they cannot be released against). The `current` branch must be one\n"
    " of the resolved branches, otherwise a `ConfigError` is returned.\n"
).
-spec resolve(version_bump@config:config(), list(binary()), binary()) -> {ok,
        {branch(), list(branch())}} |
    {error, version_bump@error:release_error()}.
resolve(Config, Git_branches, Current) ->
    Existing = gleam@list:filter(
        erlang:element(4, Config),
        fun(Bc) -> gleam@list:contains(Git_branches, erlang:element(2, Bc)) end
    ),
    Main_name = first_main_name(erlang:element(4, Config)),
    Resolved = gleam@list:map(
        Existing,
        fun(Bc@1) -> classify(Bc@1, Main_name) end
    ),
    case gleam@list:find(
        Resolved,
        fun(B) -> erlang:element(2, B) =:= Current end
    ) of
        {ok, Branch} ->
            {ok, {Branch, Resolved}};

        {error, _} ->
            {error,
                {config_error,
                    <<<<"Current branch '"/utf8, Current/binary>>/binary,
                        "' is not a configured release branch"/utf8>>}}
    end.

-file("src/version_bump/branch.gleam", 355).
?DOC(" The pair with the highest version per semver `compare`.\n").
-spec highest(list({binary(), version_bump@semver:version()})) -> gleam@option:option({binary(),
    version_bump@semver:version()}).
highest(Candidates) ->
    case Candidates of
        [] ->
            none;

        [First | Rest] ->
            {some,
                gleam@list:fold(
                    Rest,
                    First,
                    fun(Acc, Cand) ->
                        case version_bump@semver:compare(
                            erlang:element(2, Cand),
                            erlang:element(2, Acc)
                        ) of
                            gt ->
                                Cand;

                            _ ->
                                Acc
                        end
                    end
                )}
    end.

-file("src/version_bump/branch.gleam", 347).
-spec lt(version_bump@semver:version(), version_bump@semver:version()) -> boolean().
lt(A, B) ->
    case version_bump@semver:compare(A, B) of
        lt ->
            true;

        _ ->
            false
    end.

-file("src/version_bump/branch.gleam", 340).
-spec gte(version_bump@semver:version(), version_bump@semver:version()) -> boolean().
gte(A, B) ->
    case version_bump@semver:compare(A, B) of
        lt ->
            false;

        _ ->
            true
    end.

-file("src/version_bump/branch.gleam", 326).
?DOC(" Parse a `\">=lo <hi\"` range into its two bounds.\n").
-spec parse_range(binary()) -> {ok,
        {version_bump@semver:version(), version_bump@semver:version()}} |
    {error, nil}.
parse_range(R) ->
    case gleam@string:split(gleam@string:trim(R), <<" "/utf8>>) of
        [Lo, Hi] ->
            Lo_str = gleam@string:replace(Lo, <<">="/utf8>>, <<""/utf8>>),
            Hi_str = gleam@string:replace(Hi, <<"<"/utf8>>, <<""/utf8>>),
            case {version_bump@semver:parse(Lo_str),
                version_bump@semver:parse(Hi_str)} of
                {{ok, Low}, {ok, High}} ->
                    {ok, {Low, High}};

                {_, _} ->
                    {error, nil}
            end;

        _ ->
            {error, nil}
    end.

-file("src/version_bump/branch.gleam", 314).
?DOC(
    " True when `version` falls inside a maintenance range string of the shape\n"
    " `\">=A.B.C <D.E.F\"`. An absent range admits everything.\n"
).
-spec in_range(version_bump@semver:version(), gleam@option:option(binary())) -> boolean().
in_range(Version, Range) ->
    case Range of
        none ->
            true;

        {some, R} ->
            case parse_range(R) of
                {ok, {Low, High}} ->
                    gte(Version, Low) andalso lt(Version, High);

                {error, _} ->
                    true
            end
    end.

-file("src/version_bump/branch.gleam", 293).
?DOC(" True when `version` belongs on `branch`.\n").
-spec version_matches_branch(version_bump@semver:version(), branch()) -> boolean().
version_matches_branch(Version, Branch) ->
    case erlang:element(3, Branch) of
        prerelease_branch ->
            case {erlang:element(5, Branch), erlang:element(5, Version)} of
                {{some, Id}, [First | _]} ->
                    First =:= Id;

                {_, _} ->
                    false
            end;

        maintenance_branch ->
            case erlang:element(5, Version) of
                [] ->
                    in_range(Version, erlang:element(6, Branch));

                _ ->
                    false
            end;

        release_branch ->
            erlang:element(5, Version) =:= []
    end.

-file("src/version_bump/branch.gleam", 382).
?DOC(
    " Extract the version substring from a tag given the format's prefix/suffix.\n"
    " Fails when the tag does not start with `prefix` and end with `suffix`.\n"
).
-spec extract_version_string(binary(), binary(), binary()) -> {ok, binary()} |
    {error, nil}.
extract_version_string(Tag, Prefix, Suffix) ->
    case {gleam_stdlib:string_starts_with(Tag, Prefix),
        gleam_stdlib:string_ends_with(Tag, Suffix)} of
        {true, true} ->
            Without_prefix = gleam@string:drop_start(Tag, string:length(Prefix)),
            Core = gleam@string:drop_end(Without_prefix, string:length(Suffix)),
            case Core of
                <<""/utf8>> ->
                    {error, nil};

                _ ->
                    {ok, Core}
            end;

        {_, _} ->
            {error, nil}
    end.

-file("src/version_bump/branch.gleam", 373).
?DOC(
    " Split a tag format like `\"v${version}\"` into its prefix and suffix around\n"
    " the `${version}` placeholder. If the placeholder is missing, the whole\n"
    " format is treated as a prefix (best effort).\n"
).
-spec tag_format_parts(binary()) -> {binary(), binary()}.
tag_format_parts(Tag_format) ->
    case gleam@string:split_once(Tag_format, <<"${version}"/utf8>>) of
        {ok, {Prefix, Suffix}} ->
            {Prefix, Suffix};

        {error, _} ->
            {Tag_format, <<""/utf8>>}
    end.

-file("src/version_bump/branch.gleam", 97).
?DOC(
    " Pick the highest existing release on `branch` from a list of git tags.\n"
    "\n"
    " Tags are matched against `tag_format` (e.g. `\"v${version}\"`); the embedded\n"
    " version is parsed as semver. Only versions compatible with the branch are\n"
    " considered:\n"
    "\n"
    "   * `PrereleaseBranch`  — only versions whose first prerelease identifier is\n"
    "     the branch's prerelease id (e.g. `1.0.0-beta.2` on the `beta` branch).\n"
    "   * `MaintenanceBranch` — only non-prerelease versions inside the branch's\n"
    "     range (e.g. `1.x` admits `1.*.*`, `1.2.x` admits `1.2.*`).\n"
    "   * `ReleaseBranch`     — only non-prerelease versions.\n"
    "\n"
    " Returns the highest matching version as a `LastRelease`, or `None` when no\n"
    " tag matches.\n"
).
-spec last_release(list(binary()), branch(), binary()) -> gleam@option:option(version_bump@release:last_release()).
last_release(Tags, Branch, Tag_format) ->
    {Prefix, Suffix} = tag_format_parts(Tag_format),
    Candidates = begin
        _pipe = Tags,
        gleam@list:filter_map(
            _pipe,
            fun(Tag) -> case extract_version_string(Tag, Prefix, Suffix) of
                    {ok, Version_str} ->
                        case version_bump@semver:parse(Version_str) of
                            {ok, Version} ->
                                case version_matches_branch(Version, Branch) of
                                    true ->
                                        {ok, {Tag, Version}};

                                    false ->
                                        {error, nil}
                                end;

                            {error, _} ->
                                {error, nil}
                        end;

                    {error, _} ->
                        {error, nil}
                end end
        )
    end,
    case highest(Candidates) of
        none ->
            none;

        {some, {Tag@1, Version@1}} ->
            {some,
                {last_release,
                    version_bump@semver:to_string(Version@1),
                    Tag@1,
                    <<""/utf8>>,
                    [erlang:element(4, Branch)]}}
    end.

-file("src/version_bump/branch.gleam", 143).
?DOC(
    " Compute the next version string for `branch`, given the last release and the\n"
    " `ReleaseType` the commits warrant.\n"
    "\n"
    " With no previous release the first version is `1.0.0` (or `0.1.0` in\n"
    " `InitialDevelopment` mode), with the branch's prerelease identifier appended\n"
    " on a prerelease branch. Otherwise the last version is bumped by `rtype` — in\n"
    " `InitialDevelopment` mode a breaking change is downshifted to a minor bump\n"
    " while major is 0 (see `semver.effective_release_type`).\n"
).
-spec next_version(
    gleam@option:option(version_bump@release:last_release()),
    version_bump@semver:release_type(),
    branch(),
    version_bump@semver:versioning_mode()
) -> {ok, binary()} | {error, version_bump@error:release_error()}.
next_version(Last, Rtype, Branch, Mode) ->
    case Last of
        none ->
            Base = case Mode of
                initial_development ->
                    <<"0.1.0"/utf8>>;

                stable ->
                    <<"1.0.0"/utf8>>
            end,
            case erlang:element(5, Branch) of
                {some, Id} ->
                    {ok,
                        <<<<<<Base/binary, "-"/utf8>>/binary, Id/binary>>/binary,
                            ".1"/utf8>>};

                none ->
                    {ok, Base}
            end;

        {some, Release} ->
            gleam@result:'try'(
                version_bump@semver:parse(erlang:element(2, Release)),
                fun(Version) ->
                    Effective = version_bump@semver:effective_release_type(
                        Version,
                        Rtype,
                        Mode
                    ),
                    case erlang:element(5, Branch) of
                        {some, Id@1} ->
                            {ok,
                                version_bump@semver:to_string(
                                    version_bump@semver:bump_with_prerelease(
                                        Version,
                                        Effective,
                                        Id@1
                                    )
                                )};

                        none ->
                            {ok,
                                version_bump@semver:to_string(
                                    version_bump@semver:bump(Version, Effective)
                                )}
                    end
                end
            )
    end.