| name | toolchains-rust-core |
| description | Rust 2024 edition core patterns: idiomatic code, error handling, traits/generics, macros, async/concurrency, testing, and project architecture |
| version | 2.0.0 |
| category | toolchains-rust |
| tags | ["rust","patterns","performance","minimalism","efficiency","safety","async","testing","architecture"] |
| effort | medium |
Rust 2024 Core Patterns
Quick Start
[dependencies]
tokio = { version = "1", features = ["full"] }
thiserror = "2"
anyhow = "2"
serde = { version = "1", features = ["derive"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
[dev-dependencies]
rstest = "0.22"
proptest = "1"
mockall = "0.13"
insta = "1"
criterion = { version = "0.5", features = ["html_reports"] }
cargo add tokio --features full && cargo add thiserror anyhow serde --features serde/derive
cargo clippy -- -D warnings
cargo test
cargo test --doc
Idiomatic Patterns
- Builder pattern: Use consuming
self methods; finish with build() -> Result<T, E> for validated construction.
- Newtype pattern:
struct UserId(u64) gives type safety at zero cost; add impl From<u64> for UserId.
- From/Into: Implement
From<T> only — Into<T> is blanket-provided; accept impl Into<T> in function params.
- Derive by default:
#[derive(Debug, Clone, PartialEq, Eq, Hash)] on domain types; add serde only when needed.
- Type state: Encode state machines as generic marker types — invalid transitions become compile errors, not panics.
- Borrowed params: Accept
&str not &String, &[T] not &Vec<T>, impl AsRef<Path> not &PathBuf.
- Default for config:
Config { field: value, ..Default::default() } — audit all fields when adding new ones.
- RAII guards: Use
Drop for resource cleanup; wrap locks, file handles, and connections in guard types.
Cow<'_, str>: Return Cow<'_, str> when sometimes borrowing, sometimes owning — avoids unnecessary clones.
- Zero-cost abstractions: Iterator chains compile to identical (often better) machine code than manual loops.
- Stack vs heap: Default to stack; use
Box only for trait objects, recursive types, or large data escaping scope.
Error Handling
thiserror (libraries)
use thiserror::Error;
#[derive(Error, Debug)]
pub enum UserError {
#[error("user {id} not found")]
NotFound { id: u64 },
#[error("invalid email: {0}")]
InvalidEmail(String),
#[error("database error")]
Database(#[from] sqlx::Error),
#[error("serialization failed")]
Serialization(#[from] serde_json::Error),
}
Rules:
- Libraries expose typed error enums — callers can pattern-match variants.
- Never use
String or Box<dyn Error> in public library API.
#[from] generates From<SourceError> — use ? to propagate automatically.
#[source] exposes the underlying error via Error::source() without auto-From.
anyhow (applications / CLIs)
use anyhow::{Context, Result};
fn load_config(path: &Path) -> Result<Config> {
let text = std::fs::read_to_string(path)
.with_context(|| format!("reading config from {}", path.display()))?;
let config: Config = toml::from_str(&text)
.context("parsing config as TOML")?;
Ok(config)
}
fn main() -> Result<()> {
let config = load_config(Path::new("config.toml"))?;
run(config)
}
Rules:
anyhow::Result<T> in main(), CLI handlers, and top-level application code.
- Chain context with
.context("what we were doing") at each call site.
- Never use
anyhow in public library APIs — wrap and convert at the boundary.
Error conversion at library/app boundary
let user = user_service.get(id)
.await
.map_err(|e| anyhow::anyhow!("get user {id}: {e}"))?;
impl From<UserError> for AppError { ... }
Traits and Generics
Small, focused traits
trait Fetch {
async fn fetch(&self, url: &str) -> Result<Bytes, FetchError>;
}
trait Store {
async fn store(&self, key: &str, value: &[u8]) -> Result<(), StoreError>;
}
trait Cache: Fetch + Store + Send + Sync {}
Trait objects vs generics
fn process<F: Fetch>(fetcher: &F) -> Result<()> { ... }
fn process_dyn(fetcher: &dyn Fetch) -> Result<()> { ... }
fn process_arc(fetcher: Arc<dyn Fetch>) -> Result<()> { ... }
Associated types vs generics
trait Parser {
type Output;
fn parse(&self, input: &str) -> Result<Self::Output, ParseError>;
}
trait Convert<T> {
fn convert(&self) -> T;
}
Generic bounds and where clauses
fn clone_and_display<T: Clone + Display>(value: &T) { ... }
fn process<T, E>(items: &[T]) -> Result<Vec<String>, E>
where
T: Display + Send + 'static,
E: std::error::Error + From<fmt::Error>,
{ ... }
Blanket implementations
trait Summarize {
fn summary(&self) -> String;
}
impl<T: Display> Summarize for T {
fn summary(&self) -> String {
format!("{}", self)
}
}
Extension traits (adding methods to foreign types)
trait IteratorExt: Iterator {
fn take_while_inclusive<P>(self, predicate: P) -> TakeWhileInclusive<Self, P>
where
P: FnMut(&Self::Item) -> bool,
Self: Sized;
}
impl<I: Iterator> IteratorExt for I {
fn take_while_inclusive<P>(self, predicate: P) -> TakeWhileInclusive<Self, P>
where P: FnMut(&Self::Item) -> bool, Self: Sized {
TakeWhileInclusive { iter: self, predicate, done: false }
}
}
Sealed traits (prevent external implementation)
mod private {
pub trait Sealed {}
}
pub trait MyTrait: private::Sealed {
fn method(&self);
}
impl private::Sealed for MyType {}
impl MyTrait for MyType { fn method(&self) {} }
Generic Associated Types (GATs)
trait Lender {
type Item<'a> where Self: 'a;
fn lend(&mut self) -> Self::Item<'_>;
}
Macros
Declarative macros (macro_rules!)
macro_rules! hashmap {
($($key:expr => $val:expr),* $(,)?) => {{
let mut map = std::collections::HashMap::new();
$(map.insert($key, $val);)*
map
}};
}
let m = hashmap!{ "a" => 1, "b" => 2 };
macro_rules! impl_from_str {
($($t:ty),+) => {
$(impl std::str::FromStr for $t {
type Err = ParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
s.parse::<u64>().map(Self).map_err(Into::into)
}
})+
};
}
Procedural macros (derive)
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, DeriveInput};
#[proc_macro_derive(Builder)]
pub fn derive_builder(input: TokenStream) -> TokenStream {
let ast = parse_macro_input!(input as DeriveInput);
let name = &ast.ident;
let expanded = quote! {
impl #name {
pub fn builder() -> #nameBuilder { Default::default() }
}
};
expanded.into()
}
Rules:
- Use declarative macros for simple repetition; proc macros for derive and attribute macros.
- Document macro hygiene:
$crate:: prefix for items referenced inside the macro.
- Prefer
derive_more and strum crates over writing your own derive macros.
- Test macros via
trybuild to verify compile-time error messages.
Async / Concurrency Patterns
Tokio fundamentals
#[tokio::main]
async fn main() -> anyhow::Result<()> {
run().await
}
let handler = async |req: Request| -> Result<Response> {
process(req).await
};
Structured concurrency
use tokio::{join, select};
let (users, orders) = join!(fetch_users(), fetch_orders());
select! {
result = fetch_with_primary() => result,
result = fetch_with_fallback() => result,
_ = tokio::time::sleep(Duration::from_secs(5)) => Err(TimeoutError),
}
use tokio::task::JoinSet;
let mut set = JoinSet::new();
for id in ids {
set.spawn(async move { fetch_item(id).await });
}
while let Some(result) = set.join_next().await {
handle(result??);
}
Shared state patterns
let state: Arc<Mutex<HashMap<u64, User>>> = Arc::new(Mutex::new(HashMap::new()));
let state_clone = Arc::clone(&state);
tokio::spawn(async move {
let mut guard = state_clone.lock().await;
guard.insert(1, user);
});
let cache: Arc<RwLock<HashMap<String, Value>>> = Arc::new(RwLock::new(HashMap::new()));
let val = cache.read().await.get("key").cloned();
use std::sync::atomic::{AtomicU64, Ordering};
let counter = Arc::new(AtomicU64::new(0));
counter.fetch_add(1, Ordering::SeqCst);
Actor pattern (message-passing concurrency)
use tokio::sync::mpsc;
enum Command {
Get { key: String, reply: tokio::sync::oneshot::Sender<Option<String>> },
Set { key: String, value: String },
Shutdown,
}
struct CacheActor {
store: HashMap<String, String>,
rx: mpsc::Receiver<Command>,
}
impl CacheActor {
async fn run(mut self) {
while let Some(cmd) = self.rx.recv().await {
match cmd {
Command::Get { key, reply } => {
let _ = reply.send(self.store.get(&key).cloned());
}
Command::Set { key, value } => {
self.store.insert(key, value);
}
Command::Shutdown => break,
}
}
}
}
#[derive(Clone)]
struct CacheHandle(mpsc::Sender<Command>);
impl CacheHandle {
async fn get(&self, key: String) -> Option<String> {
let (tx, rx) = tokio::sync::oneshot::channel();
self.0.send(Command::Get { key, reply: tx }).await.ok()?;
rx.await.ok()?
}
async fn set(&self, key: String, value: String) {
let _ = self.0.send(Command::Set { key, value }).await;
}
}
Cancellation safety
struct Guard {
resource: Resource,
}
impl Drop for Guard {
fn drop(&mut self) {
self.resource.cleanup();
}
}
let mut locked = mutex.lock().await;
do_io().await;
drop(locked);
{
let mut locked = mutex.lock().await;
update_state(&mut locked);
}
do_io().await;
CPU-bound work
let result = tokio::task::spawn_blocking(|| {
heavy_computation()
})
.await?;
use rayon::prelude::*;
let result: Vec<_> = data.par_iter().map(|x| transform(x)).collect();
Async write durability
let mut f = tokio::fs::OpenOptions::new()
.create(true).write(true).truncate(true)
.open(path).await?;
f.write_all(&bytes).await?;
f.flush().await?;
Rules:
- After
tokio::fs::File::write_all(), call flush().await before any reader runs or you return — otherwise silent read-after-write race / data loss.
tokio::fs::File defers writes to a blocking thread pool — flush().await ensures all queued writes have completed before a reader runs (it is NOT a userspace BufWriter). The free fn tokio::fs::write() is a single open/write/close. Sync std::fs::File has no Rust-layer userspace buffer, so writes reach the OS page cache on return — but crash/power-loss durability still requires sync_all()/sync_data(); drop alone does not fsync.
HTTP client construction
let client = reqwest::ClientBuilder::new()
.timeout(Duration::from_secs(30))
.connect_timeout(Duration::from_secs(5))
.build()?;
Rules:
- Never construct
reqwest::Client::new() in a service — a timeout-free client has no application-level timeout, so a slow upstream can block a worker for the OS TCP timeout duration (minutes).
- Always set both a request
timeout and a connect_timeout; build once and clone (the inner pool is shared).
Testing Best Practices
Test placement
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_basic_operation() {
let sut = MyStruct::new(42);
let result = sut.compute();
assert_eq!(result, 84);
}
}
use my_crate::Api;
#[tokio::test]
async fn full_roundtrip() {
let api = Api::start_test_server().await;
}
rstest — parametrized and fixture-based tests
use rstest::*;
#[fixture]
fn database() -> TestDb {
TestDb::in_memory()
}
#[rstest]
#[case("valid@email.com", true)]
#[case("not-an-email", false)]
#[case("", false)]
fn test_email_validation(#[case] input: &str, #[case] expected: bool) {
assert_eq!(is_valid_email(input), expected);
}
#[rstest]
async fn test_with_fixture(database: TestDb) {
let repo = UserRepository::new(database);
assert!(repo.find(999).await.unwrap().is_none());
}
proptest — property-based testing
use proptest::prelude::*;
proptest! {
#[test]
fn roundtrip_serialization(s in "\\PC*") {
let encoded = encode(&s);
let decoded = decode(&encoded).unwrap();
prop_assert_eq!(s, decoded);
}
#[test]
fn sort_is_idempotent(mut v in prop::collection::vec(any::<i32>(), 0..100)) {
v.sort();
let sorted_once = v.clone();
v.sort();
prop_assert_eq!(sorted_once, v);
}
}
insta — snapshot testing
use insta::assert_snapshot;
#[test]
fn test_error_message() {
let err = UserError::NotFound { id: 42 };
assert_snapshot!(err.to_string());
}
#[test]
fn test_json_output() {
let user = User { id: 1, name: "Alice".into() };
insta::assert_json_snapshot!(user, @r###"
{
"id": 1,
"name": "Alice"
}
"###);
}
mockall — mock trait implementations
use mockall::{automock, predicate::*};
#[cfg_attr(test, automock)]
trait EmailService: Send + Sync {
async fn send(&self, to: &str, body: &str) -> Result<(), EmailError>;
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_sends_welcome_email() {
let mut mock = MockEmailService::new();
mock.expect_send()
.with(eq("user@example.com"), contains("Welcome"))
.times(1)
.returning(|_, _| Ok(()));
let service = UserService::new(Arc::new(mock));
service.register("user@example.com").await.unwrap();
}
}
Async tests and multi-threaded scenarios
#[tokio::test]
async fn test_basic_async() { ... }
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn test_concurrent_writes() { ... }
Doc tests
pub fn parse(s: &str) -> Result<UserId, ParseError> { ... }
Fuzzing
#![no_main]
use libfuzzer_sys::fuzz_target;
fuzz_target!(|data: &[u8]| {
if let Ok(s) = std::str::from_utf8(data) {
let _ = my_crate::parse(s);
}
});
Architecture Best Practices
Workspace layout
my-project/
├── Cargo.toml # [workspace] members = [...]
├── crates/
│ ├── domain/ # Core types, traits, no I/O deps
│ │ └── src/lib.rs
│ ├── infrastructure/ # DB, HTTP clients, external APIs
│ │ └── src/lib.rs
│ ├── application/ # Business logic, orchestration
│ │ └── src/lib.rs
│ └── api/ # HTTP layer (axum/actix-web)
│ └── src/
│ ├── lib.rs
│ └── main.rs
└── tests/
└── integration/
Rules:
domain must not depend on infrastructure — only pure types and traits.
application depends on domain traits, not infrastructure concretions.
infrastructure implements domain traits; injected at startup in main.
- Compile-time enforcement of layering: wrong imports = compiler error.
Workspace discipline
[workspace.package]
rust-version = "1.91"
[workspace.dependencies]
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
[package]
edition = "2021"
[dependencies]
tokio = { workspace = true }
serde = { workspace = true }
Rules:
- Declare every shared external crate once in
[workspace.dependencies]; members reference dep = { workspace = true }. Never pin locally if it is already in the workspace table — local pins drift and force duplicate compiles.
- MSRV (
rust-version in [workspace.package]) is the floor imposed by the most-constrained TRANSITIVE dependency (e.g. a cloud SDK forcing a newer toolchain), not just the language features your code uses. Verify with cargo msrv or by auditing transitive rust-version.
- Per-crate edition policy:
edition = "2024" only for crates that need async closures (async || {}) or other 2024-gated features; others stay 2021. Note let-chains stabilized in Rust 1.88 and work in edition 2021 too, so they are not a reason to upgrade. Check the crate's own Cargo.toml, and verify with cargo check before upgrading.
- Cross-crate change protocol: edit lib →
cargo check workspace-wide → cargo test -p <lib> → cargo test -p <each consumer> → commit all touched Cargo.toml together.
Feature flags
[features]
default = ["tokio-runtime"]
tokio-runtime = ["dep:tokio"]
async-std-runtime = ["dep:async-std"]
serde = ["dep:serde", "dep:serde_json"]
tracing = ["dep:tracing"]
[dependencies]
tokio = { version = "1", optional = true }
serde = { version = "1", optional = true }
#[cfg(feature = "serde")]
impl Serialize for MyType { ... }
#[cfg(feature = "tracing")]
tracing::info!("processing item");
[features]
default = ["http-server"]
http-server = ["dep:axum", "dep:tower-http"]
[dependencies]
axum = { version = "0.7", optional = true }
tower-http = { version = "0.6", optional = true }
[[bin]]
name = "serverd"
required-features = ["http-server"]
Rules:
- If a crate is consumed as a library (not only built as a binary), mark
axum/tower-http optional = true, gate them behind an http-server (or axum-server) feature, set default = ["http-server"] for backward-compatible binary builds, and add required-features = ["http-server"] on [[bin]] stanzas. Prevents forcing the full HTTP stack onto library consumers.
Module layout
pub use self::user::{User, UserId, UserError};
pub use self::service::UserService;
mod user;
mod service;
Shared state patterns
#[derive(Clone)]
struct AppState {
users: Arc<dyn UserRepository>,
cache: Arc<dyn Cache>,
config: Arc<Config>,
}
Graceful shutdown
use tokio::signal;
async fn run() -> anyhow::Result<()> {
let (shutdown_tx, shutdown_rx) = tokio::sync::broadcast::channel(1);
let server = tokio::spawn(serve(shutdown_rx));
signal::ctrl_c().await?;
let _ = shutdown_tx.send(());
server.await??;
Ok(())
}
async fn serve(mut shutdown: tokio::sync::broadcast::Receiver<()>) {
loop {
tokio::select! {
_ = accept_connection() => { }
_ = shutdown.recv() => break,
}
}
}
Production graceful shutdown
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await?;
async fn shutdown_signal() {
let ctrl_c = async { signal::ctrl_c().await.expect("install SIGINT handler"); };
#[cfg(unix)]
let term = async {
signal::unix::signal(signal::unix::SignalKind::terminate())
.expect("install SIGTERM handler").recv().await;
};
#[cfg(not(unix))]
let term = std::future::pending::<()>();
tokio::select! { _ = ctrl_c => {}, _ = term => {} }
}
Rules:
- Prefer
axum::serve(listener, app).with_graceful_shutdown(shutdown_signal()) where shutdown_signal() awaits BOTH SIGTERM and SIGINT (cfg-gated Unix/non-Unix). Draining in-flight requests lets storage fsync complete before exit.
- Ops: systemd
KillSignal=SIGTERM + TimeoutStopSec=120 (must exceed the fsync window on networked filesystems, e.g. EBS/EFS); on macOS use launchctl bootout (SIGTERM + drain), NOT launchctl kickstart -k (SIGKILL truncates in-flight writes).
- Clients/bridges reconnect with exponential backoff (200ms → 30s cap) so daemon restarts are transparent.
Performance
- Avoid cloning: Restructure to use references or
Cow before reaching for .clone().
SmallVec<[T; N]>: Use for vectors usually small; improves cache locality.
- Arena allocation:
bumpalo for batch-allocating many short-lived objects.
- Capacity hints:
String::with_capacity(n), Vec::with_capacity(n) when final size is predictable.
- Criterion benchmarks:
cargo bench gives statistical benchmarking with warmup.
- Flamegraph profiling:
[profile.release] debug = true then cargo flamegraph.
- LTO for binaries:
lto = "thin" + codegen-units = 1 in release profile.
- Measure first: Profile before optimizing any non-obvious path.
Why/What/Test Doc Pattern
pub fn data_dir() -> anyhow::Result<PathBuf> { ... }
Rules:
- Non-trivial public
fn/struct/trait/mod items carry three doc-comment lines — Why: (the problem it solves), What: (mechanics), Test: (where coverage lives, or why untestable). "Non-trivial" in Rust is calibrated at roughly > 30 LOC or cyclomatic complexity > 5.
- Trivial wrappers, one-liners, and simple getters (e.g.
pub fn is_empty(&self) -> bool { self.0.is_empty() }) need at most a single summary line. Forcing a three-line Why/What/Test triple on code shorter than its own docstring adds noise, not signal.
- Clippy cannot enforce intent; this is how future readers reconstruct design without git archaeology.
Safety
- Minimize unsafe: Keep
unsafe blocks as small as possible; wrap in safe abstractions.
- Safety comments: Every
unsafe block requires // SAFETY: explaining invariants.
- Rust 2024
unsafe fn: Function bodies are no longer implicitly unsafe.
unsafe extern blocks: Rust 2024 requires unsafe extern "C" { ... }.
Send + Sync: Auto-implemented when all fields qualify; verify with static_assertions.
- Interior mutability:
Cell<T> for Copy, RefCell<T> single-threaded, Mutex<T> multi-threaded.
Anti-Patterns to Avoid
- Excessive cloning: Do not clone to satisfy the borrow checker — restructure, use references, or
Cow.
- Blocking in async: Never call
std::fs::read, std::net, or thread::sleep in async; use tokio:: equivalents.
- Unflushed async writes: A buffered
tokio::File write without flush().await is a read-after-write data-loss race.
- Timeout-free HTTP client:
reqwest::Client::new() in a service hangs forever on a slow upstream — always ClientBuilder with timeouts.
- Bare
unwrap() in production: Replace with ?, .unwrap_or_default(), .expect("invariant: reason").
- Stringly-typed errors: No
String or Box<dyn Error> in library APIs — define typed enums with thiserror.
- Half-initialized objects: Never expose constructors that leave fields unset — use builder pattern.
Deref for inheritance: Do not implement Deref to simulate OOP — use trait composition.
env::set_var without unsafe: In Rust 2024, set_var/remove_var are unsafe.
- Clippy silencing:
cargo clippy -- -D warnings in CI; use #[allow] with justification, never blanket silencing.
- Global state for DI: Do not use
lazy_static/once_cell for mutable services — constructor inject.
Rust Daemon & Supervised-Process Patterns
A daemon launched by launchd/systemd runs in a stripped environment with aggressive respawn policies. The patterns below assume that supervisor and cross-reference the production graceful-shutdown guidance under Architecture Best Practices.
Path resolution under supervision
pub fn data_dir() -> anyhow::Result<PathBuf> {
if let Ok(raw) = std::env::var("APP_DATA_DIR") {
let trimmed = raw.trim();
anyhow::ensure!(!trimmed.is_empty(), "APP_DATA_DIR is empty");
let p = PathBuf::from(trimmed);
anyhow::ensure!(p.is_absolute(), "APP_DATA_DIR must be absolute: {p:?}");
anyhow::ensure!(p != Path::new("/"), "APP_DATA_DIR must not be root");
return Ok(p);
}
let dir = dirs::data_local_dir()
.or_else(passwd_home_data_dir)
.ok_or_else(|| anyhow::anyhow!("cannot resolve data dir"))?;
tracing::info!(path = %dir.display(), "resolved data dir");
Ok(dir)
}
Rules:
- Resolve the data dir as env override →
dirs::data_local_dir() → $HOME/passwd (getpwuid) fallback. The passwd fallback is mandatory: under posix_spawn, dirs::* can return None because HOME/NSFileManager is not initialised.
- Validate any env override is absolute (
anyhow::ensure!(p.is_absolute())); reject empty/whitespace/relative/root.
- Log the resolved path at INFO on every boot — first thing you check when a supervised daemon "loses" its data.
File-descriptor limits
[Service]
LimitNOFILE=8192
<key>SoftResourceLimits</key> <dict><key>NumberOfFiles</key><integer>8192</integer></dict>
<key>HardResourceLimits</key> <dict><key>NumberOfFiles</key><integer>8192</integer></dict>
Rules:
- Set
LimitNOFILE=8192 (systemd) / SoftResourceLimits+HardResourceLimits NumberOfFiles: 8192 (launchd). The macOS launchd default is 256 — a daemon holding many DB files (e.g. N stores × 3 files) hits EMFILE.
- Combined with
KeepAlive/Restart=always, an EMFILE crash becomes a respawn storm. Expose open_fds + fd_soft_limit in the /health response for early warning.
Single-instance guard
if let Ok(resp) = http_get(&recorded_health_url).await {
if resp.is_healthy() {
tracing::info!("healthy incumbent already running; exiting");
std::process::exit(0);
}
}
bind_and_serve(addr).await
Rules:
- On startup probe the recorded health address; if a healthy incumbent answers,
std::process::exit(0) instead of binding. This stops launchd/systemd respawn storms without external intervention (a second instance exiting cleanly is success, not failure).
std::process::exit skips destructors, so flush any buffered logs/tracing (e.g. a non-blocking tracing-appender worker guard) before calling it — or place the guard before subscriber/buffer init — otherwise the "exiting" line may never be written.
MCP stdio framing
loop {
let line = match read_line(&mut stdin).await? {
Some(l) => l,
None => std::process::exit(0),
};
let resp = handle(&line).await;
write_framed(&mut stdout, &resp).await?;
}
Rules:
- In a stdio JSON-RPC server, ALL logs go to stderr — a stray
println! corrupts the protocol framing. Route tracing to stderr explicitly.
- The stdio serve loop must call
std::process::exit(0) on stdin EOF; otherwise reqwest idle-pool background tasks keep the tokio runtime alive and orphaned workers accumulate across client restarts.
Rust Embedded Storage Discipline
Patterns for embedded key/value and document stores (redb / sled / SQLite) with bincode/serde payloads. The recurring failure mode is a binary upgrade that comes up "healthy but empty" — these rules make degradation impossible without an explicit, recoverable decision.
Graceful format-upgrade handling
fn open_or_recreate(path: &Path) -> anyhow::Result<Db> {
match Db::open(path) {
Ok(db) => Ok(db),
Err(e) if is_format_error(&e) => {
run_migration_or_warn(path)?;
recreate(path)
}
Err(e) if is_lock_error(&e) => Err(e.into()),
Err(e) => Err(e.into()),
}
}
Rules:
- At EVERY open/create site classify errors:
UpgradeRequired/RepairAborted/Corrupted = incompatible on-disk format; DatabaseAlreadyOpen = lock contention; everything else = transient I/O.
- Destructive recreate ONLY on format errors — NEVER on lock or I/O errors (a lock error means another instance owns live data; an I/O error is transient).
- Ship a data-preserving migration tool for format breaks. A binary upgrade must never silently degrade to "healthy but empty".
Atomic swap for durable rebuilds
let staging = root.join("index.staging");
build_into(&staging)?;
anyhow::ensure!(record_count(&staging)? > 0, "rebuild produced 0 records — Failed, not Ready");
fs::rename(&staging, root.join("index.live"))?;
Rules:
- Any reindex/compaction/migration writes to a STAGING location and swaps atomically only on success; on failure/empty/abort, discard the staged copy and leave the previous live data intact.
- Validate non-empty before marking ready — a pipeline that walked files but produced zero records is
Failed, not Ready (the false-green antipattern).
Root-relative path storage
let root = root.canonicalize()?;
let rel = abs_path.strip_prefix(&root)?.to_path_buf();
store.put(key, &rel)?;
Rules:
- Store file paths relative to a known root, never absolute — survives data-dir moves, remounts, and cross-platform copies. Canonicalize the root so
strip_prefix reliably yields a relative path.
- Provide an idempotent, schema-versioned migration that detects and rewrites legacy absolute paths on warm boot.