Development loop
aozora’s development workflow is built around three rules:
- Docker-only execution. The host toolchain is never invoked.
justis the entry point. Every operation goes through ajustrecipe that wraps the underlying tool inside the dev container.- Lint gates run automatically. lefthook installs git hooks
that run
fmt + clippy + typospre-commit andtest + denypre-push, so a passing local commit roughly mirrors a passing CI run.
First-time setup
git clone git@github.com:P4suta/aozora.git
cd aozora
docker compose build dev # ~5 min the first time, cached afterwards
just hooks # install lefthook git hooks
just test # confirm green
Daily loop
just shell # drop into the dev container
just build # cargo build --workspace --all-targets
just test # workspace nextest
just lint # fmt + clippy + typos + strict-code
just prop # property-based sweep (128 cases / block)
just ci # full CI replica (lint + build + test + prop + deny + audit + udeps + coverage + book-build)
just --list enumerates everything available; just --list --unsorted
preserves the topical grouping (build → test → lint → deps → bench →
docs → release → dev-helpers).
Watch mode (bacon)
just watch # default `check` job
just watch clippy
just watch test
Inside bacon: t test, c clippy, d doc, f failing-only,
esc previous job, q quit, Ctrl-J list jobs. The watcher runs
inside the dev container so file change detection works against the
bind-mounted source.
For headless usage (no TTY, e.g. piping to tee):
just watch-headless check # plain output, no TUI
Why Docker for everything?
Three reasons.
- Toolchain reproducibility. The dev image pins
rust:1.95.0-bookwormplus exact versions ofcargo-nextest,cargo-llvm-cov,cargo-deny,cargo-audit,cargo-udeps,cargo-semver-checks,cargo-fuzz,mdbook,mdbook-mermaid,lychee,git-cliff,bacon, andlefthook. A fresh checkout on any machine produces identical tool behaviour. - sccache hits. The compose file mounts a named volume at
/workspace/.sccacheand setsRUSTC_WRAPPER=sccache. Across sessions and across branches, the cache stays warm. - Host insulation. Nothing in the workspace touches
~/.cargo,~/.rustup, or any global state. Removing the project meansdocker compose down -v && rm -rf aozora/.
The two exceptions to Docker-only:
- samply profiling.
perf_event_open(2)doesn’t survive the container seccomp profile; thesamply-*recipes invoke the host toolchain (see Profiling with samply). - Release builds. GitHub Actions runners build the release binaries natively per OS (the cross-target binary needs to match its runner OS exactly).
Editor / IDE setup
The repository includes a .devcontainer/ config, so:
- VS Code with Dev Containers extension — “Reopen in Container”
picks up the dev image, the rust-analyzer toolchain, and the
aozora-*workspace at once. No host-side rust install needed. - Anything else — point your editor’s rust-analyzer at the dev
container via
docker exec. The cleanest approach is symlinkingtarget/from the named volume to a host-visible path; the alternative is the editor’s own remote-LSP support.
sccache stats
After a build cycle, check that the cache is actually warm:
just sccache-stats
Healthy steady state: 80%+ hit rate during normal iteration. A
sub-50% hit rate usually means RUSTC_WRAPPER got defeated — the
likely culprit is a stray env override or an [env] in
.cargo/config.toml. To reset counters before a measurement window:
just sccache-zero && just clean && just build && just sccache-stats
Pre-commit hooks (lefthook)
lefthook.yml configures:
- pre-commit (parallel):
fmt,clippy,typos. - commit-msg: Conventional Commits regex.
- pre-push (parallel):
test,deny.
The hooks shell into docker compose run --rm dev … so they’re
identical to the just recipes you ran manually. To skip a hook
temporarily, push from the dev container’s shell directly (the
hooks attach to the host git, not the container’s git).
Why lefthook over husky / pre-commit / cargo-husky?
- husky — Node-only ecosystem; would force a Node dep into a Rust workspace.
- pre-commit (Python framework) — Python-only ecosystem; same issue inverted.
- cargo-husky — abandoned upstream.
- lefthook — single Go binary, language-neutral, parallel execution, ships from a small upstream that’s actively maintained. Mainstream choice for polyglot Rust workspaces in 2026.
Conventional commits
The commit-msg hook enforces:
<type>(<scope>): <subject>
Where <type> ∈ feat | fix | docs | style | refactor | perf | test | build | ci | chore | revert,
and <scope> is typically a crate name without the aozora- prefix
(e.g. feat(render): add aozora-tcy class hook).
git-cliff turns these into the CHANGELOG on release.
Adding a new 青空文庫 notation
End-to-end TDD flow:
- Spec fixture. Add a
(input, html, serialise)triple underspec/aozora/cases/. - AST variant. Add a borrowed-arena variant to
AozoraNodeincrates/aozora-syntax/src/borrowed.rs. - Lexer test (red). Add a case to the relevant phase test
under
crates/aozora-pipeline/tests/. - Lexer impl (green). Wire the recogniser into the appropriate phase (sanitize → events → pair → classify).
- Renderer. Emit the new HTML shape in
crates/aozora-render/src/html.rsand the canonical serialisation incrates/aozora-render/src/serialize.rs. - Cross-layer invariants. Extend the property test or corpus predicate that the new shape interacts with (escape-safety, round-trip, span well-formedness).
See also
- Testing strategy — what each test layer asserts.
- Release process — how a tag becomes a published release.