lib/watcher.ex

defmodule TestIex.Watcher do
  @moduledoc """
  Watcher for file events + evtl. running a configured `cmd` function
  """
  require Logger

  defmodule State do
    defstruct watcher_pid: nil, cmd: nil, last_event: nil
  end

  use GenServer

  def start_link(args) do
    GenServer.start_link(__MODULE__, args, name: __MODULE__)
  end

  def init(args) do
    {:ok, watcher_pid} = FileSystem.start_link(args)
    FileSystem.subscribe(watcher_pid)
    {:ok, %State{watcher_pid: watcher_pid, cmd: nil, last_event: nil}}
  end

  def set_command(fun) do
    GenServer.call(__MODULE__, {:set_command, fun})
  end

  def handle_call({:set_command, fun}, _, %State{} = state) do
    state = %State{state | cmd: fun}
    {:reply, :ok, state}
  end

  def handle_info(
        {:file_event, watcher_pid, {path, events}},
        %State{watcher_pid: watcher_pid} = state
      ) do
    TestIex.Log.log_msg("file_event " <> inspect({path, events}))

    if should_run?(state, {path, events}) do
      try do
        state.cmd.()
      rescue
        e ->
          Logger.error(Exception.format(:error, e, __STACKTRACE__))
      end
    else
      TestIex.Log.log_msg("Skipping duplicate event for #{path}")
    end

    new_last_event = {path, events, now_in_ms()}
    state = %State{state | last_event: new_last_event}

    {:noreply, state}
  end

  def handle_info({:file_event, watcher_pid, :stop}, %{watcher_pid: watcher_pid} = state) do
    TestIex.Log.log_msg("file_event: STOPPING WATCHER #{inspect(watcher_pid)}")
    {:noreply, state}
  end

  defp should_run?(%State{} = state, {path, events}) do
    code_file = Path.extname(path) in [".ex", ".exs"]
    cmd_set = state.cmd != nil
    duplicate = duplicate_event?(state.last_event, {path, events})

    code_file && cmd_set && !duplicate
  end

  # first event!
  defp duplicate_event?(nil, {_path, _events}), do: false

  defp duplicate_event?({l_path, _l_events, l_time}, {path, _events}) do
    # same path + less than dedup_timeout_ms ago executed
    l_path == path && now_in_ms() - l_time < event_dedup_timeout_ms()
  end

  defp now_in_ms do
    DateTime.utc_now() |> DateTime.to_unix(:millisecond)
  end

  defp event_dedup_timeout_ms do
    TestIex.Config.event_dedup_timeout()
  end
end