rtb-assets 0.6.4

Embedded-asset + overlay filesystem abstraction. Part of the phpboyscout Rust toolkit.
Documentation
# AGENTS.md

This file provides guidance to AI coding agents (Claude Code, agy, codex, etc.) when working with code in this repository.

Ways of working are deliberately not repeated here. They live in the phpboyscout
skills, and naming a skill tends to age better than restating it.

## What this crate is

`rtb-assets` is a read-only overlay filesystem. You stack layers — a
`rust-embed` type, a directory on disk, an in-memory map — and read through the
stack as if it were one tree, later-registered layers winning. It exists so a
tool can ship defaults compiled into its binary and let a user shadow them from
disk without the tool knowing which happened.

The public surface is one container, one builder, one trait with three
implementations and one error enum, in ~530 lines across `src/lib.rs`,
`src/assets.rs`, `src/source.rs` and `src/error.rs`. Read all four before
changing any. The contract is spec `0002-rtb-assets-v0.1` on the
[rust-tool-base wiki](https://gitlab.com/phpboyscout/rust-tool-base/-/wikis/specs/0002-rtb-assets-v0.1)
— `src/lib.rs:36` cites a repo-relative path for it that is in neither tree.

The crate never writes, creates or deletes. No metadata, no streaming, no
caching, no file watching, no way to ask which layer supplied a byte, and the
stack is fixed once built. Those gaps are deliberate with the reason recorded in
`docs/explanation/limitations.md` — read it before proposing a feature. It is
framework-free too: `deny.toml` bans a dependency back on `rust-tool-base`, and
there is no async runtime and no TLS.

## The three things people get wrong

**`open` reads one layer; the merge loaders read every layer.** `open` and
`open_text` return the highest-priority layer's bytes and stop
(`src/assets.rs:29`); `load_merged_yaml` and `load_merged_json` walk the whole
stack and deep-merge (`src/assets.rs:115`). The same override file shadows
wholesale through one and merges through the other, which is usually the answer
to "why did my override not take effect".

**The merge is RFC 7396, via `json_patch::merge`.** `null` in a higher layer
*deletes* the key rather than setting it, and an array replaces the one below it
entirely — no append, no merge-by-index. YAML converts to `serde_json::Value`
first, so `.inf` and `.nan` become `null` silently and explicit tags are lost.
`docs/reference/merge-semantics.md` has the table; treat it as contract, because
`serde_yaml` 0.9.34 ships as `0.9.34+deprecated` and any replacement moves it.

**`debug-embed` is enabled, and Cargo features are additive across the whole
graph.** `Cargo.toml:33` turns on rust-embed's `debug-embed`, removing
rust-embed's usual read-from-disk-in-debug-builds behaviour. Verified by editing
an embedded file on disk and re-running two debug binaries without rebuilding:
with `rtb-assets` in the graph, both the `rtb-assets` read and a *direct*
`rust_embed::RustEmbed` read returned the compile-time bytes; without it, that
same direct read returned the edited bytes. Adding this crate therefore takes
dev-mode passthrough away from a consumer's own unrelated embeds; layer a
`DirectorySource` over the embedded one to get live editing back.

## Its place in the toolkit

Inter-repo dependencies resolve by published crates.io version, never by path,
so a change made here reaches nothing until release-plz publishes it. Five
crates in three sibling repos depend on `rtb-assets` directly, every one a
plain `rtb-assets = { version = "0.6.0" }`: `rtb-app` and `rtb-test-support`
(`phpboyscout/rust/app`), `rtb-cli` and `rtb-docs` (`phpboyscout/rust/cli`), and
`rust-tool-base` at `crates/rtb/Cargo.toml:54`, which re-exports it as
`rtb::assets`. None of the five marks it `optional`, which is unusual here —
nine of the thirteen `rtb-*` deps in `crates/rtb` are feature-gated and this is
not, so nothing compiles it out. Measured across every `Cargo.toml` on
`origin/main` in the twelve `phpboyscout/rust/*` projects and `rust-tool-base`'s
crates. Treat the public API as load-bearing, not private.

Two surfaces make that reach concrete: `rtb-app` holds `pub assets: Arc<Assets>`
on the `App` context struct (`crates/rtb-app/src/app.rs:52`), so every command
handler in every rtb tool sees this type, and `rtb-docs` builds a whole
documentation tree out of an `Assets` (`crates/rtb-docs/src/loader.rs:36`).

Two directories in `rust-tool-base` look like they belong here and do not:
`overrides/` is the Zensical docs theme's `custom_dir` (`zensical.toml:13`), and
`assets/init/config.yaml` is a default-config file nothing on `origin/main`
references. Its workspace manifest also groups `rust-embed` with `vfs` under
"Embedded assets + VFS"; this crate uses `rust-embed` and not `vfs`.

## Working here

`just ci` is the gate — `fmt-check`, `lint`, `docs`, `test`, `audit` — and it
matches CI. Lint is `cargo clippy --all-targets -- -D warnings` with clippy
`pedantic`, `nursery` and `cargo` at `warn`, so a pedantic nit fails the build.
`src/lib.rs:39` declares `#![forbid(unsafe_code)]`, not `deny`; `Cargo.toml:20`
sets `unsafe_code = "deny"` package-wide so tests can `#![allow]` where they
must, but the library cannot re-allow it.

One gap to know about: neither `just test` nor CI runs doctests. The justfile
recipe is `cargo nextest run || cargo test` and nextest skips doctests, so the
fallback never fires when nextest is installed; CI's `rust-test` component runs
`cargo nextest run --workspace`. Both examples pass — run `cargo test --doc`
after touching one.

Behaviour is specified twice on purpose: `tests/features/assets.feature` holds
six cucumber scenarios covering precedence, merge and the error variants, and
`tests/unit.rs` holds fourteen unit tests. A change moves both and the matching
`docs/` page, in the same commit.

## Which skills apply here

> The estate has no Rust-specific skills yet, so this is the language-agnostic
> set. Rows that only make sense for the Go toolkit are dropped, not reworded.

| When | Skill |
|---|---|
| Starting a feature, a component, or any non-trivial change | `spec-driven-development` |
| Deciding whether a change is finished | `definition-of-done` |
| Concluding anything from a sibling checkout, a lockfile or a claim in a doc | `verify-dont-trust` |
| Editing a consumer (`rtb-app`, `rtb-cli`, `rtb-docs`, `rust-tool-base`) to try a change | `cross-repo-worktree` |
| Writing or restructuring anything under `docs/` | `diataxis-docs` |
| Writing anything that names a version, a method or a behaviour | `checkable-claims` |
| Touching path handling — `safe_join` is the single hardened helper | `centralize-security-footguns` |
| Writing a commit message or a merge request description | `conventional-commits`, `pre-1-0-release-safety` |
| Committing, branching, merging, or opening a merge request | `forge-publish-workflow` |
| Releasing this crate together with the crates that depend on it | `release-train` |

> Skills are a Claude Code mechanism, shipped by the
> [phpboyscout marketplace]https://gitlab.com/phpboyscout/claude-code-plugins.
> An agent without them should treat a named skill as a topic to ask about.

## House rules

- Linear history. Rebase and fast-forward; never squash-merge from the UI.
- The commit type decides whether a release is cut: only `feat` and `fix`
  release, so a change that repoints or removes a public interface is `feat`,
  not `refactor`, or it lands and never ships.
- Releases run through `release-plz`, not the releaser-pleaser flow the Go side
  uses. Tags are `rtb-assets-v<version>`; never tag or release by hand.
- No AI attribution in anything published, and never at-mention anyone.