src/nova_watcher.erl

%%%-------------------------------------------------------------------
%%% @author Niclas Axelsson <niclas@burbas.se>
%%% @doc
%%%
%%% @end
%%% Created :  3 Mar 2021 by Niclas Axelsson <niclas@burbas.se>
%%%-------------------------------------------------------------------
-module(nova_watcher).

-behaviour(gen_server).

%% API
-export([
         start_link/0,
         async_cast/4,
         async_cast/3,
         async_cast/2,
         async_cast/1,
         stop/0
        ]).

%% gen_server callbacks
-export([
         init/1,
         handle_call/3,
         handle_cast/2,
         handle_info/2,
         terminate/2,
         code_change/3,
         format_status/2
        ]).

-include_lib("kernel/include/logger.hrl").

-define(SERVER, ?MODULE).

-record(state, {
                process_refs = [] :: [pid()]
               }).

%%%===================================================================
%%% API
%%%===================================================================

%%--------------------------------------------------------------------
%% @doc
%% Starts the server
%% @end
%%--------------------------------------------------------------------
-spec start_link() -> {ok, Pid :: pid()} |
                      {error, Error :: {already_started, pid()}} |
                      {error, Error :: term()} |
                      ignore.
start_link() ->
    gen_server:start_link({local, ?SERVER}, ?MODULE, [], []).

async_cast(Application, Cmd, Args, Options) ->
    gen_server:cast(?SERVER, {async, Application, Cmd, Args, Options}).

async_cast(Application, Cmd, Options) ->
    async_cast(Application, Cmd, [], Options).

async_cast(Cmd, Options) ->
    async_cast(nova:get_main_app(), Cmd, Options).

