Skip to content

Assets and AssetsBuilder reference

Assets is the read-only handle downstream code holds. AssetsBuilder registers the layers it reads from. Both live in rtb_assets::assets and are re-exported at the crate root, so use rtb_assets::{Assets, AssetsBuilder}; is the import you want.

Every path argument is a &str, not a Path. Asset paths are /-separated strings regardless of host platform — see Path rules.

How do I construct an Assets?

let assets = rtb_assets::Assets::builder()
    // .embedded / .directory / .memory / .source, lowest priority first
    .build();

Assets::builder() and AssetsBuilder::new() are the same thing; the builder derives Default, so AssetsBuilder::default() also works. Assets itself derives DefaultAssets::default() is a valid handle with zero layers, from which every read returns nothing. That is useful as a placeholder in a struct that has not been configured yet, and it never errors.

The builder is #[must_use]: dropping it without calling build() is a compiler warning, because a builder that is never built has done nothing.

What does each builder method do?

Method Signature Notes
embedded::<E> fn embedded<E>(self, label: &'static str) -> Self where E: RustEmbed + Send + Sync + 'static Adds a compile-time embedded layer. E is your #[derive(RustEmbed)] type, passed by turbofish.
directory fn directory(self, root: impl Into<PathBuf>, label: impl Into<String>) -> Self Adds a layer over a directory on the host filesystem.
memory fn memory(self, label: impl Into<String>, files: HashMap<String, Vec<u8>>) -> Self Adds a layer over an in-memory map.
source fn source(self, source: Arc<dyn AssetSource>) -> Self Adds any custom layer.
build fn build(self) -> Assets Freezes the stack.

directory takes the root first, memory takes the label first

.directory(root, label) and .memory(label, files) put the label in different positions. When both arguments to .directory are string literals, swapping them still compiles — impl Into<PathBuf> and impl Into<String> each accept a &str — and produces a layer rooted at the label, which silently reads nothing:

// Wrong way round; compiles, reads nothing.
let a = Assets::builder().directory("user", "/etc/mytool").build();
// Debug shows the mistake: Assets { layers: ["/etc/mytool"] }

If a directory layer contributes no files, print the Assets with {:?} and check the labels before you check the disk.

label is used only in diagnostics: it is what AssetError::Parse names when a layer's YAML or JSON fails to parse, and what Debug prints. It is not deduplicated and it is not looked up — two layers may share a label without error.

In what order are layers consulted?

Registration order is priority order, lowest first. The last registered layer that provides a path wins a byte read; a merge folds layers first to last, so later layers override earlier ones.

let assets = Assets::builder()
    .embedded::<Defaults>("defaults")   // lowest priority
    .directory("/etc/mytool", "system")
    .directory(user_dir, "user")        // highest priority
    .build();

Layer precedence explains why the two read paths behave differently.

open — read the winning layer's bytes

#[must_use]
pub fn open(&self, path: &str) -> Option<Vec<u8>>

Walks layers highest-priority first and returns the first layer's bytes. Returns None when no layer provides path, when path names a directory, and when a DirectorySource rejects the path as unsafe. A missing file is not an error — the overlay model expects most layers not to have most paths.

The returned Vec<u8> is a fresh copy on every call. Nothing is cached; a DirectorySource re-reads from disk each time.

open_text — read the winning layer's bytes as UTF-8

pub fn open_text(&self, path: &str) -> Result<String, AssetError>

open plus a UTF-8 check. Unlike open, a missing path is an error here:

Condition Result
Some layer has path, bytes are valid UTF-8 Ok(String)
No layer has path Err(AssetError::NotFound(path))
Bytes are not valid UTF-8 Err(AssetError::NotUtf8 { path })

A UTF-8 BOM is not stripped — it arrives as \u{feff} at the start of the string.

exists — is this path provided by any layer?

#[must_use]
pub fn exists(&self, path: &str) -> bool

true if any layer provides path, not only the winning one. It answers "can I read this", not "which layer would win".

exists is implemented by attempting the read and discarding the result, so on a DirectorySource it costs a full file read. Prefer open/open_text and match on the result when you are about to read the file anyway.

exists returns false for a directory, even when list_dir lists that directory as an entry of its parent.

list_dir — what is directly inside this directory?

#[must_use]
pub fn list_dir(&self, dir: &str) -> Vec<String>

Returns the immediate children of dir across every layer: deduplicated, byte-order sorted, and stripped of the dir prefix. Nested paths are not flattened — d/sub/b.txt contributes sub to list_dir("d"), not sub/b.txt.

// layer "lo": d/a.txt, d/sub/b.txt   layer "hi": d/b.txt
assets.list_dir("d");   // ["a.txt", "b.txt", "sub"]

"" and "." both mean the root. A trailing slash is accepted, so "d" and "d/" are equivalent. An unknown directory, a path that names a file, and a path a DirectorySource rejects all return an empty Vec rather than an error.

Two things the return value does not tell you:

  • Which entries are directories. Names come back bare. Probe with existsfalse for a name that appeared in the listing means it is a directory.
  • Which layer each entry came from. Entries are unioned; a name present on three layers appears once.

Sorting is by byte value, so README.md sorts before assets.rs.

load_merged_yaml / load_merged_json — merge a file across layers

pub fn load_merged_yaml<T: DeserializeOwned>(&self, path: &str) -> Result<T, AssetError>
pub fn load_merged_json<T: DeserializeOwned>(&self, path: &str) -> Result<T, AssetError>

Reads path from every layer that has it, deep-merges the results in registration order, then deserialises the merged document into T. This is the only pair of methods that does not stop at the winning layer.

The merge rules — which values combine, which replace, and which delete — are set out in Merge semantics, and they contain at least two surprises. Read that page before you ship a layered config file.

Failure modes, in the order they are reached:

Condition Result
No layer has path Err(AssetError::NotFound(path))
A contributing layer's bytes are not UTF-8 (YAML only) Err(AssetError::Parse), path names the layer
A contributing layer fails to parse Err(AssetError::Parse), path names the layer
The merged document does not fit T Err(AssetError::Parse), path is the bare path

A parse failure on any contributing layer aborts the whole load. There is no fallback to the next layer down: a broken user override surfaces as an error rather than silently reverting to defaults.

Fields of T that the merged document does not mention need a serde default (#[serde(default)], or Option<T>), exactly as they would loading a single file. Unknown keys in the document are ignored unless T opts into #[serde(deny_unknown_fields)].

What traits does Assets implement?

Clone, Debug, Default, Send, Sync, 'static.

Clone is refcount-only — layers live behind Arc, so cloning an Assets into a thread or a task copies two pointers and no asset bytes. Hold it by value rather than behind a lock.

Debug prints layer labels lowest-priority first and nothing else:

Assets { layers: ["defaults", "system", "user"] }

AssetsBuilder implements Debug the same way, plus Default. Neither type implements PartialEq, Serialize, or Deserialize.

What is deliberately not on this API?

There is no write, no delete, no create, no cache invalidation, no file-watching, no metadata (size, mtime, mime type), no streaming reader, and no way to ask which layer supplied a path. See What rtb-assets does not do for why, and for what to reach for instead.