lib/depot/adapter/local.ex

defmodule Depot.Adapter.Local do
  @moduledoc """
  Depot Adapter for the local filesystem.

  ## Direct usage

      iex> {:ok, prefix} = Briefly.create(directory: true)
      iex> filesystem = Depot.Adapter.Local.configure(prefix: prefix)
      iex> :ok = Depot.write(filesystem, "test.txt", "Hello World")
      iex> {:ok, "Hello World"} = Depot.read(filesystem, "test.txt")

  ## Usage with a module

      defmodule LocalFileSystem do
        use Depot.Filesystem,
          adapter: Depot.Adapter.Local,
          prefix: prefix
      end

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

  ## Usage with Streams

  The following options are available for streams:

    * `:chunk_size` - When reading, the amount to read,
      by `:line` (default) or by a given number of bytes.

    * `:modes` - A list of modes to use when opening the file
      for reading. For more information, see the docs for
      `File.stream!/3`.

  ### Examples

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

      # with custom read chunk size
      {:ok, %File.Stream{line_or_bytes: 1_024, ...}} = Depot.read_stream(filesystem, "test.txt", chunk_size: 1_024)

      # with custom file read modes
      {:ok, %File.Stream{mode: [{:encoding, :utf8}, :binary], ...}} = Depot.read_stream(filesystem, "test.txt", modes: [encoding: :utf8])

  """
  use Bitwise, only_operators: true
  alias Depot.Visibility.UnixVisibilityConverter
  alias Depot.Visibility.PortableUnixVisibilityConverter, as: DefaultVisibilityConverter

  defmodule Config do
    @moduledoc false

    @type t :: %__MODULE__{
            prefix: Path.t(),
            converter: UnixVisibilityConverter.t(),
            visibility: UnixVisibilityConverter.config()
          }

    defstruct prefix: nil, converter: nil, visibility: nil
  end

  @behaviour Depot.Adapter

  @impl Depot.Adapter
  def starts_processes, do: false

  @impl Depot.Adapter
  def configure(opts) do
    visibility_config = Keyword.get(opts, :visibility, [])
    converter = Keyword.get(visibility_config, :converter, DefaultVisibilityConverter)
    visibility = visibility_config |> Keyword.drop([:converter]) |> converter.config()

    config = %Config{
      prefix: Keyword.fetch!(opts, :prefix),
      converter: converter,
      visibility: visibility
    }

    {__MODULE__, config}
  end

  @impl Depot.Adapter
  def write(%Config{} = config, path, contents, opts) do
    path = full_path(config, path)

    mode =
      with {:ok, visibility} <- Keyword.fetch(opts, :visibility) do
        mode = config.converter.for_file(config.visibility, visibility)
        {:ok, mode}
      end

    with :ok <- ensure_directory(config, Path.dirname(path), opts),
         :ok <- File.write(path, contents),
         :ok <- maybe_chmod(path, mode) do
      :ok
    end
  end

  @impl Depot.Adapter
  def write_stream(%Config{} = config, path, opts) do
    modes = opts[:modes] || []
    line_or_bytes = opts[:chunk_size] || :line
    {:ok, File.stream!(full_path(config, path), modes, line_or_bytes)}
  rescue
    e -> {:error, e}
  end

  @impl Depot.Adapter
  def read(%Config{} = config, path) do
    File.read(full_path(config, path))
  end

  @impl Depot.Adapter
  def read_stream(%Config{} = config, path, opts) do
    modes = opts[:modes] || []
    line_or_bytes = opts[:chunk_size] || :line
    {:ok, File.stream!(full_path(config, path), modes, line_or_bytes)}
  rescue
    e -> {:error, e}
  end

  @impl Depot.Adapter
  def delete(%Config{} = config, path) do
    with {:error, :enoent} <- File.rm(full_path(config, path)), do: :ok
  end

  @impl Depot.Adapter
  def move(%Config{} = config, source, destination, opts) do
    source = full_path(config, source)
    destination = full_path(config, destination)

    with :ok <- ensure_directory(config, Path.dirname(destination), opts) do
      File.rename(source, destination)
    end
  end

  @impl Depot.Adapter
  def copy(%Config{} = config, source, destination, opts) do
    source = full_path(config, source)
    destination = full_path(config, destination)

    with :ok <- ensure_directory(config, Path.dirname(destination), opts) do
      File.cp(source, destination)
    end
  end

  @impl Depot.Adapter
  def copy(
        %Config{} = source_config,
        source,
        %Config{} = destination_config,
        destination,
        opts
      ) do
    source = full_path(source_config, source)
    destination = full_path(destination_config, destination)

    with :ok <- ensure_directory(destination_config, Path.dirname(destination), opts) do
      File.cp(source, destination)
    end
  end

  @impl Depot.Adapter
  def file_exists(%Config{} = config, path) do
    case File.exists?(full_path(config, path)) do
      true -> {:ok, :exists}
      false -> {:ok, :missing}
    end
  end

  @impl Depot.Adapter
  def list_contents(%Config{} = config, path) do
    full_path = full_path(config, path)

    with {:ok, files} <- File.ls(full_path) do
      contents =
        for file <- files,
            {:ok, stat} = File.stat(Path.join(full_path, file), time: :posix),
            stat.type in [:directory, :regular] do
          struct =
            case stat.type do
              :directory -> Depot.Stat.Dir
              :regular -> Depot.Stat.File
            end

          struct!(struct,
            name: file,
            size: stat.size,
            mtime: stat.mtime,
            visibility: visibility_for_mode(config, stat.type, stat.mode)
          )
        end

      {:ok, contents}
    end
  end

  @impl Depot.Adapter
  def create_directory(%Config{} = config, path, opts) do
    path = full_path(config, path)
    ensure_directory(config, path, opts)
  end

  @impl Depot.Adapter
  def delete_directory(%Config{} = config, path, opts) do
    path = full_path(config, path)

    if Keyword.get(opts, :recursive, false) do
      with {:ok, _} <- File.rm_rf(path), do: :ok
    else
      File.rmdir(path)
    end
  end

  @impl Depot.Adapter
  def clear(%Config{} = config) do
    with {:ok, contents} <- list_contents(%Config{} = config, ".") do
      Enum.reduce_while(contents, :ok, fn dir_or_file, :ok ->
        case clear_dir_or_file(config, dir_or_file) do
          :ok -> {:cont, :ok}
          err -> {:halt, err}
        end
      end)
    end
  end

  @impl Depot.Adapter
  def set_visibility(%Config{} = config, path, visibility) do
    path = full_path(config, path)

    mode =
      if File.dir?(path) do
        config.converter.for_directory(config.visibility, visibility)
      else
        config.converter.for_file(config.visibility, visibility)
      end

    File.chmod(path, mode)
  end

  @impl Depot.Adapter
  def visibility(%Config{} = config, path) do
    path = full_path(config, path)

    with {:ok, %{mode: mode, type: type}} <- File.stat(path) do
      {:ok, visibility_for_mode(config, type, mode)}
    end
  end

  defp visibility_for_mode(config, type, mode) do
    mode = mode &&& 0o777

    case type do
      :directory -> config.converter.from_directory(config.visibility, mode)
      _ -> config.converter.from_file(config.visibility, mode)
    end
  end

  defp clear_dir_or_file(config, %Depot.Stat.Dir{name: dir}),
    do: delete_directory(config, dir, recursive: true)

  defp clear_dir_or_file(config, %Depot.Stat.File{name: name}),
    do: delete(config, name)

  defp full_path(config, path) do
    Depot.RelativePath.join_prefix(config.prefix, path)
  end

  defp ensure_directory(config, path, opts) do
    mode =
      with {:ok, visibility} <- Keyword.fetch(opts, :directory_visibility) do
        mode = config.converter.for_directory(config.visibility, visibility)
        {:ok, mode}
      end

    path
    |> IO.chardata_to_string()
    |> Path.join("/")
    |> do_mkdir_p(mode)
  end

  defp do_mkdir_p(path, mode) do
    with :missing <- existing_directory(path),
         parent = Path.dirname(path),
         :ok <- infinite_loop_protect(path),
         :ok <- do_mkdir_p(parent, mode),
         :ok <- :file.make_dir(path) do
      maybe_chmod(path, mode)
    end
  end

  def existing_directory(path) do
    if File.dir?(path), do: :ok, else: :missing
  end

  defp infinite_loop_protect(path) do
    if Path.dirname(path) != path, do: :ok, else: {:error, :einval}
  end

  defp maybe_chmod(path, {:ok, mode}), do: File.chmod(path, mode)
  defp maybe_chmod(_path, :error), do: :ok
end