This file provides essential patterns for working with this Rust codebase.
DO NOT use mod.rs files! Use the modern Rust 2018+ module style:
src/
├── lib.rs # mod llm;
├── llm.rs # pub mod types; pub mod router; + main code
└── llm/
├── types.rs
└── router.rs
The foo.rs file serves as the module root for foo/ directory. No mod.rs needed!
use anyhow::{Result, Context, bail, ensure};
// Standard return type
pub async fn my_function() -> anyhow::Result<Output> {
// Add context to errors
let data = fetch_data().await.context("failed to fetch")?;
// Early exit with ensure
ensure!(!data.is_empty(), "data cannot be empty");
// Bail for errors
if invalid {
bail!("invalid state");
}
Ok(output)
}- Test behavior, not coverage - focus on user-facing functionality
- Never change test expectations to make tests pass - fix the code
- Skip trivial tests - Rust's type system catches many bugs at compile time
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn feature_works() -> anyhow::Result<()> {
// Setup
let store = InMemoryStore::new();
// Execute
let result = my_function(&store).await?;
// Assert
assert_eq!(result.status, ExpectedStatus);
Ok(())
}
}Before every commit:
cargo check --all-targets # Type check
cargo fmt # Format
cargo clippy -- -D warnings # Lint (must pass)
cargo test # Tests (must pass)Functions should be self-documenting - avoid redundant comments:
// BAD: Comment restates function name
/// Find a user by their ID.
pub async fn find_by_id(...)
// GOOD: No comment needed, name is clear
pub async fn find_by_id(...)
// GOOD: Comment explains non-obvious behavior
/// Returns None if the thread was archived more than 24 hours ago.
pub async fn find_active_thread(...)This is a Cargo workspace. New crates go under crates/.
Cargo.toml # Virtual workspace manifest (no [package])
crates/
├── agent-sdk/ # Main SDK crate (published)
│ ├── Cargo.toml # Inherits workspace deps, lints, metadata
│ ├── src/
│ │ ├── lib.rs
│ │ ├── agent_loop.rs
│ │ ├── llm.rs
│ │ ├── llm/
│ │ │ ├── types.rs
│ │ │ └── router.rs
│ │ ├── providers.rs
│ │ └── ...
│ └── examples/
└── (future crates added here during Phase 0 extraction)
- Shared dependency versions are declared in
[workspace.dependencies]and referenced from member crates via{ workspace = true }. - Lint policy lives in
[workspace.lints]and members opt in with[lints] workspace = true. - Common package metadata (
edition,license,repository) is shared through[workspace.package]and inherited withfield.workspace = true.
All work targets the main branch.
- Base branch for every PR:
main - New crates go under
crates/ - The workspace root
Cargo.tomlis a virtual manifest — no[package]section
Never bypass clippy with #[allow(...)] - fix the code instead:
// BAD: Bypassing clippy
#[allow(clippy::too_many_arguments)]
pub fn create(a: A, b: B, c: C, d: D, e: E, f: F) { }
// GOOD: Use a struct
pub struct CreateParams { pub a: A, pub b: B, /* ... */ }
pub fn create(params: CreateParams) { }// Async function
pub async fn fetch_data(id: &str) -> Result<Data> {
let response = client.get(url).await?;
Ok(response.json().await?)
}
// Async trait (with async-trait crate)
#[async_trait]
pub trait DataFetcher {
async fn fetch(&self, id: &str) -> Result<Data>;
}- Use
anyhow::Resultfor all fallible functions - Use
timecrate (notchrono) for datetime handling - Use
tracingfor logging - Prefer inline full paths for clarity:
llm::Message::user("hi") - No unsafe code -
#[forbid(unsafe_code)]is enforced - Never use
unwrap()orexpect()- Always propagate errors with?and add context:
// BAD: Panics without context
let value = some_result.unwrap();
let value = some_result.expect("something failed");
let lock = mutex.lock().unwrap();
// GOOD: Propagate errors with context
let value = some_result.context("failed to get value")?;
// For RwLock/Mutex: convert poison error to anyhow error
let lock = self.data.read().ok().context("lock poisoned")?;
let lock = self.data.write().ok().context("lock poisoned")?;
// For Option types
let value = some_option.context("value was None")?;This rule applies to both production code AND tests. In tests, return Result<()> and use ?.
Use sqlx::query! and sqlx::query_as! macros for all Postgres queries in
agent-service-host. These macros provide compile-time SQL validation and type
checking. Never use the untyped sqlx::query(...) string form.
// BAD: untyped query — no compile-time checking
let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM agent_sdk_tasks WHERE thread_id = $1")
.bind(thread_key(thread_id))
.fetch_one(&self.pool)
.await?;
// GOOD: typed macro — validated at compile time
let record = sqlx::query!(
r"SELECT COUNT(*) AS cnt FROM agent_sdk_tasks WHERE thread_id = $1",
thread_key(thread_id),
)
.fetch_one(&self.pool)
.await?;
let count = record.cnt.unwrap_or(0);A Postgres 18 instance is available via compose.yml:
# Start Postgres
scripts/postgres18-dev.sh up
# Wait until healthy
scripts/postgres18-dev.sh wait
# Connection URL
scripts/postgres18-dev.sh url
# → postgres://agent_sdk:agent_sdk@127.0.0.1:55432/agent_sdkNormal builds use SQLX_OFFLINE=true (set in .cargo/config.toml). The
.sqlx/ directory holds cached query metadata so builds work without a live
database.
After adding or changing any sqlx::query! / sqlx::query_as! call, you must
refresh the offline cache:
# Apply migrations + regenerate .sqlx/ cache
scripts/postgres18-dev.sh prepare
# Or manually:
scripts/postgres18-dev.sh wait
SQLX_OFFLINE=false DATABASE_URL="postgres://agent_sdk:agent_sdk@127.0.0.1:55432/agent_sdk" \
cargo sqlx prepare --workspace -- -p agent-service-host --all-targets# Full cycle: migrate + run store tests against real Postgres
scripts/postgres18-dev.sh test-migrations