Skip to main content

lib/jido_vfs.ex

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

  alias Jido.VFS.Errors

  @type adapter :: module()
  @type filesystem :: {module(), Jido.VFS.Adapter.config()}
  @type operation ::
          :write
          | :write_stream
          | :read
          | :read_stream
          | :delete
          | :move
          | :copy
          | :copy_between
          | :file_exists
          | :list_contents
          | :create_directory
          | :delete_directory
          | :clear
          | :set_visibility
          | :visibility
          | :stat
          | :access
          | :append
          | :truncate
          | :utime
          | :commit
          | :revisions
          | :read_revision
          | :rollback

  @type copy_between_strategy :: :native | :stream | :tempfile

  defp convert_path_error({:path, :traversal}, path),
    do: Errors.PathTraversal.exception(attempted_path: path)

  defp convert_path_error({:path, :absolute}, path),
    do: Errors.AbsolutePath.exception(absolute_path: path)

  defp convert_path_error(:enotdir, path), do: Errors.NotDirectory.exception(not_dir_path: path)

  @doc """
  Safely configure an adapter and normalize configure-time failures into typed errors.
  """
  @spec safe_configure(adapter(), keyword()) :: {:ok, filesystem()} | {:error, term()}
  def safe_configure(adapter, opts \\ [])

  def safe_configure(adapter, opts) when is_atom(adapter) and is_list(opts) do
    if function_exported?(adapter, :configure, 1) do
      case normalize_adapter_call(fn -> adapter.configure(opts) end) do
        {configured_adapter, _config} = filesystem
        when is_atom(configured_adapter) and configured_adapter not in [:ok, :error] ->
          {:ok, filesystem}

        {:error, %Errors.Unknown.Unknown{error: reason}} ->
          {:error, Errors.AdapterError.exception(adapter: adapter, reason: reason)}

        {:error, reason} ->
          if jido_vfs_error?(reason) do
            {:error, reason}
          else
            {:error, Errors.AdapterError.exception(adapter: adapter, reason: reason)}
          end

        other ->
          {:error,
           Errors.AdapterError.exception(
             adapter: adapter,
             reason: %{operation: :configure, reason: {:invalid_config_result, other}}
           )}
      end
    else
      {:error, Errors.UnsupportedOperation.exception(operation: :configure, adapter: adapter)}
    end
  end

  def safe_configure(adapter, _opts) do
    {:error,
     Errors.AdapterError.exception(
       adapter: adapter,
       reason: %{operation: :configure, reason: :invalid_adapter_or_options}
     )}
  end

  @doc """
  Configure an adapter, raising when configuration fails.
  """
  @spec configure!(adapter(), keyword()) :: filesystem()
  def configure!(adapter, opts \\ []) do
    case safe_configure(adapter, opts) do
      {:ok, filesystem} -> filesystem
      {:error, error} -> raise error
    end
  end

  @doc """
  Returns whether a filesystem supports a specific operation.
  """
  @spec supports?(filesystem, operation()) :: boolean()
  def supports?({adapter, _config}, operation) do
    supports_adapter?(adapter, operation)
  end

  defp supports_adapter?(adapter, operation) when is_atom(adapter) and is_atom(operation) do
    unsupported = adapter_unsupported_operations(adapter)

    if operation in unsupported do
      false
    else
      case operation do
        :copy_between -> function_exported?(adapter, :copy, 5)
        :write -> function_exported?(adapter, :write, 4)
        :write_stream -> function_exported?(adapter, :write_stream, 3)
        :read -> function_exported?(adapter, :read, 2)
        :read_stream -> function_exported?(adapter, :read_stream, 3)
        :delete -> function_exported?(adapter, :delete, 2)
        :move -> function_exported?(adapter, :move, 4)
        :copy -> function_exported?(adapter, :copy, 4)
        :file_exists -> function_exported?(adapter, :file_exists, 2)
        :list_contents -> function_exported?(adapter, :list_contents, 2)
        :create_directory -> function_exported?(adapter, :create_directory, 3)
        :delete_directory -> function_exported?(adapter, :delete_directory, 3)
        :clear -> function_exported?(adapter, :clear, 1)
        :set_visibility -> function_exported?(adapter, :set_visibility, 3)
        :visibility -> function_exported?(adapter, :visibility, 2)
        :stat -> function_exported?(adapter, :stat, 2)
        :access -> function_exported?(adapter, :access, 3)
        :append -> function_exported?(adapter, :append, 4)
        :truncate -> function_exported?(adapter, :truncate, 3)
        :utime -> function_exported?(adapter, :utime, 3)
        :commit -> supports_versioning_operation?(adapter, :commit, 3)
        :revisions -> supports_versioning_operation?(adapter, :revisions, 3)
        :read_revision -> supports_versioning_operation?(adapter, :read_revision, 4)
        :rollback -> supports_versioning_operation?(adapter, :rollback, 3)
      end
    end
  end

  defp supports_adapter?(_adapter, _operation), do: false

  defp adapter_unsupported_operations(adapter) do
    if function_exported?(adapter, :unsupported_operations, 0) do
      case adapter.unsupported_operations() do
        operations when is_list(operations) -> operations
        _ -> []
      end
    else
      []
    end
  rescue
    _ -> []
  end

  defp supports_versioning_operation?(adapter, operation, arity) do
    with versioning_module when not is_nil(versioning_module) <- get_versioning_module(adapter),
         {:module, _module} <- Code.ensure_loaded(versioning_module) do
      function_exported?(versioning_module, operation, arity)
    else
      _ -> false
    end
  end

  defp unsupported(adapter, operation) do
    {:error, Errors.UnsupportedOperation.exception(operation: operation, adapter: adapter)}
  end

  defp normalize_adapter_result(result) do
    case result do
      {:error, %Errors.UnsupportedOperation{}} = error ->
        error

      {:error, :unsupported} ->
        {:error, Errors.UnsupportedOperation.exception(operation: :unknown, adapter: :unknown)}

      {:error, reason} ->
        {:error, Errors.to_error(reason)}

      other ->
        other
    end
  end

  defp normalize_adapter_call(fun) when is_function(fun, 0) do
    fun.() |> normalize_adapter_result()
  rescue
    e -> {:error, Errors.to_error(e)}
  end

  @doc """
  Write to a filesystem

  ## Examples

  ### Direct filesystem

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

  ### Module-based filesystem

      defmodule LocalFileSystem do
        use Jido.VFS.Filesystem,
          adapter: Jido.VFS.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, normalized_path} <- Jido.VFS.RelativePath.normalize(path) do
      normalize_adapter_call(fn -> adapter.write(config, normalized_path, contents, opts) end)
    else
      {:error, reason} -> {:error, convert_path_error(reason, path)}
    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`](`Jido.VFS.Adapter.Local`)
  > adapter is invoked, which returns a `File.Stream`.

  ### Direct filesystem

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

  ### Module-based filesystem

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

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

  """
  @spec write_stream(filesystem, Path.t(), keyword()) :: {:ok, Enumerable.t()} | {:error, term}
  def write_stream({adapter, config}, path, opts \\ []) do
    with {:ok, normalized_path} <- Jido.VFS.RelativePath.normalize(path) do
      normalize_adapter_call(fn -> adapter.write_stream(config, normalized_path, opts) end)
    else
      {:error, reason} -> {:error, convert_path_error(reason, path)}
    end
  end

  @doc """
  Read from a filesystem

  ## Examples

  ### Direct filesystem

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

  ### Module-based filesystem

      defmodule LocalFileSystem do
        use Jido.VFS.Filesystem,
          adapter: Jido.VFS.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, normalized_path} <- Jido.VFS.RelativePath.normalize(path) do
      normalize_adapter_call(fn -> adapter.read(config, normalized_path) end)
    else
      {:error, reason} -> {:error, convert_path_error(reason, 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`](`Jido.VFS.Adapter.Local`)
  > adapter is invoked, which returns a `File.Stream`.

  ### Direct filesystem

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

  ### Module-based filesystem

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

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

  """
  @spec read_stream(filesystem, Path.t(), keyword()) :: {:ok, Enumerable.t()} | {:error, term}
  def read_stream({adapter, config}, path, opts \\ []) do
    with {:ok, normalized_path} <- Jido.VFS.RelativePath.normalize(path) do
      normalize_adapter_call(fn -> adapter.read_stream(config, normalized_path, opts) end)
    else
      {:error, reason} -> {:error, convert_path_error(reason, path)}
    end
  end

  @doc """
  Delete a file from a filesystem

  ## Examples

  ### Direct filesystem

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

  ### Module-based filesystem

      defmodule LocalFileSystem do
        use Jido.VFS.Filesystem,
          adapter: Jido.VFS.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, normalized_path} <- Jido.VFS.RelativePath.normalize(path) do
      normalize_adapter_call(fn -> adapter.delete(config, normalized_path) end)
    else
      {:error, reason} -> {:error, convert_path_error(reason, path)}
    end
  end

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

  ## Examples

  ### Direct filesystem

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

  ### Module-based filesystem

      defmodule LocalFileSystem do
        use Jido.VFS.Filesystem,
          adapter: Jido.VFS.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, normalized_source} <- Jido.VFS.RelativePath.normalize(source) do
      with {:ok, normalized_destination} <- Jido.VFS.RelativePath.normalize(destination) do
        normalize_adapter_call(fn ->
          adapter.move(config, normalized_source, normalized_destination, opts)
        end)
      else
        {:error, reason} -> {:error, convert_path_error(reason, destination)}
      end
    else
      {:error, reason} -> {:error, convert_path_error(reason, source)}
    end
  end

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

  ## Examples

  ### Direct filesystem

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

  ### Module-based filesystem

      defmodule LocalFileSystem do
        use Jido.VFS.Filesystem,
          adapter: Jido.VFS.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, normalized_source} <- Jido.VFS.RelativePath.normalize(source) do
      with {:ok, normalized_destination} <- Jido.VFS.RelativePath.normalize(destination) do
        normalize_adapter_call(fn ->
          adapter.copy(config, normalized_source, normalized_destination, opts)
        end)
      else
        {:error, reason} -> {:error, convert_path_error(reason, destination)}
      end
    else
      {:error, reason} -> {:error, convert_path_error(reason, source)}
    end
  end

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

  ## Examples

  ### Direct filesystem

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

  ### Module-based filesystem

      defmodule LocalFileSystem do
        use Jido.VFS.Filesystem,
          adapter: Jido.VFS.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, normalized_path} <- Jido.VFS.RelativePath.normalize(path) do
      normalize_adapter_call(fn -> adapter.file_exists(config, normalized_path) end)
    else
      {:error, reason} -> {:error, convert_path_error(reason, path)}
    end
  end

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

  ## Examples

  ### Direct filesystem

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

  ### Module-based filesystem

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

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

  """
  @spec list_contents(filesystem, Path.t(), keyword()) ::
          {:ok, [%Jido.VFS.Stat.Dir{} | %Jido.VFS.Stat.File{}]} | {:error, term}
  def list_contents({adapter, config}, path, _opts \\ []) do
    with {:ok, normalized_path} <- Jido.VFS.RelativePath.normalize(path) do
      normalize_adapter_call(fn -> adapter.list_contents(config, normalized_path) end)
    else
      {:error, reason} -> {:error, convert_path_error(reason, path)}
    end
  end

  @doc """
  Create a directory

  ## Examples

  ### Direct filesystem

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

  ### Module-based filesystem

      defmodule LocalFileSystem do
        use Jido.VFS.Filesystem,
          adapter: Jido.VFS.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, normalized_path} <- Jido.VFS.RelativePath.normalize(path),
         {:ok, normalized_path} <- Jido.VFS.RelativePath.assert_directory(normalized_path) do
      normalize_adapter_call(fn -> adapter.create_directory(config, normalized_path, opts) end)
    else
      {:error, reason} -> {:error, convert_path_error(reason, path)}
    end
  end

  @doc """
  Delete a directory.

  ## Options

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

  ## Examples

  ### Direct filesystem

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

  ### Module-based filesystem

      defmodule LocalFileSystem do
        use Jido.VFS.Filesystem,
          adapter: Jido.VFS.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, normalized_path} <- Jido.VFS.RelativePath.normalize(path),
         {:ok, normalized_path} <- Jido.VFS.RelativePath.assert_directory(normalized_path) do
      normalize_adapter_call(fn -> adapter.delete_directory(config, normalized_path, opts) end)
    else
      {:error, reason} -> {:error, convert_path_error(reason, path)}
    end
  end

  @doc """
  Clear the filesystem.

  This is always recursive.

  ## Examples

  ### Direct filesystem

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

  ### Module-based filesystem

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

      LocalFileSystem.clear()

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

  @spec set_visibility(filesystem, Path.t(), Jido.VFS.Visibility.t()) :: :ok | {:error, term}
  def set_visibility({adapter, config}, path, visibility) do
    with {:ok, normalized_path} <- Jido.VFS.RelativePath.normalize(path) do
      normalize_adapter_call(fn -> adapter.set_visibility(config, normalized_path, visibility) end)
    else
      {:error, reason} -> {:error, convert_path_error(reason, path)}
    end
  end

  @spec visibility(filesystem, Path.t()) :: {:ok, Jido.VFS.Visibility.t()} | {:error, term}
  def visibility({adapter, config}, path) do
    with {:ok, normalized_path} <- Jido.VFS.RelativePath.normalize(path) do
      normalize_adapter_call(fn -> adapter.visibility(config, normalized_path) end)
    else
      {:error, reason} -> {:error, convert_path_error(reason, path)}
    end
  end

  @doc """
  Get file or directory metadata (stat information)

  Returns detailed metadata about a file or directory including size, modification time, and visibility.

  ## Examples

  ### Direct filesystem

      filesystem = Jido.VFS.Adapter.Local.configure(prefix: "/home/user/storage")
      {:ok, %Jido.VFS.Stat.File{}} = Jido.VFS.stat(filesystem, "test.txt")

  ### Module-based filesystem

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

      {:ok, %Jido.VFS.Stat.File{}} = LocalFileSystem.stat("test.txt")

  """
  @spec stat(filesystem, Path.t()) ::
          {:ok, %Jido.VFS.Stat.File{} | %Jido.VFS.Stat.Dir{}} | {:error, term}
  def stat({adapter, config}, path) do
    if supports?({adapter, config}, :stat) do
      with {:ok, normalized_path} <- Jido.VFS.RelativePath.normalize(path) do
        normalize_adapter_call(fn -> adapter.stat(config, normalized_path) end)
      else
        {:error, reason} -> {:error, convert_path_error(reason, path)}
      end
    else
      unsupported(adapter, :stat)
    end
  end

  @doc """
  Check file access permissions

  Checks whether the given file or directory can be accessed with the specified modes.

  ## Modes

    * `:read` - Check read access
    * `:write` - Check write access

  ## Examples

  ### Direct filesystem

      filesystem = Jido.VFS.Adapter.Local.configure(prefix: "/home/user/storage")
      :ok = Jido.VFS.access(filesystem, "test.txt", [:read, :write])

  ### Module-based filesystem

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

      :ok = LocalFileSystem.access("test.txt", [:read])

  """
  @spec access(filesystem, Path.t(), [:read | :write]) :: :ok | {:error, term}
  def access({adapter, config}, path, modes) do
    if supports?({adapter, config}, :access) do
      with {:ok, normalized_path} <- Jido.VFS.RelativePath.normalize(path) do
        normalize_adapter_call(fn -> adapter.access(config, normalized_path, modes) end)
      else
        {:error, reason} -> {:error, convert_path_error(reason, path)}
      end
    else
      unsupported(adapter, :access)
    end
  end

  @doc """
  Append content to a file

  If the file exists, the content is appended to the end. If it doesn't exist,
  a new file is created with the given content.

  ## Examples

  ### Direct filesystem

      filesystem = Jido.VFS.Adapter.Local.configure(prefix: "/home/user/storage")
      :ok = Jido.VFS.append(filesystem, "test.txt", "Additional content")

  ### Module-based filesystem

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

      :ok = LocalFileSystem.append("test.txt", "More data")

  """
  @spec append(filesystem, Path.t(), iodata(), keyword()) :: :ok | {:error, term}
  def append({adapter, config}, path, contents, opts \\ []) do
    if supports?({adapter, config}, :append) do
      with {:ok, normalized_path} <- Jido.VFS.RelativePath.normalize(path) do
        normalize_adapter_call(fn -> adapter.append(config, normalized_path, contents, opts) end)
      else
        {:error, reason} -> {:error, convert_path_error(reason, path)}
      end
    else
      unsupported(adapter, :append)
    end
  end

  @doc """
  Truncate a file to a specific size

  Resizes the file to the specified number of bytes. If the new size is larger than
  the current size, the file is padded with null bytes. If smaller, the file is truncated.

  ## Examples

  ### Direct filesystem

      filesystem = Jido.VFS.Adapter.Local.configure(prefix: "/home/user/storage")
      :ok = Jido.VFS.truncate(filesystem, "test.txt", 100)

  ### Module-based filesystem

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

      :ok = LocalFileSystem.truncate("test.txt", 0)  # Empty the file

  """
  @spec truncate(filesystem, Path.t(), non_neg_integer()) :: :ok | {:error, term}
  def truncate({adapter, config}, path, new_size) do
    if supports?({adapter, config}, :truncate) do
      with {:ok, normalized_path} <- Jido.VFS.RelativePath.normalize(path) do
        normalize_adapter_call(fn -> adapter.truncate(config, normalized_path, new_size) end)
      else
        {:error, reason} -> {:error, convert_path_error(reason, path)}
      end
    else
      unsupported(adapter, :truncate)
    end
  end

  @doc """
  Update file modification time

  Changes the modification time of a file or directory.

  ## Examples

  ### Direct filesystem

      filesystem = Jido.VFS.Adapter.Local.configure(prefix: "/home/user/storage")
      :ok = Jido.VFS.utime(filesystem, "test.txt", DateTime.utc_now())

  ### Module-based filesystem

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

      :ok = LocalFileSystem.utime("test.txt", ~U[2023-01-01 00:00:00Z])

  """
  @spec utime(filesystem, Path.t(), DateTime.t()) :: :ok | {:error, term}
  def utime({adapter, config}, path, mtime) do
    if supports?({adapter, config}, :utime) do
      with {:ok, normalized_path} <- Jido.VFS.RelativePath.normalize(path) do
        normalize_adapter_call(fn -> adapter.utime(config, normalized_path, mtime) end)
      else
        {:error, reason} -> {:error, convert_path_error(reason, path)}
      end
    else
      unsupported(adapter, :utime)
    end
  end

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

  Copy behavior is controlled by `:copy_between_strategy`:

  - `:native` - use adapter-native cross-filesystem copy only (`copy/5`).
  - `:stream` - stream chunks from source to destination without local temp files.
  - `:tempfile` - spool through a local temp file.

  The default strategy is `:tempfile`.

  ## Options

  - `:copy_between_strategy` - `:native | :stream | :tempfile` (default: `:tempfile`)
  - `:copy_between_temp_dir` - temp directory for `:tempfile` strategy (default: `System.tmp_dir!/0`)
  - `:chunk_size` - chunk size for streaming strategies (default: `64 * 1024`)
  - all other options are forwarded to adapter `read`/`read_stream`/`write`/`write_stream`/`append` calls.

  ## Examples

  ### Direct filesystem

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

  ### Module-based filesystem

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

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

      :ok = Jido.VFS.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
    with {:ok, _strategy} <- copy_between_strategy_from_opts(opts) do
      copy(filesystem, source, destination, copy_between_runtime_opts(opts))
    end
  end

  # Same adapter
  def copy_between_filesystem(
        {{adapter, config_source}, path_source},
        {{adapter, config_destination}, path_destination},
        opts
      ) do
    with {:ok, normalized_source, normalized_destination} <-
           normalize_copy_paths(path_source, path_destination),
         {:ok, strategy} <- copy_between_strategy_from_opts(opts) do
      copy_between_with_strategy(
        {{adapter, config_source}, normalized_source},
        {{adapter, config_destination}, normalized_destination},
        strategy,
        opts
      )
    else
      {:error, {:source, reason}} -> {:error, convert_path_error(reason, path_source)}
      {:error, {:destination, reason}} -> {:error, convert_path_error(reason, path_destination)}
      {:error, reason} -> {:error, reason}
    end
  end

  # Different adapters
  def copy_between_filesystem({source_filesystem, source_path}, {destination_filesystem, destination_path}, opts) do
    with {:ok, normalized_source, normalized_destination} <-
           normalize_copy_paths(source_path, destination_path),
         {:ok, strategy} <- copy_between_strategy_from_opts(opts) do
      copy_between_with_strategy(
        {source_filesystem, normalized_source},
        {destination_filesystem, normalized_destination},
        strategy,
        opts
      )
    else
      {:error, {:source, reason}} -> {:error, convert_path_error(reason, source_path)}
      {:error, {:destination, reason}} -> {:error, convert_path_error(reason, destination_path)}
      {:error, reason} -> {:error, reason}
    end
  end

  defp copy_between_with_strategy(
         {{adapter, config_source}, source_path},
         {{adapter, config_destination}, destination_path},
         :native,
         opts
       ) do
    if supports?({adapter, config_source}, :copy_between) do
      normalize_adapter_call(fn ->
        adapter.copy(
          config_source,
          source_path,
          config_destination,
          destination_path,
          copy_between_runtime_opts(opts)
        )
      end)
    else
      unsupported(adapter, :copy_between)
    end
  end

  defp copy_between_with_strategy(_source, _destination, :native, _opts) do
    unsupported(__MODULE__, :copy_between)
  end

  defp copy_between_with_strategy(source, destination, :stream, opts) do
    copy_via_stream(source, destination, copy_between_runtime_opts(opts))
  end

  defp copy_between_with_strategy(source, destination, :tempfile, opts) do
    copy_via_tempfile(source, destination, Keyword.delete(opts, :copy_between_strategy))
  end

  defp copy_between_runtime_opts(opts) do
    Keyword.drop(opts, [:copy_between_strategy, :copy_between_temp_dir])
  end

  defp copy_between_strategy_from_opts(opts) do
    case Keyword.get(opts, :copy_between_strategy, :tempfile) do
      strategy when strategy in [:native, :stream, :tempfile] ->
        {:ok, strategy}

      strategy ->
        {:error,
         Errors.AdapterError.exception(
           adapter: __MODULE__,
           reason: %{
             operation: :copy_between_filesystem,
             reason: {:invalid_copy_between_strategy, strategy},
             allowed_strategies: [:native, :stream, :tempfile]
           }
         )}
    end
  end

  defp copy_via_stream(
         {source_filesystem, source_path},
         {destination_filesystem, destination_path},
         opts
       ) do
    chunk_size = normalize_copy_chunk_size(Keyword.get(opts, :chunk_size, 64 * 1024))
    adapter_opts = Keyword.drop(opts, [:copy_between_temp_dir])
    stream_opts = Keyword.put(adapter_opts, :chunk_size, chunk_size)

    with {:ok, chunks} <- source_copy_chunks(source_filesystem, source_path, stream_opts, chunk_size),
         :ok <-
           write_copy_chunks_to_destination(
             destination_filesystem,
             destination_path,
             chunks,
             stream_opts,
             chunk_size
           ) do
      :ok
    end
  end

  defp source_copy_chunks(source_filesystem, source_path, opts, chunk_size) do
    if supports?(source_filesystem, :read_stream) do
      case Jido.VFS.read_stream(source_filesystem, source_path, opts) do
        {:ok, read_stream} -> {:ok, read_stream}
        {:error, reason} -> copy_side_error(:source, source_path, reason)
      end
    else
      case Jido.VFS.read(source_filesystem, source_path) do
        {:ok, contents} -> {:ok, chunk(contents, chunk_size)}
        {:error, reason} -> copy_side_error(:source, source_path, reason)
      end
    end
  end

  defp write_copy_chunks_to_destination(destination_filesystem, destination_path, chunks, opts, _chunk_size) do
    cond do
      supports?(destination_filesystem, :write_stream) ->
        with {:ok, write_stream} <- open_destination_write_stream(destination_filesystem, destination_path, opts) do
          try do
            Enum.into(chunks, write_stream)
            :ok
          rescue
            error ->
              copy_side_error(:destination, destination_path, error)
          catch
            kind, reason ->
              copy_side_error(:destination, destination_path, %{kind: kind, reason: reason})
          end
        else
          {:error, reason} ->
            copy_side_error(:destination, destination_path, reason)
        end

      supports?(destination_filesystem, :append) ->
        with :ok <- Jido.VFS.write(destination_filesystem, destination_path, "", opts) do
          try do
            Enum.reduce_while(chunks, :ok, fn chunk, :ok ->
              case Jido.VFS.append(destination_filesystem, destination_path, chunk, opts) do
                :ok -> {:cont, :ok}
                {:error, reason} -> {:halt, copy_side_error(:destination, destination_path, reason)}
              end
            end)
          rescue
            error ->
              copy_side_error(:destination, destination_path, error)
          catch
            kind, reason ->
              copy_side_error(:destination, destination_path, %{kind: kind, reason: reason})
          end
        else
          {:error, reason} ->
            copy_side_error(:destination, destination_path, reason)
        end

      true ->
        try do
          contents =
            chunks
            |> Enum.reduce([], fn chunk, acc -> [acc, chunk] end)
            |> IO.iodata_to_binary()

          case Jido.VFS.write(destination_filesystem, destination_path, contents, opts) do
            :ok -> :ok
            {:error, reason} -> copy_side_error(:destination, destination_path, reason)
          end
        rescue
          error ->
            copy_side_error(:destination, destination_path, error)
        catch
          kind, reason ->
            copy_side_error(:destination, destination_path, %{kind: kind, reason: reason})
        end
    end
  end

  defp copy_via_tempfile(
         {source_filesystem, source_path},
         {destination_filesystem, destination_path},
         opts
       ) do
    chunk_size = normalize_copy_chunk_size(Keyword.get(opts, :chunk_size, 64 * 1024))
    temp_dir = Keyword.get(opts, :copy_between_temp_dir, System.tmp_dir!())
    adapter_opts = Keyword.drop(opts, [:copy_between_temp_dir])

    with :ok <- ensure_copy_temp_dir(temp_dir) do
      temp_path =
        Path.join(
          temp_dir,
          "jido_vfs_copy_#{System.unique_integer([:positive, :monotonic])}"
        )

      try do
        with :ok <-
               copy_source_into_tempfile(source_filesystem, source_path, temp_path, adapter_opts, chunk_size),
             :ok <-
               copy_tempfile_into_destination(
                 destination_filesystem,
                 destination_path,
                 temp_path,
                 adapter_opts,
                 chunk_size
               ) do
          :ok
        end
      rescue
        e -> {:error, Errors.to_error(e)}
      catch
        kind, reason ->
          {:error,
           Errors.AdapterError.exception(
             adapter: __MODULE__,
             reason: %{operation: :copy_between_filesystem, kind: kind, reason: reason}
           )}
      after
        File.rm(temp_path)
      end
    end
  end

  defp ensure_copy_temp_dir(temp_dir) do
    case File.mkdir_p(temp_dir) do
      :ok ->
        :ok

      {:error, reason} ->
        {:error,
         Errors.AdapterError.exception(
           adapter: __MODULE__,
           reason: %{operation: :copy_between_filesystem, reason: {:temp_dir_unavailable, temp_dir, reason}}
         )}
    end
  end

  defp normalize_copy_chunk_size(size) when is_integer(size) and size > 0, do: size
  defp normalize_copy_chunk_size(_), do: 64 * 1024

  defp normalize_copy_paths(source_path, destination_path) do
    case Jido.VFS.RelativePath.normalize(source_path) do
      {:ok, normalized_source} ->
        case Jido.VFS.RelativePath.normalize(destination_path) do
          {:ok, normalized_destination} ->
            {:ok, normalized_source, normalized_destination}

          {:error, reason} ->
            {:error, {:destination, reason}}
        end

      {:error, reason} ->
        {:error, {:source, reason}}
    end
  end

  defp copy_source_into_tempfile(source_filesystem, source_path, temp_path, opts, chunk_size) do
    if supports?(source_filesystem, :read_stream) do
      with {:ok, read_stream} <-
             Jido.VFS.read_stream(source_filesystem, source_path, Keyword.put(opts, :chunk_size, chunk_size)),
           {:ok, file} <- File.open(temp_path, [:write, :binary]) do
        try do
          Enum.each(read_stream, &IO.binwrite(file, &1))
          :ok
        rescue
          error ->
            copy_side_error(:source, source_path, error)
        catch
          kind, reason ->
            copy_side_error(:source, source_path, %{kind: kind, reason: reason})
        after
          File.close(file)
        end
      else
        {:error, reason} ->
          copy_side_error(:source, source_path, reason)
      end
    else
      with {:ok, contents} <- Jido.VFS.read(source_filesystem, source_path),
           :ok <- File.write(temp_path, contents) do
        :ok
      else
        {:error, reason} ->
          copy_side_error(:source, source_path, reason)
      end
    end
  end

  defp copy_tempfile_into_destination(
         destination_filesystem,
         destination_path,
         temp_path,
         opts,
         chunk_size
       ) do
    if supports?(destination_filesystem, :write_stream) do
      with {:ok, write_stream} <-
             open_destination_write_stream(
               destination_filesystem,
               destination_path,
               Keyword.put(opts, :chunk_size, chunk_size)
             ) do
        try do
          temp_path
          |> File.stream!(chunk_size, [])
          |> Enum.into(write_stream)

          :ok
        rescue
          error ->
            copy_side_error(:destination, destination_path, error)
        catch
          kind, reason ->
            copy_side_error(:destination, destination_path, %{kind: kind, reason: reason})
        end
      else
        {:error, reason} ->
          copy_side_error(:destination, destination_path, reason)
      end
    else
      if supports?(destination_filesystem, :append) do
        with :ok <- Jido.VFS.write(destination_filesystem, destination_path, "", opts),
             {:ok, file} <- File.open(temp_path, [:read, :binary]) do
          try do
            IO.binstream(file, chunk_size)
            |> Enum.reduce_while(:ok, fn chunk, :ok ->
              case Jido.VFS.append(destination_filesystem, destination_path, chunk, opts) do
                :ok ->
                  {:cont, :ok}

                {:error, reason} ->
                  {:halt, copy_side_error(:destination, destination_path, reason)}
              end
            end)
          rescue
            error ->
              copy_side_error(:destination, destination_path, error)
          catch
            kind, reason ->
              copy_side_error(:destination, destination_path, %{kind: kind, reason: reason})
          after
            File.close(file)
          end
        else
          {:error, reason} ->
            copy_side_error(:destination, destination_path, reason)
        end
      else
        with {:ok, contents} <- File.read(temp_path),
             :ok <- Jido.VFS.write(destination_filesystem, destination_path, contents, opts) do
          :ok
        else
          {:error, reason} ->
            copy_side_error(:destination, destination_path, reason)
        end
      end
    end
  end

  defp copy_side_error(side, path, reason) do
    reason =
      case reason do
        {:error, nested_reason} -> nested_reason
        other -> other
      end

    if jido_vfs_error?(reason) do
      {:error, reason}
    else
      {:error,
       Errors.AdapterError.exception(
         adapter: __MODULE__,
         reason: %{operation: :copy_between_filesystem, side: side, path: path, reason: reason}
       )}
    end
  end

  defp open_destination_write_stream(destination_filesystem, destination_path, opts) do
    with :ok <- ensure_destination_write_target(destination_filesystem, destination_path, opts),
         {:ok, write_stream} <- Jido.VFS.write_stream(destination_filesystem, destination_path, opts) do
      {:ok, write_stream}
    end
  end

  defp ensure_destination_write_target(destination_filesystem, destination_path, opts) do
    case Jido.VFS.write(destination_filesystem, destination_path, "", opts) do
      :ok -> :ok
      {:error, %Errors.UnsupportedOperation{operation: :write}} -> :ok
      {:error, reason} -> {:error, reason}
    end
  end

  defp jido_vfs_error?(%{__struct__: module}) do
    module
    |> Atom.to_string()
    |> String.starts_with?("Elixir.Jido.VFS.Errors.")
  end

  defp jido_vfs_error?(_), do: false

  @doc false
  # Also used by the InMemory adapter and therefore not private
  @spec chunk(binary(), pos_integer()) :: [binary()]
  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]

  defp normalize_revision_result({:ok, revisions}) when is_list(revisions) do
    {:ok, Enum.map(revisions, &to_revision_struct/1)}
  end

  defp normalize_revision_result(other), do: other

  defp to_revision_struct(%Jido.VFS.Revision{} = revision), do: revision

  defp to_revision_struct(%{revision: revision} = revision_map) do
    %Jido.VFS.Revision{
      sha: to_string(revision),
      author_name: Map.get(revision_map, :author_name, "Unknown"),
      author_email: Map.get(revision_map, :author_email, "unknown@jido.vfs.local"),
      message: Map.get(revision_map, :message, ""),
      timestamp: normalize_revision_timestamp(Map.get(revision_map, :timestamp))
    }
  end

  defp to_revision_struct(%{sha: sha} = revision_map) do
    %Jido.VFS.Revision{
      sha: to_string(sha),
      author_name: Map.get(revision_map, :author_name, "Unknown"),
      author_email: Map.get(revision_map, :author_email, "unknown@jido.vfs.local"),
      message: Map.get(revision_map, :message, ""),
      timestamp: normalize_revision_timestamp(Map.get(revision_map, :timestamp))
    }
  end

  defp to_revision_struct(other) do
    %Jido.VFS.Revision{
      sha: inspect(other),
      author_name: "Unknown",
      author_email: "unknown@jido.vfs.local",
      message: "",
      timestamp: DateTime.utc_now()
    }
  end

  defp normalize_revision_timestamp(%DateTime{} = timestamp), do: timestamp

  defp normalize_revision_timestamp(timestamp) when is_integer(timestamp) do
    DateTime.from_unix!(timestamp)
  end

  defp normalize_revision_timestamp(_), do: DateTime.utc_now()

  @spec get_versioning_module(module()) :: module() | nil
  defp get_versioning_module(adapter) when is_atom(adapter) do
    if function_exported?(adapter, :versioning_module, 0) do
      case adapter.versioning_module() do
        module when is_atom(module) -> module
        _ -> nil
      end
    else
      nil
    end
  rescue
    _ -> nil
  end

  defp get_versioning_module(_), do: nil

  @doc """
  Commit changes to a version-controlled filesystem.

  Uses the polymorphic versioning interface to support any adapter that implements
  versioning functionality (Git, ETS, InMemory).

  ## Examples

      # Git adapter
      filesystem = Jido.VFS.Adapter.Git.configure(path: "/repo", mode: :manual)
      Jido.VFS.write(filesystem, "file.txt", "content")
      :ok = Jido.VFS.commit(filesystem, "Add new file")

      # ETS adapter (uses versioning wrapper)
      filesystem = Jido.VFS.Adapter.ETS.configure(name: :test_ets)
      :ok = Jido.VFS.commit(filesystem, "Snapshot")

  """
  @spec commit(filesystem, String.t() | nil, keyword()) :: :ok | {:error, term}
  def commit({adapter, config}, message \\ nil, opts \\ []) do
    with versioning_module when not is_nil(versioning_module) <- get_versioning_module(adapter),
         true <- supports?({adapter, config}, :commit) do
      normalize_adapter_call(fn -> versioning_module.commit(config, message, opts) end)
    else
      _ -> unsupported(adapter, :commit)
    end
  end

  @doc """
  List revisions/commits for a path in a version-controlled filesystem.

  Uses the polymorphic versioning interface to support any adapter that implements
  versioning functionality. Returns a list of revision maps with standardized format.

  ## Options

    * `:limit` - Maximum number of revisions to return
    * `:since` - Only revisions after this datetime
    * `:until` - Only revisions before this datetime
    * `:author` - Only revisions by this author

  ## Examples

      # Git adapter
      filesystem = Jido.VFS.Adapter.Git.configure(path: "/repo")
      {:ok, revisions} = Jido.VFS.revisions(filesystem, "file.txt", limit: 10)

      # ETS adapter
      filesystem = Jido.VFS.Adapter.ETS.configure(name: :test_ets)
      {:ok, revisions} = Jido.VFS.revisions(filesystem, "file.txt")

  """
  @spec revisions(filesystem, Path.t(), keyword()) ::
          {:ok, [Jido.VFS.Revision.t()]} | {:error, term}
  def revisions({adapter, config}, path \\ ".", opts \\ []) do
    with versioning_module when not is_nil(versioning_module) <- get_versioning_module(adapter),
         true <- supports?({adapter, config}, :revisions),
         {:ok, normalized_path} <- Jido.VFS.RelativePath.normalize(path) do
      normalize_adapter_call(fn -> versioning_module.revisions(config, normalized_path, opts) end)
      |> normalize_revision_result()
    else
      {:error, reason} -> {:error, convert_path_error(reason, path)}
      _ -> unsupported(adapter, :revisions)
    end
  end

  @doc """
  Read a file as it existed at a specific revision.

  Uses the polymorphic versioning interface to support any adapter that implements
  versioning functionality.

  ## Examples

      # Git adapter
      filesystem = Jido.VFS.Adapter.Git.configure(path: "/repo")
      {:ok, content} = Jido.VFS.read_revision(filesystem, "file.txt", "abc123")

      # ETS adapter
      filesystem = Jido.VFS.Adapter.ETS.configure(name: :test_ets)
      {:ok, content} = Jido.VFS.read_revision(filesystem, "file.txt", "version_id")

  """
  @spec read_revision(filesystem, Path.t(), String.t(), keyword()) ::
          {:ok, binary()} | {:error, term}
  def read_revision({adapter, config}, path, revision, opts \\ []) do
    with versioning_module when not is_nil(versioning_module) <- get_versioning_module(adapter),
         true <- supports?({adapter, config}, :read_revision),
         {:ok, normalized_path} <- Jido.VFS.RelativePath.normalize(path) do
      normalize_adapter_call(fn ->
        versioning_module.read_revision(config, normalized_path, revision, opts)
      end)
    else
      {:error, reason} -> {:error, convert_path_error(reason, path)}
      _ -> unsupported(adapter, :read_revision)
    end
  end

  @doc """
  Rollback the filesystem to a previous revision.

  Uses the polymorphic versioning interface to support any adapter that implements
  versioning functionality.

  ## Options

    * `:path` - Only rollback changes to a specific path (if supported)

  ## Examples

      # Git adapter - full rollback
      filesystem = Jido.VFS.Adapter.Git.configure(path: "/repo")
      :ok = Jido.VFS.rollback(filesystem, "abc123")

      # ETS adapter - single file rollback
      filesystem = Jido.VFS.Adapter.ETS.configure(name: :test_ets)
      :ok = Jido.VFS.rollback(filesystem, "version_id", path: "file.txt")

  """
  @spec rollback(filesystem, String.t(), keyword()) :: :ok | {:error, term}
  def rollback({adapter, config}, revision, opts \\ []) do
    with versioning_module when not is_nil(versioning_module) <- get_versioning_module(adapter),
         true <- supports?({adapter, config}, :rollback) do
      normalize_adapter_call(fn -> versioning_module.rollback(config, revision, opts) end)
    else
      _ -> unsupported(adapter, :rollback)
    end
  end
end