Protocol-based virtual filesystem.

VFS is both a mount table (a struct holding a list of {mountpoint, backend} pairs) and the public API for working with virtual filesystems. The struct itself implements VFS.Mountable, so mount tables nest naturally — you can mount a %VFS{} inside another %VFS{} for namespacing.

The data-flow ops in this module — read_file/2, stream_read/3, write_file/4, mkdir/3, rm/3, walk/3, materialize/2 — are wrapped in :telemetry.span/3 so consumers can attach OpenTelemetry, log, or metric handlers. The cheap lookups (exists?/2, stat/2, readdir/2, capabilities/1) are not instrumented. The "Telemetry events" section below is the full, exact contract.

Quick tour

iex> fs = VFS.new() |> VFS.mount("/", VFS.Memory.new(%{"/repo/README.md" => "hello\n", "/tmp/scratch" => ""}))
iex> {:ok, "hello\n", fs} = VFS.read_file(fs, "/repo/README.md")
iex> {:ok, fs} = VFS.write_file(fs, "/tmp/scratch", "world\n")
iex> {:ok, "world\n", _fs} = VFS.read_file(fs, "/tmp/scratch")
iex> :ok
:ok

State threading

Every op returns the (possibly updated) %VFS{} as the last element of its success tuple. Threading it forward preserves lazy backend caches. See VFS.Mountable for the full contract.

Telemetry events

All under the [:vfs, _, _] prefix:

  • [:vfs, :read_file, :start | :stop | :exception]

  • [:vfs, :stream_read, :start | :stop | :exception]

  • [:vfs, :write_file, :start | :stop | :exception]

  • [:vfs, :mkdir, :start | :stop | :exception]

  • [:vfs, :rm, :start | :stop | :exception]

  • [:vfs, :walk, :start | :stop] (terminal — emitted on enumeration end)

  • [:vfs, :materialize, :start | :stop | :exception]

  • [:vfs, :cache, :hit | :miss] (emitted by lazy backends themselves)

Metadata always includes %{impl: <impl module>}. Errors land in the :stop event metadata as %{error: %VFS.Error{...}}.

Summary

Types

A {mountpoint, backend} pair. The backend is any VFS.Mountable.

t()

Functions

Raise a helpful error if value does not implement VFS.Mountable.

Return the capability set supported by impl.

Return whether path exists, plus the possibly cache-updated state.

Create directory at path.

Mount backend at mountpoint. If a mount already exists at the same point, it is replaced. Mounts are kept sorted by mountpoint length (longest first) so longest-prefix routing is a linear scan.

Return the list of {mountpoint, backend} pairs in longest-first order.

Build an empty mount table. Use mount/3 to attach backends.

Read the entire content of path.

List entries directly under directory path.

Return metadata for path.

Remove the mount at mountpoint. No-op if no such mount.

Lazily walk the tree under root. Emits {path, %VFS.Stat{}} tuples. Telemetry: [:vfs, :walk, :start | :stop] (terminal — emitted on enumeration completion).

Types

mount()

@type mount() :: {VFS.Path.t(), VFS.Mountable.t()}

A {mountpoint, backend} pair. The backend is any VFS.Mountable.

t()

@type t() :: %VFS{mounts: [mount()]}

Functions

assert_implemented!(value)

@spec assert_implemented!(term()) :: :ok

Raise a helpful error if value does not implement VFS.Mountable.

Useful at trust boundaries (constructor arguments, public API entry points) where catching a missing defimpl early beats a Protocol.UndefinedError deep in a downstream call.

Examples

iex> VFS.assert_implemented!(VFS.Memory.new())
:ok

iex> VFS.assert_implemented!(%URI{})
** (ArgumentError) %URI{} does not implement the VFS.Mountable protocol. Add `defimpl VFS.Mountable, for: URI do ... end` or pass a struct that has one.

capabilities(impl)

@spec capabilities(VFS.Mountable.t()) :: MapSet.t(VFS.Mountable.capability())

Return the capability set supported by impl.

Examples

iex> caps = VFS.capabilities(VFS.Memory.new())
iex> MapSet.subset?(MapSet.new([:read, :write, :mkdir]), caps)
true

exists?(impl, path)

@spec exists?(VFS.Mountable.t(), String.t()) :: {boolean(), VFS.Mountable.t()}

Return whether path exists, plus the possibly cache-updated state.

Examples

iex> mem = VFS.Memory.new(%{"/note.txt" => "hi"})
iex> {true, _mem} = VFS.exists?(mem, "/note.txt")
iex> :ok
:ok

materialize(impl, opts \\ [])

@spec materialize(
  VFS.Mountable.t(),
  keyword()
) :: {:ok, VFS.Mountable.t()} | {:error, VFS.Error.t()}

Pre-warm any internal cache. See VFS.Mountable.materialize/2.

Non-lazy backends usually return unchanged state.

Examples

iex> mem = VFS.Memory.new(%{"/note.txt" => "hi"})
iex> {:ok, ^mem} = VFS.materialize(mem)
iex> :ok
:ok

mkdir(impl, path, opts \\ [])

@spec mkdir(VFS.Mountable.t(), String.t(), keyword()) ::
  {:ok, VFS.Mountable.t()} | {:error, VFS.Error.t()}

Create directory at path.

Pass parents: true for mkdir -p behavior.

Examples

iex> mem = VFS.Memory.new()
iex> {:ok, mem} = VFS.mkdir(mem, "/a/b", parents: true)
iex> {true, _mem} = VFS.exists?(mem, "/a/b")
iex> :ok
:ok

