Antec Rust Coding Standard

SkillDev tools

antec - personal agentic system

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Antec Rust Coding Standard skill

What this skill tells your AI

The instructions your AI receives, as published by rkinas/antec in .agents/skills/rust/SKILL.md and read by ahel’s review.

Version: 1.0.0 Rust Edition: 2021 Runtime: Tokio 1.x Applies to: All 15 crates in the Antec workspace Sources: Rust API Guidelines, Rust Reference, Rust Performance Book, Tokio best practices, production codebases (tokio, serde, axum, polars)


How to Use This Standard

This document defines 179 rules across 14 categories, ordered by priority. Rules use prefixes (own-, err-, mem-, etc.) for easy reference in code reviews and CI.

Priority levels:

  • CRITICAL — violations are build-blockers
  • HIGH — violations must be fixed before merge
  • MEDIUM — should be followed, exceptions documented
  • LOW — best practice, not enforced
  • REFERENCE — patterns to avoid (anti-pattern catalog)

When unsure about a Rust API, use Context7 MCP to verify:

  • rust-lang/reference — language semantics, lifetime rules, trait system
  • tokio-rs/tokio — async runtime, channels, I/O
  • tokio-rs/axum — HTTP framework, extractors, middleware
  • rusqlite/rusqlite — SQLite bindings
  • bytecodealliance/wasmtime — WASM runtime

Category 1: Ownership & Borrowing [CRITICAL]

Prefix: own-

own-borrow-over-clone

Prefer borrowing over cloning. Clone only when ownership transfer is genuinely required.

// GOOD: borrow
fn process_message(content: &str) -> Result<Response> { ... }

// BAD: unnecessary clone
fn process_message(content: String) -> Result<Response> { ... } // caller forced to clone

own-str-param

Accept &str in function parameters, not String. Return String when the function produces a new string.

// GOOD
fn normalize_channel(name: &str) -> String { ... }

// BAD
fn normalize_channel(name: String) -> String { ... }

own-arc-async

Use Arc<T> for shared ownership across async tasks. Never use Rc<T> in async code — it is not Send.

// GOOD: shared state across spawned tasks
let state = Arc::new(AppState::new());
let state_clone = Arc::clone(&state);
tokio::spawn(async move { state_clone.handle().await });

// BAD: Rc in async context
let state = Rc::new(AppState::new()); // compile error: Rc is not Send

own-cow-parsing

Use Cow<'_, str> in parsing hot paths to avoid allocation when input can be returned as-is.

use std::borrow::Cow;

fn sanitize(input: &str) -> Cow<'_, str> {
    if input.contains('<') {
        Cow::Owned(input.replace('<', "&lt;"))
    } else {
        Cow::Borrowed(input)
    }
}

own-copy-small

Derive Copy for small types (≤ 16 bytes) that have no heap allocation. This includes IDs, flags, enum variants without data.

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum RiskLevel {
    Safe,
    Moderate,
    Dangerous,
}

own-interior-mut-async

Use tokio::sync::Mutex (not std::sync::Mutex) when the lock must be held across .await points.

// GOOD: tokio mutex for async
let sessions: Arc<tokio::sync::Mutex<HashMap<SessionId, Session>>> = ...;
let mut guard = sessions.lock().await;
guard.insert(id, session);
// guard can be held across .await

// BAD: std mutex held across await
let guard = std_mutex.lock().unwrap(); // DEADLOCK RISK
some_async_fn().await; // other tasks can't acquire lock

own-rwlock-read-heavy

Use tokio::sync::RwLock when reads vastly outnumber writes (e.g., tool registry, config, locale data).

let registry: Arc<RwLock<ToolRegistry>> = ...;
// Many concurrent readers
let tools = registry.read().await;
// Exclusive writer
let mut tools = registry.write().await;

own-drop-explicit

Implement Drop only for types managing external resources (file handles, DB connections, temp dirs). Never for logic.

own-lifetime-elision

Rely on lifetime elision where the compiler can infer. Only write explicit lifetimes when required by the borrow checker.

// GOOD: elided (compiler infers)
fn first_word(s: &str) -> &str { ... }

// Only explicit when needed
fn longest<'a>(a: &'a str, b: &'a str) -> &'a str { ... }

own-move-closures

Use move closures for spawned tasks. Be explicit about what is captured.

let config = Arc::clone(&config);
tokio::spawn(async move {
    // config is moved into the task
    config.reload().await;
});

own-pin-futures

Use Pin<Box<dyn Future>> only when dynamic dispatch on futures is required (trait objects returning futures). Prefer static dispatch otherwise.

