# 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, since a
restatement goes stale while the skill does not.
## What this is
`rtb-config` is a crate in the [phpboyscout Rust toolkit](https://rust.phpboyscout.uk),
published to crates.io and documented at <https://config.rust.phpboyscout.uk>. It
gives a tool typed, layered configuration over figment. `Config<C>` is generic
over *your* `serde::Deserialize` struct — no `get_string("foo.bar")` accessors,
ever — with `C` defaulting to `()` so a consumer holding an `Arc<Config>` need not
carry the type parameter. Kinds merge in a fixed order — embedded `&'static str`
YAML, then user files, then prefixed environment variables — whatever order the
builder methods were called in, and within a kind the last registration wins.
`get()` returns an `arc_swap` snapshot, `reload()` re-reads every source and swaps
atomically, `subscribe()` wakes on each *successful* reload. Two Cargo features,
**both off by default** (`Cargo.toml:32-42`): `hot-reload` (`Config::watch_files`,
a 250 ms debounced `notify` watcher on an OS thread, `src/watch.rs:26`) and
`mutable` (`Config::schema`, `Config::write`).
**It is one crate, not a family.** That matters most arriving from the Go side,
where `config` is a core plus twenty-six `config-` sibling repos, mostly codecs and
backends. Nothing here answers to that shape: `Sources` is a private struct with
three fixed `Vec`s (`src/config.rs:156-159`) — no provider trait, no registry, no
extension point — so adding a format or a remote source means editing
`Sources::parse` here. `docs/explanation/limitations.md` states the rest of the
boundary and is the thing to read before promising a caller anything: no provenance,
no CLI-flag layer, no remote sources, no validation beyond serde, and on secrets only
that `Config`'s `Debug` never renders `C` (`src/config.rs:35-50`).
`src/lib.rs:47` ships `#![forbid(unsafe_code)]` while the manifest sets
`unsafe_code = "deny"` (`Cargo.toml:20`), so integration tests can
`#![allow(unsafe_code)]` for `unsafe { std::env::set_var }`, as `tests/unit.rs:10`
and `tests/bdd.rs:6` do. Do not tidy either into agreement with the other.
## Where it has got to
Pre-1.0, on v0.6.x; the read path is settled, the write and watch paths are not.
**Spec 0044, `rtb-config v2 — a Store architecture`, is APPROVED and retires
figment** for a native Store with provenance and structure-preserving writes. Weigh
that before investing in the figment layering: deepening `Sources` is work the
approved design throws away; fixing a documented defect on today's surface is not.
**Specs live in the rust-tool-base wiki** as `specs/0004-rtb-config-v0.1`,
`specs/0014-rtb-config-hot-reload` and `specs/0044-rtb-config-v2-store-architecture`,
and the source comments link there. Tests are
numbered against those specs — `T#` in `tests/unit.rs`, `S#` in
`tests/features/config.feature` — so a new criterion takes the next number.
## Who depends on this
Four crates in three other repos, all by **published crates.io version, not path**:
| `rust-tool-base` | `rtb` | `crates/rtb/Cargo.toml:53` |
| `rust/app` | `rtb-app` | `crates/rtb-app/Cargo.toml:21` |
| `rust/app` | `rtb-test-support` | `crates/rtb-test-support/Cargo.toml:21` |
| `rust/cli` | `rtb-cli` | `crates/rtb-cli/Cargo.toml:22` |
**Every one of those four edges is unconditional**, which is not the umbrella's
norm: nine of the thirteen `rtb-*` deps in `crates/rtb/Cargo.toml` are
`optional = true` and this is one of the four that are not. `rtb-test-support`
being among them means a breaking change here takes out other crates' test
scaffolding as well as their production paths. Nothing here depends on a sibling,
so this crate moves alone — but a change reaches those four only once released and
bumped, and none of them enables `hot-reload` or `mutable`, so both optional
surfaces are unconsumed inside the estate.
## The traps
**Reading is YAML-only; writing is not.** `ConfigBuilder::user_file` merges every
path through `Yaml::file` whatever the extension (`src/config.rs:185`), while
`Config::write` picks its format from the extension (`src/mutable.rs:90-96`). Write
a `.toml` and this crate cannot read it back; JSON survives only because YAML 1.2
is a superset of it, a parser property rather than a supported format.
**An underscore in an environment variable is a nesting delimiter, not a
character.** The env layer registers `.split("_")` (`src/config.rs:191`), so a flat
`max_retries` field is unreachable from the environment: `MYTOOL_MAX_RETRIES`
becomes `max.retries`, and an unknown key is ignored with no error and no warning.
**`with_value` and `Default` build a `Config` with no sources at all**, so
`reload()` re-parses an empty source set — silently resetting a `Default`-shaped
struct to `Default`, erroring for anything else (`src/config.rs:89-112`).
**`just ci` runs with `--all-features`, as CI does** (`justfile:17-27`), so
`src/watch.rs`, `src/mutable.rs`, `tests/mutable.rs`, `tests/watch.rs` and the
`@hot-reload` scenario are all compiled by it. Keep it that way: the `rust-lint`,
`rust-test` and `rust-docs` components default to `--all-features`, and a recipe
without the flag stops compiling both optional features. CI also sets
`RUSTFLAGS: -D warnings`, so a `missing_docs` or clippy warning that is merely
noisy locally fails the gate.
## Which skills apply here
No Rust-specific skills exist yet, so this is the language-agnostic set only, and
releases run through colophon, so `releaser-pleaser-releases` never applies.
| Any new or changed public item, or a move towards the v2 Store | `spec-driven-development` |
| Deciding what belongs behind the `Config` seam and what does not | `deep-modules` |
| Before calling a change finished — tests, scenarios and docs together | `definition-of-done` |
| A watcher or reload bug that will not reproduce on demand | `diagnose-with-a-red-loop` |
| Reporting a result, or trusting a green `just ci` | `verify-dont-trust` |
| Writing anything a reader can check against this code | `checkable-claims` |
| Editing `README.md` or anything under `docs/` | `diataxis-docs`, `write-like-a-person` |
| A fix that belongs in `rust/app`, `rust/cli` or the umbrella | `raise-a-forge-issue` |
| Releasing this crate and its four consumers together | `release-train` |
| A Renovate MR on the toolchain pin or a CI component | `assess-before-bumping` |
| Working in a repo other than the one you were invoked in | `cross-repo-worktree` |
| 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` |
> 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
> rather than a file it can load.
## House rules
- Linear history. Rebase and fast-forward; never squash-merge from the UI.
- Conventional Commits, and the type decides whether a release is cut. `feat`,
`fix`, `perf` and `refactor` release. A change that repoints or removes a public
interface is `feat`, not `refactor`, or it ships as a patch.
- No AI attribution in anything published, and never at-mention anyone.
- Never cut a release yourself. That is the maintainer's call, every time.