lib/depot.ex

defmodule Depot do
  @external_resource "README.md"
  @moduledoc @external_resource
             |> File.read!()
             |> String.split("<!-- MDOC !-->")
             |> Enum.fetch!(1)

  @type adapter :: module()
  @type filesystem :: {module(), Depot.Adapter.config()}

  @doc """
  Write to a filesystem

  ## Examples

  ### Direct filesystem

      filesystem = Depot.Adapter.Local.configure(prefix: "/home/user/storage")
      :ok = Depot.write(filesystem, "test.txt", "Hello World")

  ### Module-based filesystem

      defmodule LocalFileSystem do
        use Depot.Filesystem,
          adapter: Depot.Adapter.Local,
          prefix: "/home/user/storage"
      end

      LocalFileSystem.write("test.txt", "Hello World")

  """
  @spec write(filesystem, Path.t(), iodata(), keyword()) :: :ok | {:error, term}
  def write({adapter, config}, path, contents, opts \\ []) do
    with {:ok, path} <- Depot.RelativePath.normalize(path) do
      adapter.write(config, path, contents, opts)
    end
  end

  @doc """
  Returns a `Stream` for writing to the given `path`.

  ## Options

  The following stream options apply to all adapters:

    * `:chunk_size` - When reading, the amount to read,
      usually expressed as a number of bytes.

  ## Examples

  > Note: The shape of the returned stream will
  > necessarily depend on the adapter in use. In the
  > following examples the [`Local`](`Depot.Adapter.Local`)
  > adapter is invoked, which returns a `File.Stream`.

  ### Direct filesystem

      filesystem = Depot.Adapter.Local.configure(prefix: "/home/user/storage")
      {:ok, %File.Stream{}} = Depot.write_stream(filesystem, "test.txt")

  ### Module-based filesystem

      defmodule LocalFileSystem do
        use Depot.Filesystem,
          adapter: Depot.Adapter.Local,
          prefix: "/home/user/storage"
      end

      {:ok, %File.Stream{}} = LocalFileSystem.write_stream("test.txt")

  """
  def write_stream({adapter, config}, path, opts \\ []) do
    with {:ok, path} <- Depot.RelativePath.normalize(path) do
      adapter.write_stream(config, path, opts)
    end
  end

  @doc """
  Read from a filesystem

  ## Examples

  ### Direct filesystem

      filesystem = Depot.Adapter.Local.configure(prefix: "/home/user/storage")
      {:ok, "Hello World"} = Depot.read(filesystem, "test.txt")

  ### Module-based filesystem

      defmodule LocalFileSystem do
        use Depot.Filesystem,
          adapter: Depot.Adapter.Local,
          prefix: "/home/user/storage"
      end

      {:ok, "Hello World"} = LocalFileSystem.read("test.txt")

  """
  @spec read(filesystem, Path.t(), keyword()) :: {:ok, binary} | {:error, term}
  def read({adapter, config}, path, _opts \\ []) do
    with {:ok, path} <- Depot.RelativePath.normalize(path) do
      adapter.read(config, path)
    end
  end

  @doc """
  Returns a `Stream` for reading the given `path`.

  ## Options

  The following stream options apply to all adapters:

    * `:chunk_size` - When reading, the amount to read,
      usually expressed as a number of bytes.

  ## Examples

  > Note: The shape of the returned stream will
  > necessarily depend on the adapter in use. In the
  > following examples the [`Local`](`Depot.Adapter.Local`)
  > adapter is invoked, which returns a `File.Stream`.

  ### Direct filesystem

      filesystem = Depot.Adapter.Local.configure(prefix: "/home/user/storage")
      {:ok, %File.Stream{}} = Depot.read_stream(filesystem, "test.txt")

  ### Module-based filesystem

      defmodule LocalFileSystem do
        use Depot.Filesystem,
          adapter: Depot.Adapter.Local,
          prefix: "/home/user/storage"
      end

      {:ok, %File.Stream{}} = LocalFileSystem.read_stream("test.txt")

  """
  def read_stream({adapter, config}, path, opts \\ []) do
    with {:ok, path} <- Depot.RelativePath.normalize(path) do
      adapter.read_stream(config, path, opts)
    end
  end

  @doc """
  Delete a file from a filesystem

  ## Examples

  ### Direct filesystem

      filesystem = Depot.Adapter.Local.configure(prefix: "/home/user/storage")
      :ok = Depot.delete(filesystem, "test.txt")

  ### Module-based filesystem

      defmodule LocalFileSystem do
        use Depot.Filesystem,
          adapter: Depot.Adapter.Local,
          prefix: "/home/user/storage"
      end

      :ok = LocalFileSystem.delete("test.txt")

  """
  @spec delete(filesystem, Path.t(), keyword()) :: :ok | {:error, term}
  def delete({adapter, config}, path, _opts \\ []) do
    with {:ok, path} <- Depot.RelativePath.normalize(path) do
      adapter.delete(config, path)
    end
  end

  @doc """
  Move a file from source to destination on a filesystem

  ## Examples

  ### Direct filesystem

      filesystem = Depot.Adapter.Local.configure(prefix: "/home/user/storage")
      :ok = Depot.move(filesystem, "test.txt", "other-test.txt")

  ### Module-based filesystem

      defmodule LocalFileSystem do
        use Depot.Filesystem,
          adapter: Depot.Adapter.Local,
          prefix: "/home/user/storage"
      end

      :ok = LocalFileSystem.move("test.txt", "other-test.txt")

  """
  @spec move(filesystem, Path.t(), Path.t(), keyword()) :: :ok | {:error, term}
  def move({adapter, config}, source, destination, opts \\ []) do
    with {:ok, source} <- Depot.RelativePath.normalize(source),
         {:ok, destination} <- Depot.RelativePath.normalize(destination) do
      adapter.move(config, source, destination, opts)
    end
  end

  @doc """
  Copy a file from source to destination on a filesystem

  ## Examples

  ### Direct filesystem

      filesystem = Depot.Adapter.Local.configure(prefix: "/home/user/storage")
      :ok = Depot.copy(filesystem, "test.txt", "other-test.txt")

  ### Module-based filesystem

      defmodule LocalFileSystem do
        use Depot.Filesystem,
          adapter: Depot.Adapter.Local,
          prefix: "/home/user/storage"
      end

      :ok = LocalFileSystem.copy("test.txt", "other-test.txt")

  """
  @spec copy(filesystem, Path.t(), Path.t(), keyword()) :: :ok | {:error, term}
  def copy({adapter, config}, source, destination, opts \\ []) do
    with {:ok, source} <- Depot.RelativePath.normalize(source),
         {:ok, destination} <- Depot.RelativePath.normalize(destination) do
      adapter.copy(config, source, destination, opts)
    end
  end

  @doc """
  Copy a file from source to destination on a filesystem

  ## Examples

  ### Direct filesystem

      filesystem = Depot.Adapter.Local.configure(prefix: "/home/user/storage")
      :ok = Depot.copy(filesystem, "test.txt", "other-test.txt")

  ### Module-based filesystem

      defmodule LocalFileSystem do
        use Depot.Filesystem,
          adapter: Depot.Adapter.Local,
          prefix: "/home/user/storage"
      end

      :ok = LocalFileSystem.copy("test.txt", "other-test.txt")

  """
  @spec file_exists(filesystem, Path.t(), keyword()) :: {:ok, :exists | :missing} | {:error, term}
  def file_exists({adapter, config}, path, _opts \\ []) do
    with {:ok, path} <- Depot.RelativePath.normalize(path) do
      adapter.file_exists(config, path)
    end
  end

  @doc """
  List the contents of a folder on a filesystem

  ## Examples

  ### Direct filesystem

      filesystem = Depot.Adapter.Local.configure(prefix: "/home/user/storage")
      {:ok, contents} = Depot.list_contents(filesystem, ".")

  ### Module-based filesystem

      defmodule LocalFileSystem do
        use Depot.Filesystem,
          adapter: Depot.Adapter.Local,
          prefix: "/home/user/storage"
      end

      {:ok, contents} = LocalFileSystem.list_contents(".")

  """
  @spec list_contents(filesystem, Path.t(), keyword()) ::
          {:ok, [%Depot.Stat.Dir{} | %Depot.Stat.File{}]} | {:error, term}
  def list_contents({adapter, config}, path, _opts \\ []) do
    with {:ok, path} <- Depot.RelativePath.normalize(path) do
      adapter.list_contents(config, path)
    end
  end

  @doc """
  Create a directory

  ## Examples

  ### Direct filesystem

      filesystem = Depot.Adapter.Local.configure(prefix: "/home/user/storage")
      :ok = Depot.create_directory(filesystem, "test/")

  ### Module-based filesystem

      defmodule LocalFileSystem do
        use Depot.Filesystem,
          adapter: Depot.Adapter.Local,
          prefix: "/home/user/storage"
      end

      LocalFileSystem.create_directory("test/")

  """
  @spec create_directory(filesystem, Path.t(), keyword()) :: :ok | {:error, term}
  def create_directory({adapter, config}, path, opts \\ []) do
    with {:ok, path} <- Depot.RelativePath.normalize(path),
         {:ok, path} <- Depot.RelativePath.assert_directory(path) do
      adapter.create_directory(config, path, opts)
    end
  end

  @doc """
  Delete a directory.

  ## Options

    * `:recursive` - Recursively delete contents. Defaults to `false`.

  ## Examples

  ### Direct filesystem

      filesystem = Depot.Adapter.Local.configure(prefix: "/home/user/storage")
      :ok = Depot.delete_directory(filesystem, "test/")

  ### Module-based filesystem

      defmodule LocalFileSystem do
        use Depot.Filesystem,
          adapter: Depot.Adapter.Local,
          prefix: "/home/user/storage"
      end

      LocalFileSystem.delete_directory("test/")

  """
  @spec delete_directory(filesystem, Path.t(), keyword()) :: :ok | {:error, term}
  def delete_directory({adapter, config}, path, opts \\ []) do
    with {:ok, path} <- Depot.RelativePath.normalize(path),
         {:ok, path} <- Depot.RelativePath.assert_directory(path) do
      adapter.delete_directory(config, path, opts)
    end
  end

  @doc """
  Clear the filesystem.

  This is always recursive.

  ## Examples

  ### Direct filesystem

      filesystem = Depot.Adapter.Local.configure(prefix: "/home/user/storage")
      :ok = Depot.clear(filesystem)

  ### Module-based filesystem

      defmodule LocalFileSystem do
        use Depot.Filesystem,
          adapter: Depot.Adapter.Local,
          prefix: "/home/user/storage"
      end

      LocalFileSystem.clear()

  """
  @spec clear(filesystem, keyword()) :: :ok | {:error, term}
  def clear({adapter, config}, _opts \\ []) do
    adapter.clear(config)
  end

  @spec set_visibility(filesystem, Path.t(), Depot.Visibility.t()) :: :ok | {:error, term}
  def set_visibility({adapter, config}, path, visibility) do
    with {:ok, path} <- Depot.RelativePath.normalize(path) do
      adapter.set_visibility(config, path, visibility)
    end
  end

  @spec visibility(filesystem, Path.t()) :: {:ok, Depot.Visibility.t()} | {:error, term}
  def visibility({adapter, config}, path) do
    with {:ok, path} <- Depot.RelativePath.normalize(path) do
      adapter.visibility(config, path)
    end
  end

  @doc """
  Copy a file from one filesystem to the other

  This can either be done natively if the same adapter is used for both filesystems
  or by streaming/read-write cycle the file from the source to the local system
  and back to the destination.

  ## Examples

  ### Direct filesystem

      filesystem_source = Depot.Adapter.Local.configure(prefix: "/home/user/storage")
      filesystem_destination = Depot.Adapter.Local.configure(prefix: "/home/user/storage2")
      :ok = Depot.copy_between_filesystem({filesystem_source, "test.txt"}, {filesystem_destination, "copy.txt"})

  ### Module-based filesystem

      defmodule LocalSourceFileSystem do
        use Depot.Filesystem,
          adapter: Depot.Adapter.Local,
          prefix: "/home/user/storage"
      end

      defmodule LocalDestinationFileSystem do
        use Depot.Filesystem,
          adapter: Depot.Adapter.Local,
          prefix: "/home/user/storage2"
      end

      :ok = Depot.copy_between_filesystem(
        {LocalSourceFileSystem.__filesystem__(), "test.txt"},
        {LocalDestinationFileSystem.__filesystem__(), "copy.txt"}
      )

  """
  @spec copy_between_filesystem(
          source :: {filesystem, Path.t()},
          destination :: {filesystem, Path.t()},
          keyword()
        ) :: :ok | {:error, term}
  def copy_between_filesystem(source, destination, opts \\ [])

  # Same adapter, same config -> just do a plain copy
  def copy_between_filesystem({filesystem, source}, {filesystem, destination}, opts) do
    copy(filesystem, source, destination, opts)
  end

  # Same adapter -> try direct copy if supported
  def copy_between_filesystem(
        {{adapter, config_source}, path_source} = source,
        {{adapter, config_destination}, path_destination} = destination,
        opts
      ) do
    with :ok <-
           adapter.copy(config_source, path_source, config_destination, path_destination, opts) do
      :ok
    else
      {:error, :unsupported} -> copy_via_local_memory(source, destination, opts)
      error -> error
    end
  end

  # different adapter
  def copy_between_filesystem(source, destination, opts) do
    copy_via_local_memory(source, destination, opts)
  end

  defp copy_via_local_memory(
         {{source_adapter, _} = source_filesystem, source_path},
         {{destination_adapter, _} = destination_filesystem, destination_path},
         opts
       ) do
    case {Depot.read_stream(source_filesystem, source_path, opts),
          Depot.write_stream(destination_filesystem, destination_path, opts)} do
      # A and B support streaming -> Stream data
      {{:ok, read_stream}, {:ok, write_stream}} ->
        read_stream
        |> Stream.into(write_stream)
        |> Stream.run()

      # Only A support streaming -> Stream to memory and write when done
      {{:ok, read_stream}, {:error, ^destination_adapter}} ->
        Depot.write(destination_filesystem, destination_path, Enum.into(read_stream, []))

      # Only B support streaming -> Load into memory and stream to B
      {{:error, ^source_adapter}, {:ok, write_stream}} ->
        with {:ok, contents} <- Depot.read(source_filesystem, source_path) do
          contents
          |> chunk(Keyword.get(opts, :chunk_size, 5 * 1024))
          |> Enum.into(write_stream)
        end

      # Neither support streaming
      {{:error, ^source_adapter}, {:error, ^destination_adapter}} ->
        with {:ok, contents} <- Depot.read(source_filesystem, source_path) do
          Depot.write(destination_filesystem, destination_path, contents)
        end
    end
  rescue
    e -> {:error, e}
  end

  @doc false
  # Also used by the InMemory adapter and therefore not private
  def chunk("", _size), do: []

  def chunk(binary, size) when byte_size(binary) >= size do
    {chunk, rest} = :erlang.split_binary(binary, size)
    [chunk | chunk(rest, size)]
  end

  def chunk(binary, _size), do: [binary]
end