lib/file_system/backends/fs_poll.ex

require Logger

defmodule SecretsWatcherFileSystem.Backends.FSPoll do
  @moduledoc """
  File system backend for any OS.

  ## Backend Options

    * `:interval` (integer, default: 1000), polling interval

  ## Using FSPoll Backend

  Unlike other backends, polling backend is never automatically chosen in any
  OS environment, despite being usable on all platforms.

  To use polling backend, one has to explicitly specify in the backend option.
  """

  use GenServer
  @behaviour SecretsWatcherFileSystem.Backend

  def bootstrap, do: :ok

  def supported_systems do
    [{:unix, :linux}, {:unix, :freebsd}, {:unix, :openbsd}, {:unix, :darwin}, {:win32, :nt}]
  end

  def known_events do
    [:created, :deleted, :modified]
  end

  def start_link(args) do
    GenServer.start_link(__MODULE__, args, [])
  end

  def init(args) do
    worker_pid = Keyword.fetch!(args, :worker_pid)
    dirs = Keyword.fetch!(args, :dirs)
    interval = Keyword.get(args, :interval, 1000)

    Logger.info("Polling file changes every #{interval}ms...")
    send(self(), :first_check)

    {:ok, {worker_pid, dirs, interval, %{}}}
  end

  def handle_info(:first_check, {worker_pid, dirs, interval, _empty_map}) do
    schedule_check(interval)
    {:noreply, {worker_pid, dirs, interval, files_mtimes(dirs)}}
  end

  def handle_info(:check, {worker_pid, dirs, interval, stale_mtimes}) do
    fresh_mtimes = files_mtimes(dirs)

    diff(stale_mtimes, fresh_mtimes)
    |> Tuple.to_list
    |> Enum.zip([:created, :deleted, :modified])
    |> Enum.each(&report_change(&1, worker_pid))

    schedule_check(interval)
    {:noreply, {worker_pid, dirs, interval, fresh_mtimes}}
  end

  defp schedule_check(interval) do
    Process.send_after(self(), :check, interval)
  end

  defp files_mtimes(dirs, files_mtimes_map \\ %{}) do
    Enum.reduce(dirs, files_mtimes_map, fn dir, map ->
      case File.stat!(dir) do
        %{type: :regular, mtime: mtime} ->
          Map.put(map, dir, mtime)
        %{type: :directory} ->
          dir
          |> Path.join("*")
          |> Path.wildcard
          |> files_mtimes(map)
        %{type: _other} ->
          map
      end
    end)
  end

  @doc false
  def diff(stale_mtimes, fresh_mtimes) do
    fresh_file_paths = fresh_mtimes |> Map.keys |> MapSet.new
    stale_file_paths = stale_mtimes |> Map.keys |> MapSet.new

    created_file_paths =
      MapSet.difference(fresh_file_paths, stale_file_paths) |> MapSet.to_list
    deleted_file_paths =
      MapSet.difference(stale_file_paths, fresh_file_paths) |> MapSet.to_list
    modified_file_paths =
      for file_path <- MapSet.intersection(stale_file_paths, fresh_file_paths),
        stale_mtimes[file_path] != fresh_mtimes[file_path], do: file_path

    {created_file_paths, deleted_file_paths, modified_file_paths}
  end

  defp report_change({file_paths, event}, worker_pid) do
    for file_path <- file_paths do
      send(worker_pid, {:backend_file_event, self(), {file_path, [event]}})
    end
  end
end