Skip to main content

src/version_bump@plugins@hex.erl

-module(version_bump@plugins@hex).
-compile([no_auto_import, nowarn_unused_vars, nowarn_unused_function, nowarn_nomatch, inline]).
-define(FILEPATH, "src/version_bump/plugins/hex.gleam").
-export([package_name/1, published_ok/1, set_version/2, plugin/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 Hex / Gleam publish plugin (plugin name \"hex\").\n"
    "\n"
    " The Gleam analogue of `@semantic-release/npm`. It publishes the package to\n"
    " the Hex package repository with `gleam publish`. Two things differ from npm:\n"
    " the version lives in `gleam.toml` (not `package.json`), and Hex has no\n"
    " dist-tag / channel concept, so `add_channel` is intentionally NOT implemented\n"
    " (a prerelease is published as an ordinary semver prerelease and Hex surfaces\n"
    " it as such).\n"
    "\n"
    "   - verify_conditions: a `gleam.toml` exists in `context.cwd` carrying the\n"
    "     `description` and `licences` fields that `gleam publish` requires, and a\n"
    "     `HEXPM_API_KEY` is available (skipped on a dry run, since a dry run never\n"
    "     publishes — matching the npm/github plugins here).\n"
    "   - prepare: rewrite the top-level `version` field of `gleam.toml`.\n"
    "   - publish: run `gleam publish --yes` and report the hex.pm release URL.\n"
    "\n"
    " `set_version` and `package_name` are PURE string functions, exported so the\n"
    " version rewrite and URL construction can be unit-tested without any IO.\n"
).

-file("src/version_bump/plugins/hex.gleam", 288).
?DOC(" Compile a multi-line regexp so `^` anchors to each line start.\n").
-spec compile_ml(binary()) -> {ok, gleam@regexp:regexp()} | {error, binary()}.
compile_ml(Pattern) ->
    _pipe = gleam@regexp:compile(Pattern, {options, false, true}),
    gleam@result:map_error(
        _pipe,
        fun(Err) ->
            <<"invalid regexp: "/utf8, (erlang:element(2, Err))/binary>>
        end
    ).

-file("src/version_bump/plugins/hex.gleam", 273).
?DOC(" Read the package `name` from a `gleam.toml` document. PURE.\n").
-spec package_name(binary()) -> {ok, binary()} |
    {error, version_bump@error:release_error()}.
package_name(Gleam_toml) ->
    case compile_ml(<<"^[ \t]*name[ \t]*=[ \t]*\"([^\"]*)\""/utf8>>) of
        {error, Message} ->
            {error, {plugin_error, <<"hex"/utf8>>, Message}};

        {ok, Re} ->
            case gleam@regexp:scan(Re, Gleam_toml) of
                [{match, _, [{some, Name}]} | _] ->
                    {ok, Name};

                _ ->
                    {error,
                        {plugin_error,
                            <<"hex"/utf8>>,
                            <<"no `name` field found in gleam.toml"/utf8>>}}
            end
    end.

-file("src/version_bump/plugins/hex.gleam", 309).
?DOC(" The path to `gleam.toml` within the working directory.\n").
-spec gleam_toml_path(binary()) -> binary().
gleam_toml_path(Cwd) ->
    case gleam_stdlib:string_ends_with(Cwd, <<"/"/utf8>>) of
        true ->
            <<Cwd/binary, "gleam.toml"/utf8>>;

        false ->
            <<Cwd/binary, "/gleam.toml"/utf8>>
    end.

-file("src/version_bump/plugins/hex.gleam", 317).
?DOC(" Read a file, mapping any failure to a `PluginError`.\n").
-spec read_file(binary()) -> {ok, binary()} |
    {error, version_bump@error:release_error()}.
read_file(Path) ->
    _pipe = simplifile:read(Path),
    gleam@result:map_error(
        _pipe,
        fun(Err) ->
            {plugin_error,
                <<"hex"/utf8>>,
                <<<<<<"could not read "/utf8, Path/binary>>/binary, ": "/utf8>>/binary,
                    (simplifile:describe_error(Err))/binary>>}
        end
    ).

-file("src/version_bump/plugins/hex.gleam", 236).
?DOC(
    " Best-effort hex.pm URL for the published release; `None` if the package name\n"
    " cannot be read (publishing already succeeded, so this is not fatal).\n"
).
-spec release_url(binary(), binary()) -> gleam@option:option(binary()).
release_url(Cwd, Version) ->
    case read_file(gleam_toml_path(Cwd)) of
        {ok, Contents} ->
            case package_name(Contents) of
                {ok, Name} ->
                    {some,
                        <<<<<<"https://hex.pm/packages/"/utf8, Name/binary>>/binary,
                                "/"/utf8>>/binary,
                            Version/binary>>};

                {error, _} ->
                    none
            end;

        {error, _} ->
            none
    end.

-file("src/version_bump/plugins/hex.gleam", 230).
?DOC(
    " True when `gleam publish` output confirms a successful publish. gleam prints\n"
    " \"Published package and documentation\" on success; the lowercase \"published\"\n"
    " in its <1.0.0 warning does not contain this marker, so an aborted publish is\n"
    " correctly treated as a failure. Exposed for testing.\n"
).
-spec published_ok(binary()) -> boolean().
published_ok(Output) ->
    gleam_stdlib:contains_string(Output, <<"Published package"/utf8>>).

-file("src/version_bump/plugins/hex.gleam", 188).
?DOC(
    " Run `gleam publish` and confirm from its output that the package was actually\n"
    " published — never trusting the exit code alone.\n"
).
-spec run_gleam_publish(binary()) -> {ok, nil} |
    {error, version_bump@error:release_error()}.
run_gleam_publish(Cwd) ->
    Publish = <<"echo 'I am not using semantic versioning' | gleam publish --yes"/utf8>>,
    case shellout:command(<<"sh"/utf8>>, [<<"-c"/utf8>>, Publish], Cwd, []) of
        {ok, Output} ->
            case published_ok(Output) of
                true ->
                    {ok, nil};

                false ->
                    {error,
                        {plugin_error,
                            <<"hex"/utf8>>,
                            <<<<<<"`gleam publish` exited 0 but did not report a successful publish "/utf8,
                                        "(no \"Published package\" in its output) — it likely aborted a "/utf8>>/binary,
                                    "prompt. Output:\n"/utf8>>/binary,
                                (gleam@string:trim(Output))/binary>>}}
            end;

        {error, {Code, Message}} ->
            {error,
                {plugin_error,
                    <<"hex"/utf8>>,
                    <<<<<<"`gleam publish` failed (exit "/utf8,
                                (erlang:integer_to_binary(Code))/binary>>/binary,
                            "): "/utf8>>/binary,
                        (gleam@string:trim(Message))/binary>>}}
    end.

-file("src/version_bump/plugins/hex.gleam", 340).
?DOC(
    " Extract the next release from the context, failing when the pipeline has not\n"
    " determined one (handled rather than asserted).\n"
).
-spec require_next_release(version_bump@context:context()) -> {ok,
        version_bump@release:next_release()} |
    {error, version_bump@error:release_error()}.
require_next_release(Context) ->
    case erlang:element(9, Context) of
        {some, Next} ->
            {ok, Next};

        none ->
            {error,
                {plugin_error,
                    <<"hex"/utf8>>,
                    <<"no next release determined"/utf8>>}}
    end.

-file("src/version_bump/plugins/hex.gleam", 166).
?DOC(
    " Run `gleam publish --yes` in `context.cwd` and report the resulting release. The\n"
    " `HEXPM_API_KEY` in the environment is inherited by the subprocess.\n"
).
-spec publish(version_bump@config:plugin_spec(), version_bump@context:context()) -> version_bump@task:task({ok,
        gleam@option:option(version_bump@release:release())} |
    {error, version_bump@error:release_error()}).
publish(_, Context) ->
    version_bump_task_ffi:resolve(
        begin
            gleam@result:'try'(
                require_next_release(Context),
                fun(Next) ->
                    gleam@result:'try'(
                        run_gleam_publish(erlang:element(2, Context)),
                        fun(_) ->
                            {ok,
                                {some,
                                    {release,
                                        <<"hex"/utf8>>,
                                        release_url(
                                            erlang:element(2, Context),
                                            erlang:element(2, Next)
                                        ),
                                        erlang:element(2, Next),
                                        erlang:element(4, Next),
                                        erlang:element(6, Next),
                                        <<"hex"/utf8>>}}}
                        end
                    )
                end
            )
        end
    ).

-file("src/version_bump/plugins/hex.gleam", 328).
?DOC(" Write a file, mapping any failure to a `PluginError`.\n").
-spec write_file(binary(), binary()) -> {ok, nil} |
    {error, version_bump@error:release_error()}.
write_file(Path, Contents) ->
    _pipe = simplifile:write(Path, Contents),
    gleam@result:map_error(
        _pipe,
        fun(Err) ->
            {plugin_error,
                <<"hex"/utf8>>,
                <<<<<<"could not write "/utf8, Path/binary>>/binary, ": "/utf8>>/binary,
                    (simplifile:describe_error(Err))/binary>>}
        end
    ).

-file("src/version_bump/plugins/hex.gleam", 297).
?DOC(" Replace only the first occurrence of `needle` in `haystack`.\n").
-spec replace_first(binary(), binary(), binary()) -> binary().
replace_first(Haystack, Needle, Replacement) ->
    case gleam@string:split_once(Haystack, Needle) of
        {ok, {Before, After}} ->
            <<<<Before/binary, Replacement/binary>>/binary, After/binary>>;

        {error, _} ->
            Haystack
    end.

-file("src/version_bump/plugins/hex.gleam", 251).
?DOC(
    " Replace the top-level `version = \"...\"` value in a `gleam.toml` document with\n"
    " `version`, leaving the rest of the file intact. PURE.\n"
).
-spec set_version(binary(), binary()) -> {ok, binary()} |
    {error, version_bump@error:release_error()}.
set_version(Gleam_toml, Version) ->
    case compile_ml(<<"(^[ \t]*version[ \t]*=[ \t]*\")([^\"]*)\""/utf8>>) of
        {error, Message} ->
            {error, {plugin_error, <<"hex"/utf8>>, Message}};

        {ok, Re} ->
            case gleam@regexp:scan(Re, Gleam_toml) of
                [{match, Matched, [{some, Prefix}, _]} | _] ->
                    Replacement = <<<<Prefix/binary, Version/binary>>/binary,
                        "\""/utf8>>,
                    {ok, replace_first(Gleam_toml, Matched, Replacement)};

                _ ->
                    {error,
                        {plugin_error,
                            <<"hex"/utf8>>,
                            <<"no top-level `version` field found in gleam.toml"/utf8>>}}
            end
    end.

-file("src/version_bump/plugins/hex.gleam", 154).
?DOC(" Rewrite `gleam.toml`'s `version` to the next release version, in place.\n").
-spec prepare(version_bump@config:plugin_spec(), version_bump@context:context()) -> {ok,
        nil} |
    {error, version_bump@error:release_error()}.
prepare(_, Context) ->
    gleam@result:'try'(
        require_next_release(Context),
        fun(Next) ->
            Path = gleam_toml_path(erlang:element(2, Context)),
            gleam@result:'try'(
                read_file(Path),
                fun(Contents) ->
                    gleam@result:'try'(
                        set_version(Contents, erlang:element(2, Next)),
                        fun(Updated) -> write_file(Path, Updated) end
                    )
                end
            )
        end
    ).

-file("src/version_bump/plugins/hex.gleam", 128).
?DOC(
    " Resolve the Hex API key from `context.env`, falling back to the live process\n"
    " environment. Empty/whitespace-only values are treated as absent.\n"
).
-spec api_key(version_bump@context:context()) -> gleam@option:option(binary()).
api_key(Context) ->
    From_ctx = case gleam_stdlib:map_get(
        erlang:element(3, Context),
        <<"HEXPM_API_KEY"/utf8>>
    ) of
        {ok, Value} ->
            {some, Value};

        {error, _} ->
            none
    end,
    Key = case From_ctx of
        {some, Value@1} ->
            {some, Value@1};

        none ->
            case envoy_ffi:get(<<"HEXPM_API_KEY"/utf8>>) of
                {ok, Value@2} ->
                    {some, Value@2};

                {error, _} ->
                    none
            end
    end,
    case Key of
        {some, Value@3} ->
            case gleam@string:trim(Value@3) of
                <<""/utf8>> ->
                    none;

                Trimmed ->
                    {some, Trimmed}
            end;

        none ->
            none
    end.

-file("src/version_bump/plugins/hex.gleam", 114).
?DOC(
    " Verify that a `HEXPM_API_KEY` is available, checking the context environment\n"
    " first and falling back to the live process environment.\n"
).
-spec ensure_api_key(version_bump@context:context()) -> {ok, nil} |
    {error, version_bump@error:release_error()}.
ensure_api_key(Context) ->
    case api_key(Context) of
        {some, _} ->
            {ok, nil};

        none ->
            {error,
                {plugin_error,
                    <<"hex"/utf8>>,
                    <<"HEXPM_API_KEY is not set (run `gleam hex authenticate` to create a key, "/utf8,
                        "then expose it as HEXPM_API_KEY in CI)"/utf8>>}}
    end.

-file("src/version_bump/plugins/hex.gleam", 101).
?DOC(
    " True when `field` appears as an uncommented top-level key. A leading `#`\n"
    " (comment) prevents a match, so the commented placeholders in a freshly\n"
    " scaffolded `gleam.toml` are correctly treated as absent.\n"
).
-spec field_present(binary(), binary()) -> boolean().
field_present(Contents, Field) ->
    case compile_ml(<<<<"^[ \t]*"/utf8, Field/binary>>/binary, "[ \t]*="/utf8>>) of
        {ok, Re} ->
            case gleam@regexp:scan(Re, Contents) of
                [] ->
                    false;

                _ ->
                    true
            end;

        {error, _} ->
            false
    end.

-file("src/version_bump/plugins/hex.gleam", 85).
-spec require_field(binary(), binary()) -> {ok, nil} |
    {error, version_bump@error:release_error()}.
require_field(Contents, Field) ->
    case field_present(Contents, Field) of
        true ->
            {ok, nil};

        false ->
            {error,
                {plugin_error,
                    <<"hex"/utf8>>,
                    <<<<"gleam.toml is missing the `"/utf8, Field/binary>>/binary,
                        "` field, which `gleam publish` requires to publish to Hex"/utf8>>}}
    end.

-file("src/version_bump/plugins/hex.gleam", 80).
?DOC(
    " `gleam publish` refuses to publish a package without `description` and\n"
    " `licences`, so verify they are present (and uncommented) up front rather than\n"
    " failing after the version has already been bumped and tagged.\n"
).
-spec ensure_publishable_metadata(binary()) -> {ok, nil} |
    {error, version_bump@error:release_error()}.
ensure_publishable_metadata(Contents) ->
    gleam@result:'try'(
        require_field(Contents, <<"description"/utf8>>),
        fun(_) -> require_field(Contents, <<"licences"/utf8>>) end
    ).

-file("src/version_bump/plugins/hex.gleam", 68).
?DOC(" Read `gleam.toml`, mapping a missing/unreadable file to a clear error.\n").
-spec read_gleam_toml(binary()) -> {ok, binary()} |
    {error, version_bump@error:release_error()}.
read_gleam_toml(Cwd) ->
    Path = gleam_toml_path(Cwd),
    case simplifile:read(Path) of
        {ok, Contents} ->
            {ok, Contents};

        {error, _} ->
            {error,
                {plugin_error,
                    <<"hex"/utf8>>,
                    <<"no gleam.toml found at "/utf8, Path/binary>>}}
    end.

-file("src/version_bump/plugins/hex.gleam", 55).
?DOC(
    " Ensure `gleam.toml` exists with the metadata `gleam publish` requires, and\n"
    " that a Hex API key is available (except on a dry run).\n"
).
-spec verify_conditions(
    version_bump@config:plugin_spec(),
    version_bump@context:context()
) -> {ok, nil} | {error, version_bump@error:release_error()}.
verify_conditions(_, Context) ->
    gleam@result:'try'(
        read_gleam_toml(erlang:element(2, Context)),
        fun(Contents) ->
            gleam@result:'try'(
                ensure_publishable_metadata(Contents),
                fun(_) -> case erlang:element(12, Context) of
                        true ->
                            {ok, nil};

                        false ->
                            ensure_api_key(Context)
                    end end
            )
        end
    ).

-file("src/version_bump/plugins/hex.gleam", 42).
?DOC(
    " Build the Hex plugin: implements `verify_conditions`, `prepare`, and\n"
    " `publish`. There is deliberately no `add_channel` — Hex has no dist-tags.\n"
).
-spec plugin() -> version_bump@plugin:plugin().
plugin() ->
    _record = version_bump@plugin:new(<<"hex"/utf8>>),
    {plugin,
        erlang:element(2, _record),
        {some, fun verify_conditions/2},
        erlang:element(4, _record),
        erlang:element(5, _record),
        erlang:element(6, _record),
        erlang:element(7, _record),
        {some, fun prepare/2},
        {some, fun publish/2},
        erlang:element(10, _record),
        erlang:element(11, _record)}.