lib/deep_sinker.ex

defmodule DeepSinker do
  @moduledoc """
  Customizable file traverser.
  """

  use TypedStruct

  @type path :: String.t()
  @type marker :: any()
  @type item :: {path(), marker()}
  @type order :: :asc | :desc
  @type item_type :: :file | :directory | :ignore
  @type handler :: (item() -> item_type())

  typedstruct do
    @typedoc "State of traversing."

    field :root_items, [item()], enforce: true
    field :order, order, enforce: true
    field :found_but_not_used_yet_items, [item()], enforce: true
  end

  @doc """
  Create initial state.

  ## Examples

      iex> DeepSinker.new([{"/path/to/dir1", :dir1}, {"/path/to/dir2", :dir2}])
      ...> |> is_struct(DeepSinker)
      true

      iex> DeepSinker.new([{"/path/to/dir1", :dir1}, {"/path/to/dir2", :dir2}], order: :desc)
      ...> |> is_struct(DeepSinker)
      true
  """
  @type new_option :: {:order, order()}
  @spec new([item()]) :: DeepSinker.t()
  @spec new([item()], [new_option()]) :: DeepSinker.t()
  def new(root_items, opt \\ []) do
    order = Keyword.get(opt, :order, :asc)
    found_items = Enum.sort(root_items, order)

    %DeepSinker{
      root_items: root_items,
      order: order,
      found_but_not_used_yet_items: found_items
    }
  end

  @doc """
  Pop file and update state.

  ## Examples

      iex> DeepSinker.new([{"./", :marker}])
      ...> |> DeepSinker.next()
      ...> # {state, {:ok, {filepath, :marker}}} | {state, :done}

      iex> DeepSinker.new([{"./", :marker}])
      ...> |> DeepSinker.next(
      ...>   handler: fn {path, _marker} ->
      ...>     cond do
      ...>       String.ends_with?(path, ".git") -> :ignore
      ...>       String.contains?(path, ".") -> :file
      ...>       true -> :directory
      ...>     end
      ...>   end
      ...> )
      ...> # {state, {:ok, {filepath, :marker}}} | {state, :done}
  """
  @type next_option :: {:handler, handler()}
  @type next_result :: {:ok, item()} | :done

  @spec next(DeepSinker.t()) :: {DeepSinker.t(), next_result()}
  @spec next(DeepSinker.t(), [next_option()]) :: {DeepSinker.t(), next_result()}
  def next(state, opt \\ [])

  def next(%DeepSinker{found_but_not_used_yet_items: []} = state, _opt) do
    # There are no files should search
    {state, :done}
  end

  def next(
        %DeepSinker{
          found_but_not_used_yet_items: [{path, marker} | items_not_used_yet],
          order: order
        } = state,
        opt
      ) do
    handler = Keyword.get(opt, :handler, &default_handler/1)

    case handler.({path, marker}) do
      :file ->
        state = %{state | found_but_not_used_yet_items: items_not_used_yet}
        {state, {:ok, {path, marker}}}

      :directory ->
        children = find_children(path, marker, order)
        items_not_used_yet = children ++ items_not_used_yet
        state = %{state | found_but_not_used_yet_items: items_not_used_yet}
        next(state, opt)

      :ignore ->
        state = %{state | found_but_not_used_yet_items: items_not_used_yet}
        next(state, opt)
    end
  end

  @doc """
  Stream filepaths.

  ## Examples

      iex> DeepSinker.new([{"/path_a", :a}, {"/path_b", :b}])
      ...> # |> DeepSinker.stream()
      ...> # |> Enum.to_list()
      ...> # [{"/path_a/1.txt", :a}, {"/path_a/2.txt, :a} {"/path_b/1.txt, :b}]

      iex> DeepSinker.new([{"/path_a", :a}, {"/path_b", :b}])
      ...> |> DeepSinker.stream(
      ...>   handler: fn {path, _marker} ->
      ...>     cond do
      ...>       String.ends_with?(path, ".git") -> :ignore
      ...>       String.contains?(path, ".") -> :file
      ...>       true -> :directory
      ...>     end
      ...>   end
      ...> )
      ...> # [{"/path_a/1.txt", :a}, {"/path_a/2.txt, :a} {"/path_b/1.txt, :b}]
  """
  @spec stream(DeepSinker.t()) :: Enumerable.t(item())
  @spec stream(DeepSinker.t(), [next_option()]) :: Enumerable.t(item())
  def stream(state, opt \\ [])

  def stream(%DeepSinker{} = state, opt) do
    Stream.resource(
      fn -> state end,
      fn state ->
        with {state, {:ok, item}} <- DeepSinker.next(state, opt) do
          {[item], state}
        else
          {state, :done} -> {:halt, state}
        end
      end,
      fn _ -> nil end
    )
  end

  defp find_children(directory_path, marker, order)
       when is_bitstring(directory_path) and order in [:asc, :desc] do
    with {:ok, filenames} <- :file.list_dir(directory_path) do
      filenames
      |> Enum.map(&to_string/1)
      |> Enum.map(fn name -> Path.join(directory_path, name) end)
      |> Enum.map(fn filepath -> {filepath, marker} end)
      |> Enum.sort(order)
    else
      {:error, reason} ->
        :logger.error("Failed to load directory `#{directory_path}` because of #{reason}")
        []
    end
  end

  defp default_handler({path, _marker}) do
    cond do
      File.dir?(path) -> :directory
      true -> :file
    end
  end
end