own-phantom-data

Use PhantomData<T> when a type parameter is needed for type safety but isn't stored. Common in typestate patterns.


Category 2: Error Handling [CRITICAL]

Prefix: err-

err-thiserror-lib

Library crates use thiserror to define typed error enums. Each antec-* crate has its own Error type.

// crates/antec-storage/src/error.rs
use thiserror::Error;

#[derive(Debug, Error)]
pub enum StorageError {
    #[error("migration failed at version {version}: {source}")]
    Migration {
        version: u32,
        #[source]
        source: rusqlite::Error,
    },
    #[error("connection pool exhausted")]
    PoolExhausted,
    #[error("query failed: {0}")]
    Query(#[from] rusqlite::Error),
}

err-anyhow-app

Application entry point (src/main.rs) uses anyhow for ergonomic error propagation with context.

use anyhow::{Context, Result};

#[tokio::main]
async fn main() -> Result<()> {
    let config = load_config()
        .context("failed to load configuration")?;
    start_server(config)
        .await
        .context("server failed")?;
    Ok(())
}

err-no-unwrap

Never use .unwrap() or .expect() in production code. Tests may use .unwrap().

// GOOD
let value = map.get(&key).ok_or(Error::NotFound(key))?;

// BAD (production)
let value = map.get(&key).unwrap(); // panics on missing key

// OK (test only)
#[cfg(test)]
fn test_something() {
    let result = do_thing().unwrap();
}

err-from-impl

Implement From<SourceError> for crate errors via #[from] in thiserror. This enables ? to auto-convert.

err-result-alias

Define a crate-level Result<T> type alias to reduce boilerplate.

// crates/antec-core/src/lib.rs
pub type Result<T> = std::result::Result<T, crate::Error>;

err-display-user

Error Display messages should be human-readable. Debug representation is for logs.

err-context-chain

Add context at each abstraction boundary to build a meaningful error chain.

let db = open_database(&path)
    .map_err(|e| StorageError::Init { path: path.clone(), source: e })?;

err-error-enum-flat

Keep error enums flat (one level). Don't nest error enums inside error enums.

err-infallible-new

Constructors (new()) should return the type directly if they can't fail. Use try_new() or build() if they can.

err-panic-never

panic! in production code is a bug. Use it only in unreachable!() for provably impossible states.

err-io-context

Always wrap std::io::Error with context (file path, operation name).

err-custom-status

Map domain errors to HTTP status codes in the gateway crate. Don't leak internal error types to API responses.


Category 3: Memory Optimization [CRITICAL]

Prefix: mem-

mem-vec-capacity

Preallocate Vec::with_capacity(n) when the size is known or estimable.

// GOOD
let mut results = Vec::with_capacity(messages.len());
for msg in messages {
    results.push(process(msg)?);
}

// BAD: repeated reallocation
let mut results = Vec::new();

mem-string-capacity

Use String::with_capacity() + push_str() for string building in loops.

// GOOD
let mut sql = String::with_capacity(256);
sql.push_str("SELECT * FROM memories WHERE ");
sql.push_str(&condition);

// BAD: repeated allocation
let sql = format!("SELECT * FROM memories WHERE {}", condition);
// (format! is fine for one-shot, bad in hot loops)

mem-smallvec

Use SmallVec<[T; N]> for collections that are usually small (< 8 elements) but occasionally larger.

use smallvec::SmallVec;

// Tool call results — usually 1-3, occasionally more
let mut results: SmallVec<[ToolResult; 4]> = SmallVec::new();

mem-box-large

Box large enum variants to keep the enum's stack size small.

// GOOD: large variant boxed
enum Message {
    Text(String),
    Image(Box<ImageData>), // ImageData is large
    Ping,
}

// BAD: all variants as large as the biggest
enum Message {
    Text(String),
    Image(ImageData), // bloats all variants
    Ping,
}

mem-iter-chain

Prefer iterator chains over collecting intermediate Vecs.

// GOOD: lazy, zero intermediate allocation
let active_tools: Vec<_> = registry
    .tools()
    .filter(|t| t.is_enabled())
    .map(|t| t.name())
    .collect();

// BAD: allocates intermediate vec
let all: Vec<_> = registry.tools().collect();
let active: Vec<_> = all.iter().filter(|t| t.is_enabled()).collect();

mem-bytes-slice

Use &[u8] over Vec<u8> in function parameters for read-only byte data.

mem-arena-batch

Consider arena allocation (bumpalo) for batch processing where many small objects have the same lifetime.

mem-zero-copy

Use bytes::Bytes for network buffers to enable zero-copy slicing.

mem-drop-early

Drop large allocations explicitly with drop(value) when they're no longer needed in a long-running scope.

mem-no-format-hot

Avoid format!() in hot paths. Pre-compute strings or use write!() to a buffer.

mem-stack-arrays

Use arrays [T; N] instead of Vec<T> when size is compile-time constant and small.

mem-profile

Profile memory with dhat or heaptrack before optimizing. Don't guess.


Category 4: API Design [HIGH]

Prefix: api-

api-newtype-ids

Wrap primitive IDs in newtypes for type safety.

#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct SessionId(pub Uuid);

#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct ToolName(pub String);

// Prevents: fn get_session(id: Uuid) — which Uuid? Session? User? Message?

api-builder

Use builder pattern for structs with 3+ optional fields.

let config = ServerConfig::builder()
    .bind("127.0.0.1")
    .port(8088)
    .cors_origins(vec!["http://localhost:3000"])
    .build()?;

api-into-params

Accept impl Into<String> for string parameters. Return concrete types.

// GOOD: flexible input
pub fn new(name: impl Into<String>) -> Self {
    Self { name: name.into() }
}

// Caller can pass &str or String without explicit conversion
let tool = Tool::new("calculator");
let tool = Tool::new(dynamic_string);

api-trait-plugins

Define traits for all plugin boundaries. This is the core extensibility pattern of Antec.

#[async_trait]
pub trait LlmProvider: Send + Sync {
    fn name(&self) -> &str;
    async fn complete(&self, request: CompletionRequest) -> Result<CompletionResponse>;
    async fn stream(&self, request: CompletionRequest) -> Result<CompletionStream>;
    fn supports_tools(&self) -> bool;
}

api-seal-internal

Seal traits that are not meant to be implemented outside the crate.

mod sealed {
    pub trait Sealed {}
}

pub trait InternalTrait: sealed::Sealed {
    fn internal_method(&self);
}

// Only types in this crate can implement InternalTrait

api-typestate

Use typestate pattern for complex initialization sequences.

pub struct Connection<S> { inner: RawConn, _state: PhantomData<S> }
pub struct Disconnected;
pub struct Connected;
pub struct Authenticated;

impl Connection<Disconnected> {
    pub fn connect(self) -> Result<Connection<Connected>> { ... }
}
impl Connection<Connected> {
    pub fn authenticate(self, token: &str) -> Result<Connection<Authenticated>> { ... }
}
impl Connection<Authenticated> {
    pub fn query(&self, sql: &str) -> Result<Rows> { ... }
}

api-non-exhaustive

Use #[non_exhaustive] on public enums and struct constructors to allow future additions.

#[derive(Debug)]
#[non_exhaustive]
pub enum ChannelEvent {
    MessageReceived(NormalizedMessage),
    ConnectionLost,
    RateLimited,
}

api-extension-trait

Use extension traits to add methods to foreign types.

pub trait ResultExt<T> {
    fn log_err(self) -> Option<T>;
}

impl<T, E: std::fmt::Display> ResultExt<T> for Result<T, E> {
    fn log_err(self) -> Option<T> {
        match self {
            Ok(v) => Some(v),
            Err(e) => { tracing::warn!("{e}"); None }
        }
    }
}

api-validate-boundary

Validate all data at system boundaries (API input, config parsing, external responses). Trust internal data.

api-send-sync

Ensure all public types used in async contexts are Send + Sync. Use #[async_trait] for async trait methods.

api-deref-newtype

Implement Deref for simple newtypes that should transparently expose the inner type.

api-default-derive

Derive Default for types with sensible defaults. Implement Default explicitly when logic is needed.

api-display-debug

Implement Display for user-facing output. Derive Debug for all public types.

api-impl-from

Implement From conversions between related types. Use TryFrom when conversion can fail.

api-const-fn

Use const fn where possible for compile-time evaluation (constructors, small computations).


Category 5: Async / Await [HIGH]

Prefix: async-

async-tokio-only

Tokio is the only async runtime. Never mix runtimes. Never call block_on inside async context.

async-no-std-mutex-await

Never hold std::sync::Mutex across .await points. Use tokio::sync::Mutex.

// GOOD
let guard = tokio_mutex.lock().await;
do_async_work().await;
drop(guard);

// BAD: deadlock risk
let guard = std_mutex.lock().unwrap();
do_async_work().await; // other tasks can't acquire

async-spawn-blocking

Offload CPU-heavy or blocking I/O to spawn_blocking.

let hash = tokio::task::spawn_blocking(move || {
    argon2::hash_encoded(password.as_bytes(), &salt, &config)
}).await??;

async-channels

Choose the right channel for the pattern:

PatternChannelExample
Fan-in (many producers, one consumer)mpscTool results → agent loop
Fan-out (one producer, many consumers)broadcastConfig changes → all components
Latest value (single writer, many readers)watchServer state, health status
One-shot (single response)oneshotTool approval request → response

async-joinset

Use JoinSet for parallel task execution with bounded concurrency.

let mut set = JoinSet::new();
let semaphore = Arc::new(Semaphore::new(4)); // max 4 concurrent

for tool_call in tool_calls {
    let permit = semaphore.clone().acquire_owned().await?;
    set.spawn(async move {
        let result = execute_tool(tool_call).await;
        drop(permit);
        result
    });
}

while let Some(result) = set.join_next().await {
    results.push(result??);
}

async-select-biased

Use tokio::select! with biased; when priority ordering matters (e.g., shutdown signal takes priority).

tokio::select! {
    biased;
    _ = shutdown.recv() => break,
    msg = rx.recv() => handle(msg).await,
}

async-cancel-safe

Document cancellation safety of async functions. Use tokio::pin! when needed.

async-timeout

Wrap external calls with tokio::time::timeout. Never wait forever on LLM providers, MCP servers, or network requests.

let response = tokio::time::timeout(
    Duration::from_secs(30),
    provider.complete(request),
).await.map_err(|_| Error::Timeout)??;

async-graceful-shutdown

Use CancellationToken or broadcast channel for coordinated shutdown across all background tasks.

async-stream-backpressure

Apply backpressure to streams. Use bounded channels. Never buffer unbounded data from external sources.

async-no-sleep-loop

Don't poll with sleep in a loop. Use tokio::sync::Notify, channels, or tokio::time::interval.

async-task-local

Use tokio::task_local! for request-scoped context (session ID, trace ID) instead of passing through every function.

async-instrument

Use #[tracing::instrument] on async functions for structured logging with span context.

async-pin-box-trait

Async trait methods return Pin<Box<dyn Future>> via #[async_trait]. When performance is critical, use manual impl.

async-no-recursive

Avoid recursive async functions — they require boxing. Restructure as loops or use explicit stack.


Category 6: Compiler Optimization [HIGH]

Prefix: opt-

opt-release-profile

Use optimized release profile in Cargo.toml:

[profile.release]
opt-level = 3
lto = "fat"
codegen-units = 1
panic = "abort"
strip = "symbols"

[profile.dev]
opt-level = 0
debug = true

[profile.bench]
inherits = "release"
debug = true
strip = "none"

opt-inline-small

Use #[inline] on small, frequently-called functions (< 5 lines). Use #[inline(always)] sparingly — only for proven hot paths.

opt-link-time-opt

Enable LTO (lto = "fat") in release builds for cross-crate inlining. Use lto = "thin" for faster compile times during CI.

opt-single-codegen

Use codegen-units = 1 in release for better optimization. Use default (16) in dev for faster compilation.

opt-target-cpu

Build with RUSTFLAGS="-C target-cpu=native" for deployment on known hardware.

opt-pgo

Consider Profile-Guided Optimization for the final release binary after benchmarking.

opt-branch-likely

Use std::intrinsics::likely/unlikely (nightly) or restructure code to put hot paths first in match arms.

opt-no-panic-release

Use panic = "abort" in release to eliminate unwinding code. This reduces binary size.

opt-strip-binary

Strip symbols in release: strip = "symbols". Debug info is in separate files if needed.

opt-compile-time

Use phf for compile-time hash maps (injection patterns, locale data, MIME types).

opt-simd-consider

Consider SIMD for bulk text processing (injection detection, memory search). Use packed_simd2 or auto-vectorization hints.

opt-bench-before

Always benchmark before optimizing. Use criterion for micro-benchmarks. Profile with perf / instruments.


Category 7: Naming Conventions [MEDIUM]

Prefix: name-

name-types

Types and traits: PascalCase. AgentLoop, ToolRegistry, LlmProvider.

name-functions

Functions and methods: snake_case. process_message, find_references, build_prompt.

name-constants

Constants and statics: SCREAMING_SNAKE_CASE. MAX_CONTEXT_TOKENS, DEFAULT_PORT.

name-constructors

