Skip to content

Write a custom asset source

The three built-in layers — embedded, directory, in-memory — cover the cases a CLI tool usually has. When yours is different (an in-process archive, a remote-fetched theme pack, files synthesised at startup), implement AssetSource and register it with .source().

Implement the trait

Three methods, no associated types, no error channel:

use std::sync::Arc;
use rtb_assets::{AssetSource, Assets};

/// Serves one generated file and nothing else.
struct GeneratedSource {
    path: String,
    bytes: Vec<u8>,
}

impl AssetSource for GeneratedSource {
    fn read(&self, path: &str) -> Option<Vec<u8>> {
        (path == self.path).then(|| self.bytes.clone())
    }

    fn list(&self, dir: &str) -> Vec<String> {
        let prefix = if dir.is_empty() || dir == "." {
            String::new()
        } else if dir.ends_with('/') {
            dir.to_string()
        } else {
            format!("{dir}/")
        };
        let Some(rest) = self.path.strip_prefix(prefix.as_str()) else {
            return Vec::new();
        };
        if rest.is_empty() {
            return Vec::new();
        }
        // Immediate child only: keep the segment before the first '/'.
        let head = rest.find('/').map_or(rest, |i| &rest[..i]);
        vec![head.to_string()]
    }

    fn name(&self) -> &str {
        "generated"
    }
}

Register it lowest-priority-first like any other layer:

let source = Arc::new(GeneratedSource {
    path: "build/version.txt".to_string(),
    bytes: b"0.6.3".to_vec(),
});
let assets = Assets::builder().source(source).build();

assets.open_text("build/version.txt");  // Ok("0.6.3")
assets.list_dir("build");               // ["version.txt"]
assets.list_dir("");                    // ["build"]

Get list right — it is the easy one to get wrong

list returns the immediate children of a directory, with the directory prefix stripped. Three details the built-in sources all handle, and a hand-written source usually forgets:

  • "" and "." both mean the root.
  • A trailing slash is optional: "d" and "d/" are the same request.
  • Deeper paths contribute their first segment only. A source holding build/version.txt must return ["build"] for the root and ["version.txt"] for build, not the full path in either case.

The prefix-and-head-segment block above is the pattern the built-in sources use; copying it is the fastest way to be consistent with them.

Satisfy the trait's bounds

AssetSource requires Send + Sync + 'static. Assets stores every layer as Arc<dyn AssetSource> and is itself Send + Sync, so a source holding an Rc, a RefCell, or a borrowed lifetime will not compile. Interior mutability needs Mutex or RwLock.

There is no &mut self anywhere on the trait. A source that needs to mutate — a cache, a lazily-populated map — does it behind a lock.

Follow the three rules of a well-behaved layer

Never panic, and never block for long. read runs on the caller's thread, and a merge across several layers calls it several times in sequence. A source that reaches the network will stall every one of those calls.

Return None for anything you cannot serve. The trait has no error channel by design: None means "not here", and the next layer down gets its turn. Swallowing your own errors as None is the correct behaviour, not a compromise — but it does mean you must handle and log them yourself if anyone is to know.

Make list agree with read. A name returned by list should either be readable as dir/name or be a directory. A listing that promises files which cannot be read is the one inconsistency the overlay cannot paper over, because callers iterate listings and read what they find.

Give it a meaningful name

name is diagnostic-only — nothing looks a layer up by it — but it is what appears in a YAML or JSON parse error and in the Debug output of Assets:

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

That line is the first thing anyone debugging precedence will look at. An empty string is allowed and unhelpful.

Caching a remote layer

If the layer's content comes from somewhere slow, fetch it before building the overlay and serve from memory afterwards. Two reasons: a read inside a merge has no way to signal "try again later", and a failed fetch mid-run would make the overlay's answers change from one call to the next, which nothing in the crate expects.

If the content must be refreshed, refresh it into a new source, build a new Assets, and swap it in. Construction copies no bytes.