mount(vfs, mountpoint, backend)

@spec mount(t(), String.t(), VFS.Mountable.t()) :: t()

Mount backend at mountpoint. If a mount already exists at the same point, it is replaced. Mounts are kept sorted by mountpoint length (longest first) so longest-prefix routing is a linear scan.

Raises ArgumentError if backend does not implement VFS.Mountable.

Examples

iex> fs = VFS.new() |> VFS.mount("/repo", VFS.Memory.new(%{"/x" => "1"}))
iex> {:ok, "1", _fs} = VFS.read_file(fs, "/repo/x")
iex> :ok
:ok

mounts(vfs)

@spec mounts(t()) :: [mount()]

Return the list of {mountpoint, backend} pairs in longest-first order.

Examples

iex> fs = VFS.new() |> VFS.mount("/repo", VFS.Memory.new()) |> VFS.mount("/", VFS.Memory.new())
iex> fs |> VFS.mounts() |> Enum.map(&elem(&1, 0))
["/repo", "/"]

new()

@spec new() :: t()

Build an empty mount table. Use mount/3 to attach backends.

Examples

iex> %VFS{mounts: []} = VFS.new()

iex> fs = VFS.new() |> VFS.mount("/", VFS.Memory.new(%{"/foo" => "bar"}))
iex> {:ok, "bar", _fs} = VFS.read_file(fs, "/foo")
iex> :ok
:ok

read_file(impl, path)

@spec read_file(VFS.Mountable.t(), String.t()) ::
  {:ok, binary(), VFS.Mountable.t()} | {:error, VFS.Error.t()}

Read the entire content of path.

Derived from stream_read/3 — runs the chunk stream into a binary. Use stream_read/3 directly when you need :chunk_size, :byte_range, or :line_range options.

Examples

iex> mem = VFS.Memory.new(%{"/hello.txt" => "hello"})
iex> {:ok, "hello", _mem} = VFS.read_file(mem, "/hello.txt")
iex> :ok
:ok

readdir(impl, path)

@spec readdir(VFS.Mountable.t(), String.t()) ::
  {:ok, Enumerable.t(String.t()), VFS.Mountable.t()} | {:error, VFS.Error.t()}

List entries directly under directory path.

Returns an Enumerable.t/0 of names: a list for bounded backends, a Stream for paginated or unbounded ones. Treat it as an Enumerable — Enum.to_list/1 only when you know the listing is bounded, Stream.take/2 when you don't. See VFS.Mountable.readdir/2.

rm(impl, path, opts \\ [])

@spec rm(VFS.Mountable.t(), String.t(), keyword()) ::
  {:ok, VFS.Mountable.t()} | {:error, VFS.Error.t()}

Remove path.

Pass recursive: true to remove a directory tree.

Examples

iex> mem = VFS.Memory.new(%{"/note.txt" => "hi"})
iex> {:ok, mem} = VFS.rm(mem, "/note.txt")
iex> {false, _mem} = VFS.exists?(mem, "/note.txt")
iex> :ok
:ok

stat(impl, path)

@spec stat(VFS.Mountable.t(), String.t()) ::
  {:ok, VFS.Stat.t(), VFS.Mountable.t()} | {:error, VFS.Error.t()}

Return metadata for path.

Examples

iex> mem = VFS.Memory.new(%{"/note.txt" => "hi"})
iex> {:ok, %VFS.Stat{type: :regular, size: 2}, _mem} = VFS.stat(mem, "/note.txt")
iex> :ok
:ok

stream_read(impl, path, opts \\ [])

@spec stream_read(VFS.Mountable.t(), String.t(), keyword()) ::
  {:ok, Enumerable.t(binary()), VFS.Mountable.t()} | {:error, VFS.Error.t()}

Open path for streaming read. See VFS.Mountable.stream_read/3.

Examples

iex> mem = VFS.Memory.new(%{"/hello.txt" => "hello\nworld\n"})
iex> {:ok, stream, _mem} = VFS.stream_read(mem, "/hello.txt", line_range: {2, 2})
iex> Enum.to_list(stream)
["world"]

umount(vfs, mountpoint)

@spec umount(t(), String.t()) :: t()

Remove the mount at mountpoint. No-op if no such mount.

Examples

iex> fs = VFS.new() |> VFS.mount("/", VFS.Memory.new(%{"/a" => "b"})) |> VFS.umount("/")
iex> %VFS{mounts: []} = fs

walk(impl, root, opts \\ [])

Lazily walk the tree under root. Emits {path, %VFS.Stat{}} tuples. Telemetry: [:vfs, :walk, :start | :stop] (terminal — emitted on enumeration completion).

Examples

iex> fs = VFS.new() |> VFS.mount("/", VFS.Memory.new(%{"/a" => "1", "/b/c" => "2"}))
iex> fs |> VFS.walk("/", []) |> Enum.map(&elem(&1, 0)) |> Enum.sort()
["/a", "/b/c"]

write_file(impl, path, content, opts \\ [])

@spec write_file(VFS.Mountable.t(), String.t(), binary(), keyword()) ::
  {:ok, VFS.Mountable.t()} | {:error, VFS.Error.t()}

Write content to path.

Returns the updated filesystem state; thread it into subsequent calls.

Examples

iex> mem = VFS.Memory.new()
iex> {:ok, mem} = VFS.write_file(mem, "/note.txt", "hi")
iex> {:ok, "hi", _mem} = VFS.read_file(mem, "/note.txt")
iex> :ok
:ok