src/rebar3_hex_build.erl

% @doc `rebar3 hex build' - Build packages and docs
%
%% Builds a new local version of your package.
%%
%% By default this provider will build both a package tarball and docs tarball.
%%
%% The package and docs .tar files are created in the current directory, but is not pushed to the repository. An app
%% named foo at version 1.2.3 will be built as foo-1.2.3.tar. Likewise the docs .tar would be built as
%% foo-1.2.4-docs.tar.
%%
%% ```
%% $ rebar3 hex build
%% '''
%%
%% You may also build only a package or docs tarball utilizing the same available command line options.
%%
%% ```
%% $ rebar3 hex build package
%% '''
%%
%% ```
%% $ rebar3 hex build docs
%% '''
%%
%% <h2>Configuration</h2>
%% Packages are configured via `src/<myapp>.app.src'  attributes.
%%
%% == Required configuration ==
%%
%% <ul>
%%  <li>
%%      `application' - application name. This is required per Erlang/OTP thus it should always be present anyway.
%%  </li>
%   <li>
%       `vsn' -  must be a valid <a href="https://semver.org/">semantic version</a> identifier.
%   </li>
%%  <li>
%%      `licenses' - A list of licenses the project is licensed under. This attribute is required and must be a valid
%%      <a href="https://spdx.org/licenses/">spdx</a> identifier.
%%  </li>
%% </ul>
%%
%%
%% == Optional configuration ==
%% In addition, the following meta attributes are supported and highly recommended :
%%
%% <ul>
%%  <li>
%%      `description' - a brief description about your application.
%%  </li>
%%
%%  <li>
%%      `pkg_name' - The name of the package in case you want to publish the package with a different name than the
%%       application name.
%%  </li>
%%
%%  <li>
%%      `links' - A map where the key is a link name and the value is the link URL. Optional but highly
%%      recommended.
%%  </li>
%%
%%  <li> `files' - A list of files and directories to include in the package. Defaults to standard project directories,
%%        so you usually don't need to set this property.
%%  </li>
%%  <li>
%%      `include_paths' - A list of paths containing files you wish to include in a release.
%%  </li>
%%  <li>
%%      `exclude_paths' - A list of paths containing files you wish to exclude in a release.
%%  </li>
%%  <li>
%%      `exclude_patterns' - A list of regular expressions used to exclude files that may have been accumulated via
%%      `files' and `include_paths' and standard project paths.
%%  </li>
%%  <li>
%%      `build_tools' - List of build tools that can build the package. It's very rare that you need to set this.
%%  </li>
%% </ul>
%%
%% Below is an example :
%%
%% ```
%% {application, myapp,
%%  [{description, "An Erlang/OTP application"},
%%   {vsn, "0.1.0"},
%%   {modules, []},
%%   {registered, []},
%%   {applications, [kernel,
%%                   stdlib,
%%                   ]},
%%   {licenses, ["Apache-2.0"]},
%%   {links, [{"GitHub", "https://github.com/my_name/myapp"}]}]}.
%% '''
%%
%% <h2> Command line options </h2>
%%
%% <ul>
%%  <li> `-r', `--repo' - Specify the repository to work with. This option is required when
%%      you have multiple repositories configured, including organizations. The argument must
%%      be a fully qualified repository name (e.g, `hexpm', `hexpm:my_org', `my_own_hexpm').
%%      Defaults to `hexpm'.
%%   </li>
%%   <li> `-u', `--unpack' - Builds the tarball and unpacks contents into a directory. Useful for making sure the tarball
%%        contains all needed files before publishing. See --output below for setting the output path.
%%   </li>
%%   <li> `-o', `--output' - Sets output path. When used with --unpack it means the directory
%%   (Default: `<app>-<version>'). Otherwise, it specifies tarball path (Default: `<app>-<version>.tar').
%%   Artifacts will be written to `_build/<profile>/lib/<your_app>/' by default.
%%   </li>
%% </ul>

-module(rebar3_hex_build).

-export([create_package/2, create_docs/3, create_docs/4]).

-include("rebar3_hex.hrl").

-define(DEFAULT_FILES, [
    "src",
    "c_src",
    "include",
    "rebar.config.script",
    "priv",
    "rebar.config",
    "rebar.lock",
    "CHANGELOG*",
    "changelog*",
    "README*",
    "readme*",
    "LICENSE*",
    "license*",
    "NOTICE"
]).

-define(DEPS, [{default, compile}, {default, lock}]).
-define(PROVIDER, build).
-define(DEFAULT_DOC_DIR, "doc").

-export([
    init/1,
    do/1,
    format_error/1
]).

%% Helpers
-export([doc_opts/2]).

%% ===================================================================
%% Public API
%% ===================================================================

%% @private
-spec init(rebar_state:t()) -> {ok, rebar_state:t()}.
init(State) ->
    Provider = providers:create([
        {name, ?PROVIDER},
        {module, ?MODULE},
        {namespace, hex},
        {bare, true},
        {deps, ?DEPS},
        {example, "rebar3 hex build"},
        {short_desc, "Builds a new local version of your package and docs."},
        {desc, ""},
        {opts, [
            rebar3_hex:repo_opt(),
            {app, $a, "app", {string, undefined}, "Specify the app to build."},
            {output_dir, $o, "output", {string, undefined}, "Specify the directory to output artifacts to."},
            {unpack, $u, "unpack", {boolean, false}, "Unpack the contents of tarballs generated vs writing them out to the filesystem."}
        ]}
    ]),
    State1 = rebar_state:add_provider(State, Provider),
    {ok, State1}.

%% @private
-spec do(rebar_state:t()) -> {ok, rebar_state:t()}.
do(State) ->
    Task = rebar3_hex:task_state(State, get_repo(State)),
    handle_task(Task).

get_repo(State) ->
     {Args, _} = rebar_state:command_parsed_args(State),
     case proplists:get_value(repo, Args, undefined) of
         undefined ->
             rebar3_hex_config:default_repo(State);
         RepoName ->
            rebar3_hex_config:repo(State, RepoName)
     end.

%% @private
-spec format_error(any()) -> iolist().
format_error({build_package, Error}) when is_list(Error) ->
    io_lib:format("Error building package : ~ts", [Error]);

format_error({build_package, {error, Error}}) when is_list(Error) ->
    io_lib:format("Error building package : ~ts", [Error]);

format_error({build_docs, {error, no_doc_config}}) ->
    no_doc_config_messsage();

format_error({build_docs, {error, {doc_provider_not_found, PrvName}}}) ->
    doc_provider_not_found(PrvName);

format_error({build_docs, {error, missing_doc_index}}) ->
    doc_missing_index_message();

format_error({build_docs, Error}) when is_list(Error) ->
    io_lib:format("Error building docs : ~ts", [Error]);

format_error(repo_required_for_docs) ->
   Str =  "Error :~n\tA repo argument is required when building docs if multiple repos exist"
    " and at least one has doc configuration.~n\tSpecify a repo argument or run"
    " rebar3 hex build package if you only need to build a package.",
    io_lib:format(Str, []);

format_error(app_switch_required) ->
     "--app switch is required when building packages or docs in a umbrella with multiple apps";

format_error(Reason) ->
    rebar3_hex_error:format_error(Reason).

no_doc_config_messsage() ->
    "No doc provider has been specified in your hex config.\n"
    "Be sure to add a doc provider to the hex config you rebar configuration file.\n\n"
    "Example : {hex, [{doc, ex_doc}]\n".

doc_missing_index_message() ->
    "An index.html file was not found in docs after running docs provider.\n"
    "Be sure the docs provider is configured correctly and double check it by running it on its own\n".

doc_provider_not_found(Provider) ->
    io_lib:format("The doc provider ~ts specified in your hex config could not be found", [Provider]).

handle_task(#{apps := [_,_|_]}) ->
    ?RAISE(app_switch_required);

handle_task(#{state := State, repo := Repo, apps := [App], args := #{task := docs} = Args}) ->
    case create_docs(State, Repo, App) of
        {ok, Docs} ->
            AbsDir = write_or_unpack(App, Docs, Args),
            rebar3_hex_io:say("Your docs can be inspected at ~ts", [AbsDir]),
            {ok, State};
        Error ->
            ?RAISE({build_docs, Error})
    end;

handle_task(#{state := State, apps := [App], args := #{task := package} = Args}) ->
    case create_package(State, App) of
        {ok, Pkg} ->
            AbsDir = write_or_unpack(App, Pkg, Args),
            rebar3_hex_io:say("Your package contents can be inspected at ~ts", [AbsDir]),
            {ok, State};
        Error ->
            ?RAISE({build_package, Error})
    end;

handle_task(#{state := State, repo := Repo, apps := [App], args := Args}) ->
    case create_package(State, App) of
        {ok, Pkg} ->
            AbsOutput = write_or_unpack(App, Pkg, Args),
            rebar3_hex_io:say("Your package tarball is available at ~ts", [AbsOutput]),
            case create_docs(State, Repo, App) of
                {ok, Docs} ->
                    AbsFile = write_or_unpack(App, Docs, Args),
                    rebar3_hex_io:say("Your docs tarball is available at ~ts", [AbsFile]),
                    {ok, State};
                {error, no_doc_config} ->
                    rebar_api:warn(no_doc_config_messsage(), []),
                    {ok, State};
                {error, {doc_provider_not_found, PrvName}} ->
                    rebar_api:warn(doc_provider_not_found(PrvName), []),
                    {ok, State};
                {error, missing_doc_index} ->
                    rebar_api:warn(doc_missing_index_message(), []),
                    {ok, State};
                Error ->
                    ?RAISE({build_docs, Error})
            end;
        Error ->
            ?RAISE({build_package, Error})
    end.

output_path(docs, Name, Version, #{unpack := true}) ->
    io_lib:format("~ts-~ts-docs", [Name, Version]);
output_path(docs, Name, Version, _Args) ->
    io_lib:format("~ts-~ts-docs.tar", [Name, Version]);
output_path(package, Name, Version, #{unpack := true}) ->
    io_lib:format("~ts-~ts", [Name, Version]);
output_path(package, Name, Version, _Args) ->
    io_lib:format("~ts-~ts.tar", [Name, Version]).

write_or_unpack(App, #{type := Type, tarball := Tarball, name := Name, version := Version}, Args) ->
    OutputDir = output_dir(App, Args),
    Out = output_path(Type, Name, Version, Args),
    AbsOut = filename:join(OutputDir, Out),
    case Args of
        #{unpack := true} ->
            file:make_dir(AbsOut),
            case Type of
                docs ->
                    hex_tarball:unpack_docs(Tarball, AbsOut);
                package ->
                    hex_tarball:unpack(Tarball, AbsOut)
            end;
        _ ->
            file:write_file(AbsOut, Tarball)
    end,
    AbsOut.

%% We are exploiting a feature of ensuredir that that creates all
%% directories up to the last element in the filename, then ignores
%% that last element. This way we ensure that the dir is created
%% and not have any worries about path names
output_dir(App, #{output_dir := undefined}) ->
    Dir = filename:join([rebar_app_info:out_dir(App), "hex"]),
    filelib:ensure_dir(filename:join(Dir, "tmp")),
    Dir;
output_dir(_App, #{output_dir := Output}) ->
    Dir = filename:join(filename:absname(Output), "tmp"),
    filelib:ensure_dir(Dir),
    Dir;
output_dir(App, _) ->
    Dir = filename:join([rebar_app_info:out_dir(App), "hex"]),
    filelib:ensure_dir(filename:join(Dir, "tmp")),
    Dir.

%% @private
create_package(State, App) ->
    Name = rebar_app_info:name(App),
    Version = rebar3_hex_app:vcs_vsn(State, App),
    {application, _, AppDetails} = rebar3_hex_file:update_app_src(App, Version),

    LockDeps = rebar_state:get(State, {locks, default}, []),
    case rebar3_hex_app:get_deps(LockDeps) of
        {ok, TopLevel} ->
            AppDir = rebar_app_info:dir(App),
            Config = rebar_config:consult(AppDir),
            ConfigDeps = proplists:get_value(deps, Config, []),
            Deps1 = update_versions(ConfigDeps, TopLevel),
            Description = proplists:get_value(description, AppDetails, ""),
            PackageFiles = include_files(Name, AppDir, AppDetails),
            Licenses = proplists:get_value(licenses, AppDetails, []),
            Links = proplists:get_value(links, AppDetails, []),
            BuildTools = proplists:get_value(build_tools, AppDetails, [<<"rebar3">>]),

            %% We check the app file for the 'pkg' key which allows us to select
            %% a package name other then the app name, if it is not set we default
            %% back to the app name.
            PkgName = binarify(proplists:get_value(pkg_name, AppDetails, Name)),

            Optional = [
                {<<"app">>, Name},
                {<<"parameters">>, []},
                {<<"description">>, binarify(Description)},
                {<<"files">>, [binarify(File) || {File, _} <- PackageFiles]},
                {<<"licenses">>, binarify(Licenses)},
                {<<"links">>, to_map(binarify(Links))},
                {<<"build_tools">>, binarify(BuildTools)}
            ],
            OptionalFiltered = [{Key, Value} || {Key, Value} <- Optional, Value =/= []],
            Metadata = maps:from_list([
                {<<"name">>, PkgName},
                {<<"version">>, binarify(Version)},
                {<<"requirements">>, maps:from_list(Deps1)}
                | OptionalFiltered
            ]),

            case create_package_tarball(Metadata, PackageFiles) of
                {error, _} = Err ->
                    Err;
                Tarball ->
                    Package = #{
                        type => package,
                        name => PkgName,
                        deps => Deps1,
                        version => Version,
                        metadata => Metadata,
                        files => PackageFiles,
                        tarball => Tarball,
                        has_checkouts => has_checkouts(State)
                    },
                    {ok, Package}
            end;
        Error ->
            Error
    end.

update_versions(ConfigDeps, LockDeps) ->
    [
        begin
            case lists:keyfind(binary_to_atom(N, utf8), 1, ConfigDeps) of
                {_, V} when is_binary(V) ->
                    Req = {<<"requirement">>, V},
                    {N, maps:from_list(lists:keyreplace(<<"requirement">>, 1, M, Req))};
                {_, V} when is_list(V) ->
                    Req = {<<"requirement">>, binarify(V)},
                    {N, maps:from_list(lists:keyreplace(<<"requirement">>, 1, M, Req))};
                _ ->
                    %% using version from lock. prepend ~> to make it looser
                    {_, Version} = lists:keyfind(<<"requirement">>, 1, M),
                    Req = {<<"requirement">>, <<"~>", Version/binary>>},
                    {N, maps:from_list(lists:keyreplace(<<"requirement">>, 1, M, Req))}
            end
        end
     || {N, M} <- LockDeps
    ].

include_files(Name, AppDir, AppDetails) ->
    AppSrc = {application, to_atom(Name), AppDetails},
    FilePaths = proplists:get_value(files, AppDetails, ?DEFAULT_FILES),
    %% In versions prior to v7 the name of the for including paths and excluding paths was include_files and
    %% exclude_files. We don't document this anymore, but we do support it to avoid breaking changes. However,
    %% users should be instructed to use *_paths. Likewise for exclude_regexps which is now documented as
    %% exclude_patterns.
    IncludePaths = proplists:get_value(include_paths, AppDetails, proplists:get_value(include_files, AppDetails, [])),
    ExcludePaths = proplists:get_value(exclude_paths, AppDetails, proplists:get_value(exclude_files, AppDetails, [])),
    ExcludeRes = proplists:get_value(exclude_patterns, AppDetails, proplists:get_value(exclude_regexps, AppDetails, [])),

    AllFiles = lists:ukeysort(2, rebar3_hex_file:expand_paths(FilePaths, AppDir)),
    IncludeFiles = lists:ukeysort(2, rebar3_hex_file:expand_paths(IncludePaths, AppDir)),
    ExcludeFiles = lists:ukeysort(2, rebar3_hex_file:expand_paths(ExcludePaths, AppDir)),

    %% We filter first and then include, that way glob excludes can be
    %% overwritten be explict includes
    FilterExcluded = lists:filter(
        fun({_, Path}) ->
            not exclude_file(Path, ExcludeFiles, ExcludeRes)
        end,
        AllFiles
    ),
    WithIncludes = lists:ukeymerge(2, FilterExcluded, IncludeFiles),

    AppFileSrc = filename:join("src", rebar_utils:to_list(Name) ++ ".app.src"),
    AppSrcBinary = binarify(lists:flatten(io_lib:format("~tp.\n", [AppSrc]))),
    lists:keystore(AppFileSrc, 1, WithIncludes, {AppFileSrc, AppSrcBinary}).

exclude_file(Path, ExcludeFiles, ExcludeRe) ->
    lists:keymember(Path, 2, ExcludeFiles) orelse
        known_exclude_file(Path, ExcludeRe).

known_exclude_file(Path, ExcludeRe) ->
    KnownExcludes = [
        %% emacs temp files
        "~$",
        %% c object files
        "\\.o$",
        %% compiled nif libraries
        "\\.so$",
        %% vim swap files
        "\\.swp$"
    ],
    lists:foldl(
        fun
            (_, true) -> true;
            (RE, false) -> re:run(Path, RE) =/= nomatch
        end,
        false,
        KnownExcludes ++ ExcludeRe
    ).

%% Note that we return a list
has_checkouts(State) ->
    filelib:is_dir(rebar_dir:checkouts_dir(State)).

%% @private
create_docs(State, Repo, App) ->
    create_docs(State, Repo, App, #{doc_dir => undefined}).

%% @private
-dialyzer({nowarn_function, create_docs/4}).
create_docs(State, Repo, App, Args) ->
    case maybe_gen_docs(State, Repo, App, Args) of
        {ok, DocDir} ->
            case docs_detected(DocDir) of
                true ->
                    AppDetails = rebar_app_info:app_details(App),
                    Files = rebar3_hex_file:expand_paths([DocDir], DocDir),
                    Name = rebar_utils:to_list(rebar_app_info:name(App)),
                    PkgName = rebar_utils:to_list(proplists:get_value(pkg_name, AppDetails, Name)),
                    OriginalVsn = rebar_app_info:original_vsn(App),
                    Vsn = rebar_utils:vcs_vsn(App, OriginalVsn, State),
                    case create_docs_tarball(Files) of
                        {ok, Tarball} ->
                            {ok, #{
                                type => docs, tarball => Tarball, name => binarify(PkgName), version => binarify(Vsn)
                            }};
                        {error, Reason} ->
                            {error, hex_tarball:format_error(Reason)};
                        Err ->
                            Err
                    end;
                false ->
                    {error, missing_doc_index}
            end;
        {error, _} = Err ->
            Err;
        Err ->
            {error, Err}
    end.

maybe_gen_docs(_State, _Repo, App, #{doc_dir := DocDir}) when is_list(DocDir) ->
    AppDir = rebar_app_info:dir(App),
    {ok, filename:absname(filename:join(AppDir, DocDir))};
maybe_gen_docs(State, Repo, App, _Args) ->
    case doc_opts(State, Repo) of
        {ok, PrvName} ->
            case providers:get_provider(PrvName, rebar_state:providers(State)) of
                not_found ->
                    {error, {doc_provider_not_found, PrvName}};
                Prv ->
                    case providers:do(Prv, State) of
                        {ok, _State1} ->
                            {ok, resolve_dir(App, PrvName)};
                        _ ->
                            {error, {doc_provider_failed, PrvName}}
                    end
            end;
        _ ->
            {error, no_doc_config}
    end.

resolve_dir(App, PrvName) ->
    AppDir = rebar_app_info:dir(App),
    AppOpts = rebar_app_info:opts(App),
    DocOpts =
        case PrvName of
            edoc ->
                rebar_opts:get(AppOpts, edoc_opts, []);
            _ ->
                rebar_opts:get(AppOpts, PrvName, [])
        end,
    DocDir = proplists:get_value(dir, DocOpts, ?DEFAULT_DOC_DIR),
    filename:absname(filename:join(AppDir, DocDir)).

docs_detected(DocDir) ->
    filelib:is_file(DocDir ++ "/index.html").

-spec doc_opts(rebar_state:t(), map()) -> {ok, atom()} | undefined.
doc_opts(State, Repo) ->
    case Repo of
        #{doc := #{provider := PrvName}} when is_atom(PrvName) ->
            Deprecation = "Setting doc options in repo configuration has been deprecated."
                          " You should configure a docmentation provider in top level hex "
                          " configuration now.",
            rebar_api:warn(Deprecation, []),
            {ok, PrvName};
        _ ->
            Opts = rebar_state:opts(State),
            case proplists:get_value(doc, rebar_opts:get(Opts, hex, []), undefined) of
                undefined -> undefined;
                PrvName when is_atom(PrvName) -> {ok, PrvName};
                #{provider := PrvName} -> {ok, PrvName};
                _ -> undefined
            end
    end.

binarify(Term) when is_boolean(Term) ->
    Term;
binarify(Term) when is_atom(Term) ->
    atom_to_binary(Term, utf8);
binarify([]) ->
    [];
binarify(Map) when is_map(Map) ->
    maps:from_list(binarify(maps:to_list(Map)));
binarify(Term) when is_list(Term) ->
    case io_lib:printable_unicode_list(Term) of
        true ->
            rebar_utils:to_binary(Term);
        false ->
            [binarify(X) || X <- Term]
    end;
binarify({Key, Value}) ->
    {binarify(Key), binarify(Value)};
binarify(Term) ->
    Term.

-dialyzer({nowarn_function, create_package_tarball/2}).
create_package_tarball(Metadata, Files) ->
    case hex_tarball:create(Metadata, Files) of
        {ok, #{tarball := Tarball, inner_checksum := _Checksum}} ->
            Tarball;
        {error, Reason} ->
            {error, hex_tarball:format_error(Reason)};
        Error ->
            Error
    end.

-dialyzer({nowarn_function, create_docs_tarball/1}).
create_docs_tarball(Files) ->
    case hex_tarball:create_docs(Files) of
        {ok, Tarball} ->
            {ok, Tarball};
        Error ->
            Error
    end.

-spec to_atom(atom() | string() | binary() | integer() | float()) ->
    atom().
to_atom(X) when erlang:is_atom(X) ->
    X;
to_atom(X) when erlang:is_list(X) ->
    list_to_existing_atom(X);
to_atom(X) ->
    to_atom(rebar_utils:to_list(X)).

to_map(Map) when is_map(Map) ->
    Map;
to_map(List) when is_list(List) ->
    maps:from_list(List).