async_cast(Cmd) ->
    async_cast(Cmd, #{}).

stop() ->
    gen_server:call(?SERVER, stop).

%%%===================================================================
%%% gen_server callbacks
%%%===================================================================

%%--------------------------------------------------------------------
%% @private
%% @doc
%% Initializes the server
%% @end
%%--------------------------------------------------------------------
-spec init(Args :: term()) -> {ok, State :: term()} |
                              {ok, State :: term(), Timeout :: timeout()} |
                              {ok, State :: term(), hibernate} |
                              {stop, Reason :: term()} |
                              ignore.
init([]) ->
    process_flag(trap_exit, true),
    CmdList = nova:get_env(watchers, []),
    [ erlang:apply(?MODULE, async_cast, tuple_to_list(X)) || X <- CmdList ],
    {ok, #state{}}.

%%--------------------------------------------------------------------
%% @private
%% @doc
%% Handling call messages
%% @end
%%--------------------------------------------------------------------
-spec handle_call(Request :: term(), From :: {pid(), term()}, State :: term()) ->
                         {reply, Reply :: term(), NewState :: term()} |
                         {reply, Reply :: term(), NewState :: term(), Timeout :: timeout()} |
                         {reply, Reply :: term(), NewState :: term(), hibernate} |
                         {noreply, NewState :: term()} |
                         {noreply, NewState :: term(), Timeout :: timeout()} |
                         {noreply, NewState :: term(), hibernate} |
                         {stop, Reason :: term(), Reply :: term(), NewState :: term()} |
                         {stop, Reason :: term(), NewState :: term()}.
handle_call(stop, _From, State) ->
    {stop, normal, ok, State};
handle_call(Request, From, State) ->
    ?LOG_ERROR(#{msg => <<"Unknown call">>, request => Request, from => From}),
    {reply, ok, State}.

%%--------------------------------------------------------------------
%% @private
%% @doc
%% Handling cast messages
%% @end
%%--------------------------------------------------------------------
-spec handle_cast(Request :: term(), State :: term()) ->
                         {noreply, NewState :: term()} |
                         {noreply, NewState :: term(), Timeout :: timeout()} |
                         {noreply, NewState :: term(), hibernate} |
                         {stop, Reason :: term(), NewState :: term()}.
handle_cast({async, Application, Cmd, Args, Options}, State = #state{process_refs = ProcessRefs}) ->
    LibDir = code:lib_dir(Application),
    Workdir =
        case maps:get(workdir, Options, undefined) of
            undefined ->
                LibDir;
            Subdir ->
                filename:join([LibDir, Subdir])
        end,
    %% Set working directory
    file:set_cwd(Workdir),
    ArgList = string:join(Args, " "),
    Port = erlang:open_port({spawn, Cmd ++ " " ++ ArgList}, [use_stdio, {line, 1024}, stderr_to_stdout]),
    ?LOG_NOTICE(#{action => <<"Started async command">>, command => Cmd, arguments => ArgList}),
    {noreply, State#state{process_refs = [Port|ProcessRefs]}};
handle_cast(_Request, State) ->
    {noreply, State}.

%%--------------------------------------------------------------------
%% @private
%% @doc
%% Handling all non call/cast messages
%% @end
%%--------------------------------------------------------------------
-spec handle_info(Info :: timeout() | term(), State :: term()) ->
                         {noreply, NewState :: term()} |
                         {noreply, NewState :: term(), Timeout :: timeout()} |
                         {noreply, NewState :: term(), hibernate} |
                         {stop, Reason :: normal | term(), NewState :: term()}.
handle_info({_ProcessRef, {data, Data}}, State) ->
    Msg = case Data of
              {eol, Text} -> Text;
              _ -> Data
          end,
    case nova:get_environment() of
        dev -> io:format(user, "~s~n", [Msg]);
        _ -> ok %% Ignore the output
    end,
    {noreply, State};
handle_info({'EXIT', Ref, Reason}, State = #state{process_refs = Refs}) ->
    %% Remove the port from our list
    Refs2 = lists:delete(Ref, Refs),
    case Reason of
        normal ->
            ok;
        _ ->
            ?LOG_WARNING(#{action => <<"Process exited unexpectedly">>, reason => Reason})
    end,
    {noreply, State#state{process_refs = Refs2}};
handle_info(_Info, State) ->
    {noreply, State}.

%%--------------------------------------------------------------------
%% @private
%% @doc
%% This function is called by a gen_server when it is about to
%% terminate. It should be the opposite of Module:init/1 and do any
%% necessary cleaning up. When it returns, the gen_server terminates
%% with Reason. The return value is ignored.
%% @end
%%--------------------------------------------------------------------
-spec terminate(Reason :: normal | shutdown | {shutdown, term()} | term(),
                State :: term()) -> any().
terminate(_Reason, #state{process_refs = Refs}) ->
    %% Clean up the ports
    lists:foreach(fun(PortRef) ->
                          erlang:port_close(PortRef)
                  end, Refs),
    ok.

%%--------------------------------------------------------------------
%% @private
%% @doc
%% Convert process state when code is changed
%% @end
%%--------------------------------------------------------------------
-spec code_change(OldVsn :: term() | {down, term()},
                  State :: term(),
                  Extra :: term()) -> {ok, NewState :: term()} |
                                      {error, Reason :: term()}.
code_change(_OldVsn, State, _Extra) ->
    {ok, State}.

%%--------------------------------------------------------------------
%% @private
%% @doc
%% This function is called for changing the form and appearance
%% of gen_server status when it is returned from sys:get_status/1,2
%% or when it appears in termination error logs.
%% @end
%%--------------------------------------------------------------------
-spec format_status(Opt :: normal | terminate,
                    Status :: list()) -> Status :: term().
format_status(_Opt, Status) ->
    Status.

%%%===================================================================
%%% Internal functions
%%%===================================================================






%%%===================================================================
%%% Tests
%%%===================================================================

-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").

simple_ls_test_() ->
    {setup,
     fun() ->
             ?MODULE:start_link()
     end,
     fun(_) ->
             ?MODULE:stop()
     end,
     fun(_) ->
             [?_assertEqual(?MODULE:async_cast("ls"), ok)]
     end}.

-endif.