src/locus_cli.erl

%% Copyright (c) 2019-2024 Guilherme Andrade
%%
%% Permission is hereby granted, free of charge, to any person obtaining a
%% copy  of this software and associated documentation files (the "Software"),
%% to deal in the Software without restriction, including without limitation
%% the rights to use, copy, modify, merge, publish, distribute, sublicense,
%% and/or sell copies of the Software, and to permit persons to whom the
%% Software is furnished to do so, subject to the following conditions:
%%
%% The above copyright notice and this permission notice shall be included in
%% all copies or substantial portions of the Software.
%%
%% THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
%% IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
%% FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
%% AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
%% LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
%% FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
%% DEALINGS IN THE SOFTWARE.
%%
%% locus is an independent project and has not been authorized, sponsored,
%% or otherwise approved by MaxMind.

%% @private
-module(locus_cli).
-ifdef(ESCRIPTIZING).

%% ------------------------------------------------------------------
%% API Function Exports
%% ------------------------------------------------------------------

-export([main/1]).

%% ------------------------------------------------------------------
%% API Function Definitions
%% ------------------------------------------------------------------

-spec main([string()]) -> ok | no_return().
main(Args) ->
    ensure_apps_are_started([locus, getopt]),
    case Args of
        ["check" | CmdArgs] ->
            handle_check_command(CmdArgs);
        ["analyze" | CmdArgs] ->
            locus_logger:log_warning("The `analyze` command is now called `check`", []),
            handle_check_command(CmdArgs);
        _ ->
            fall_from_grace(
              "~n"
              "Usage: locus [<command>] [<command_args>]~n"
              "~n"
              "Available commands:~n"
              "  check")
    end.

%% ------------------------------------------------------------------
%% Internal Function Definitions - Utils
%% ------------------------------------------------------------------

ensure_apps_are_started(Apps) ->
    lists:foreach(
      fun (App) ->
              {ok, _} = application:ensure_all_started(App)
      end,
      Apps).

prepare_database(DatabaseURL, LoadTimeout, SuccessHandler) ->
    DatabaseId = cli_check,
    BaseOpts = [{event_subscriber, self()}],
    DatabaseURLStr = unicode:characters_to_list(DatabaseURL),
    ExtraOpts =
        case locus_util:parse_absolute_http_url(DatabaseURLStr) of
            {ok, _} -> [no_cache];
            {error, _} -> []
        end,

    stderr_println("Loading database from \"~ts\"...", [DatabaseURL]),
    case locus:start_loader(DatabaseId, DatabaseURL, BaseOpts ++ ExtraOpts) of
        ok ->
            wait_for_database_load(DatabaseId, LoadTimeout, SuccessHandler)
    end.

wait_for_database_load(DatabaseId, LoadTimeout, SuccessHandler) ->
    receive
        {locus, DatabaseId, {load_attempt_finished, _, {ok, Version}}} ->
            stderr_println("Database version ~p successfully loaded", [Version]),
            SuccessHandler(DatabaseId);
        {locus, DatabaseId, {load_attempt_finished, _, {error, Reason}}} ->
            fall_from_grace("Failed to load database: ~p", [Reason])
    after
        LoadTimeout ->
            fall_from_grace("Timeout loading the database")
    end.

fall_from_grace() ->
    fall_from_grace("", []).

fall_from_grace(MsgFmt) ->
    fall_from_grace(MsgFmt, []).

fall_from_grace(MsgFmt, MsgArgs) ->
    _ = MsgFmt =/= "" andalso stderr_println("[ERROR] " ++ MsgFmt, MsgArgs),
    erlang:halt(1, [{flush, true}]).

stderr_println(Fmt) ->
    stderr_println(Fmt, []).

stderr_println(Fmt, Args) ->
    io:format(standard_error, Fmt ++ "~n", Args).

%% ------------------------------------------------------------------
%% Internal Function Definitions - Check
%% ------------------------------------------------------------------

handle_check_command(CmdArgs) ->
    OptSpecList =
        [{load_timeout, undefined, "load-timeout",
          {integer, 30}, "Database load timeout (in seconds)"},

         {log_level, undefined, "log-level",
          {string, "error"}, "debug | info | warning | error"},

         {url, undefined, undefined,
          utf8_binary, "Database URL (local or remote)"},

         {warnings_as_errors, undefined, "warnings-as-errors",
          {boolean, false}, "Treat check warnings as errors"}],

    case getopt:parse_and_check(OptSpecList, CmdArgs) of
        {ok, {ParsedArgs, []}} ->
            {load_timeout, LoadTimeoutSecs} = lists:keyfind(load_timeout, 1, ParsedArgs),
            {log_level, StrLogLevel} = lists:keyfind(log_level, 1, ParsedArgs),
            {url, DatabaseURL} = lists:keyfind(url, 1, ParsedArgs),
            {warnings_as_errors, WarningsAsErrors} = lists:keyfind(warnings_as_errors,
                                                                   1, ParsedArgs),
            LoadTimeout = timer:seconds(LoadTimeoutSecs),
            LogLevel = list_to_atom(StrLogLevel),
            ok = locus_logger:set_loglevel(LogLevel),
            prepare_database(DatabaseURL, LoadTimeout,
                             fun (DatabaseId) ->
                                     perform_check(DatabaseId, WarningsAsErrors)
                             end);
        _ ->
            getopt:usage(OptSpecList, "locus check"),
            fall_from_grace()
    end.

perform_check(DatabaseId, WarningsAsErrors) ->
    stderr_println("Checking database for flaws..."),
    case locus:check(DatabaseId) of
        ok ->
            stderr_println("Database is wholesome.");
        {validation_warnings, Warnings}
          when not WarningsAsErrors ->
            stderr_println("Database has a few quirks but is otherwise wholesome:~n"
                           ++ lists:flatten(["* ~p~n" || _ <- Warnings]),
                           WarningsAsErrors);
        {validation_warnings, Warnings}
          when WarningsAsErrors ->
            stderr_println("Database has a few quirks:~n"
                           ++ lists:flatten(["* ~p~n" || _ <- Warnings]),
                           WarningsAsErrors);
        {validation_errors, Errors, [] = _Warnings} ->
            fall_from_grace("Database is corrupt or incompatible:~n"
                            ++ lists:flatten(["* ~p~n" || _ <- Errors]),
                            Errors);
        {validation_errors, Errors, Warnings} ->
            fall_from_grace("Database is corrupt or incompatible:~n"
                            ++ lists:flatten(["* ~p~n" || _ <- Errors])
                            ++ "~n...and also:~n"
                            ++ lists:flatten(["* ~p~n" || _ <- Warnings]),
                            Errors ++ Warnings)
    end.

-endif.