  • new() — primary constructor
  • with_config(), with_capacity() — constructor with specific parameter
  • from_*() — conversion constructor (may allocate)

name-conversions

  • as_*() — cheap reference conversion, no allocation (as_str(), as_bytes())
  • to_*() — expensive conversion, may allocate (to_string(), to_vec())
  • into_*() — ownership-consuming conversion (into_inner(), into_bytes())

name-booleans

Boolean methods: is_*(), has_*(), can_*(). Boolean fields: same prefixes.

fn is_authenticated(&self) -> bool { ... }
fn has_permission(&self, perm: &str) -> bool { ... }
fn can_execute(&self) -> bool { ... }

name-modules

Module names: snake_case, singular. mod agent; not mod agents;. Exception: mod tests.

name-crate-prefix

Don't repeat the crate name in type names. The crate is already a namespace.

// GOOD: in antec-memory crate
pub struct Manager { ... }      // used as memory::Manager

// BAD:
pub struct MemoryManager { ... } // redundant: memory::MemoryManager

name-acronyms

Treat acronyms as words in PascalCase: HttpServer, SqlitePool, WasmSandbox (not HTTPServer).

name-iterators

Iterator methods: iter() for &T, iter_mut() for &mut T, into_iter() for T.

name-error-suffix

Error types end with Error: StorageError, GatewayError, SecurityError.

name-feature-flags

Feature flags: kebab-case. discord, whatsapp, imessage, semantic-search.


Category 8: Type Safety [MEDIUM]

Prefix: type-

type-newtype-wrap

Wrap primitives in newtypes at API boundaries. See api-newtype-ids.

type-enum-state

Use enums for state machines instead of boolean flags or string states.

// GOOD
enum SessionState {
    Created,
    Active { started_at: Instant },
    Compacting,
    Archived { archived_at: DateTime<Utc> },
}

// BAD
struct Session {
    is_active: bool,
    is_archived: bool,    // what if both true?
    state: String,        // typos, no exhaustiveness check
}

type-phantom

Use PhantomData for zero-cost type-level markers. See api-typestate.

type-never

Use ! (never type) or std::convert::Infallible for operations that can't fail.

type-option-default

Use Option<T> with #[serde(default)] for optional config fields. Never use sentinel values.

type-strong-index

Use typed indices (SessionIdx(usize)) instead of raw usize to prevent index mixups.

type-exhaustive-match

Match on enums exhaustively. Don't use _ => catch-all on enums you control — compiler should enforce new variant handling.

type-validate-parse

Parse, don't validate. Convert raw data into typed representations at the boundary.

// GOOD: parse into validated type
let port = Port::try_from(raw_port)?; // validates 1-65535

// BAD: validate then pass raw
assert!(raw_port > 0 && raw_port < 65536);
let port = raw_port; // still just a u16, can be misused

Category 9: Testing [MEDIUM]

Prefix: test-

test-per-function

Every public function gets at least one happy-path and one error-path test.

test-descriptive-name

Name tests descriptively: test_memory_search_returns_results_ranked_by_bm25, not test_search.

test-assert-message

Use descriptive assertion messages.

assert_eq!(
    result.len(), 3,
    "expected 3 memories for query '{}', got {}",
    query, result.len()
);

test-mock-traits

Mock external dependencies via trait objects and test doubles. Never call real APIs.

struct MockLlmProvider {
    response: String,
}

#[async_trait]
impl LlmProvider for MockLlmProvider {
    async fn complete(&self, _req: CompletionRequest) -> Result<CompletionResponse> {
        Ok(CompletionResponse { content: self.response.clone(), ..Default::default() })
    }
}

test-tokio-test

Async tests use #[tokio::test]. Use #[tokio::test(start_paused = true)] for time-dependent tests.

test-tempfile

Use tempfile crate for filesystem tests. Never write to real paths.

test-no-flaky

No flaky tests. If a test fails intermittently, fix the root cause. Never #[ignore] and forget.

test-offline

All tests pass offline. No real network calls. Mock all external services.

test-isolated

Each test is self-contained. No shared mutable state. Tests can run in any order.

test-should-panic

Use #[should_panic(expected = "...")] for tests that verify panic behavior. Include the expected message.

test-proptest

Use proptest for property-based testing of parsers, serializers, and algorithms.

test-snapshot

Use insta for snapshot testing of complex output (SQL queries, prompt assembly, API responses).


Category 10: Documentation [MEDIUM]

Prefix: doc-

doc-public-api

All public items have doc comments (///). Include: what it does, parameters, return value, errors, examples.

doc-module-level

Each module file starts with //! module-level documentation explaining the module's purpose.

doc-examples

Include runnable examples in doc comments for complex APIs.

Shortened here. Read the whole file on GitHub.

Signals

GitHub stars
52
Forks
5
Last commit
Mar 2026
Advanced
Catalog kind
skill
Gateway key
rust-rkinas
Source
github.com/rkinas/antec