rust-idioms

SkillDev tools

Rust ownership, tokio, thiserror/anyhow, Clippy pedantic, unsafe, lifetimes.

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 rust-idioms skill

What this skill tells your AI

The instructions your AI receives, as published by irahardianto/awesome-agv in .agents/skills/rust-idioms/SKILL.md and read by ahel’s review.

Rust Idioms and Patterns

Core Philosophy

Rust's type system and ownership model are your primary tools for correctness. Lean into the compiler — it is your strongest ally. Write code that is idiomatic, safe, and expressive.

Scope: This file covers Rust-specific coding idioms. For file layout, see references/project-structure.md (in this skill). For detailed safety, SAST security invariants, and performance anti-patterns, see references/rust-patterns-and-anti-patterns.md (in this skill). For Rust test naming and conventions, see §Testing below; for universal testing principles, see @.agents/rules/testing-strategy.md. For logging library choice and setup, see @.agents/skills/logging-implementation/SKILL.md.

Toolchain and Minimum Supported Rust Version

Default to the latest stable Rust. As of July 2026, this is Rust 1.97. All guidance in this skill assumes latest stable features. When creating new projects, set rust-version in Cargo.toml to prevent builds on outdated toolchains.

[package]
edition = "2024"
rust-version = "1.97"

Key version milestones that affect this skill:

  • 1.75+ — Native async fn in traits (no async_trait crate needed for static dispatch). This milestone is the single source of truth for the async-trait crate policy:
    • Prefer static dispatchimpl MyTrait return params or generic T: MyTrait bounds use native async fn in traits; no crate required and zero dispatch overhead.
    • Use the async-trait crate only when dynamic dispatch via dyn Trait is explicitly required — e.g. Box<dyn MyTrait>, Arc<dyn MyTrait> for runtime polymorphism, object-safe trait objects, or storing heterogeneous trait impls in a collection.
    • Never add async-trait as a default dependency just for ergonomics — it adds a heap allocation and dynamic dispatch cost. Add it only for the specific crates/traits that need dyn dispatch.
  • 1.74+ — Workspace lint inheritance ([workspace.lints])
  • 1.63+Mutex::new() in const context (no OnceCell wrapper needed)

For recommended crate versions and starter Cargo.toml, see references/recommended-dependencies.md.

Ownership and Borrowing

  1. Prefer borrowing (&T, &mut T) over cloning

    • Never .clone() to silence the borrow checker without a // CLONE: comment explaining why
    • Use Cow<'_, T> when a function may or may not need ownership
    • Prefer &str over String in function parameters, &[T] over Vec<T>
  2. Minimize owned data in structs

    • Use references with explicit lifetimes when the struct is short-lived
    • Use owned types (String, Vec<T>) when the struct must outlive its inputs
  3. Avoid unnecessary Arc<Mutex<T>>

    • If data flows one direction, use channels (tokio::sync::mpsc)
    • If data is read-heavy, consider RwLock over Mutex
    • If data is immutable after init, use Arc<T> without a lock
  4. Respect the Copy / Clone boundary:

    • Never call .clone() on types that implement Copy (e.g., i32, f64, bool, char, usize, Option<CopyType>)
    • Copy types are implicitly copied on assignment — .clone() is misleading and suggests heap allocation
    • When unsure, check: Copy = bitwise copy (stack only); Clone = potentially expensive deep copy
    // ❌ Misleading — usize implements Copy
    let count = other_count.clone();
    
    // ✅ Implicit copy — clear and correct
    let count = other_count;
    

Error Handling

  1. Use the ? operator for propagation — never unwrap() in production code

    • unwrap() and expect() are acceptable only in:
      • Tests (#[test], #[tokio::test])
      • Infallible operations where the invariant is proven (document with // SAFETY: comment)
      • CLI main() function with clear error messages via expect("reason")
  2. Choose error crates by context:

    ContextCrateReason
    Library cratesthiserrorTyped, matchable errors. Callers need to handle specific variants.
    Web service HTTP errorsthiserrorAppError enum must implement IntoResponse — typed variants required.
    Service/domain layer errorsthiserrorDomain errors need structured variants for logging and client responses.
    Application glue / scripts / CLIanyhowError type doesn't matter; ergonomic propagation is all you need.

    Web service rule: Use thiserror for AppError (HTTP handler errors) and domain errors. anyhow::Error does NOT implement IntoResponse and cannot be returned from Axum handlers. Use anyhow only in non-HTTP utility code (scripts, migration runners, CLI entrypoints) where errors are printed, not sent over the wire.

    The idiomatic pattern is thiserror for typed variants + #[from] anyhow::Error as the catch-all Internal variant in AppError. See axum-idioms/SKILL.md §Error Handling for the complete pattern.

    Never add anyhow as a dependency to library crates — it leaks a concrete error type into your public API.

  3. Error type design:

// ✅ Good — typed, matchable errors
#[derive(Debug, thiserror::Error)]
pub enum PathfinderError {
    #[error("file not found: {path}")]
    FileNotFound { path: PathBuf },
    #[error("AST parse failed: {0}")]
    ParseError(String),
    #[error(transparent)]
    Io(#[from] std::io::Error),
}

// ❌ Bad — stringly-typed, unmatchable
fn do_thing() -> Result<(), String> { ... }

// ✅ Use #[must_use] on functions returning non-Result types that callers must handle
#[must_use]
pub fn compute_checksum(data: &[u8]) -> u64 { ... }

// ℹ️ Result<T, E> already has #[must_use] in std — adding it to Result-returning
// functions is redundant. The compiler warns on unused Result values automatically.
pub fn create_task(req: CreateTaskRequest) -> Result<Task, TaskError> { ... }
  1. Use lazy evaluation for fallback values:

    • unwrap_or_else(|| expr) instead of unwrap_or(expr) when the fallback involves a function call
    • expect messages should be string literals, not format!() calls
    • map_or_else instead of map_or when either branch involves computation
    // ❌ Eager — default_value() is called even when result is Ok
    let val = result.unwrap_or(default_value());
    let msg = result.expect(&format!("failed for {id}"));
    
    // ✅ Lazy — default_value() only called when needed
    let val = result.unwrap_or_else(|_| default_value());
    let msg = result.unwrap_or_else(|e| panic!("failed for {id}: {e}"));
    

Async and Concurrency

  1. Use tokio as the async runtime

    • Mark async entry points with #[tokio::main] or #[tokio::test]
    • Prefer tokio::spawn for concurrent tasks, not std::thread::spawn
    • Use tokio::select! for racing futures, not manual polling
  2. Cancellation safety:

    • Prefer tokio::sync::mpsc over tokio::sync::broadcast unless fan-out is needed
    • Document cancellation behavior on any async fn that holds resources across .await
    • Use tokio_util::sync::CancellationToken for graceful shutdown
  3. Blocking operations:

    • Never call blocking I/O inside async context
    • Use tokio::task::spawn_blocking for CPU-heavy or blocking work
    • Use tokio::fs instead of std::fs inside async functions
  4. Use tracing instead of log for all structured diagnostics in async applications:

    • tracing is span-aware — log entries inherit context from parent spans (correlation IDs, request metadata)
    • log is fire-and-forget with no span concept — unsuitable for async where context flows across .await boundaries
    • Use #[tracing::instrument] on async functions to automatically create spans with function arguments
    • See @.agents/skills/logging-implementation/SKILL.md §Rust for the full setup

Unsafe Code

  1. Zero unsafe blocks unless in FFI boundaries

    • Tree-sitter C bindings and similar FFI are the only valid use case
    • Every unsafe block must have a // SAFETY: comment explaining the invariant
  2. Minimize unsafe surface area:

    • Encapsulate unsafe in a safe wrapper function
    • The wrapper's public API must be safe to call from any context
    • Write tests that exercise the boundary conditions of unsafe wrappers
  3. Never use unsafe to bypass the borrow checker — restructure the code instead

Lifetimes and Generics

  1. Prefer '_ lifetime elision when possible

    • Only introduce named lifetimes when the compiler requires them or when they clarify intent
    • Use 'a for single lifetime parameters, descriptive names ('input, 'query) for multiple
  2. Keep generic bounds simple:

    • Prefer concrete types for prototyping, introduce generics when the pattern stabilizes
    • Use impl Trait in argument position for simple cases
    • Use where clauses for complex bounds — never inline complex bounds in <...>
  3. Avoid lifetime gymnastics:

    • If lifetime annotations become complex, restructure to use owned data or Arc
    • Consider the "split borrow" pattern to avoid borrow checker issues in struct methods

Idiomatic Patterns

  1. Builder pattern for types with many optional fields:

    • Return Self from builder methods for chaining
    • build() returns Result<T, BuildError>, not T
  2. Newtype pattern for domain types:

    • Wrap primitives: struct UserId(u64), not bare u64
    • Implement Deref only when the newtype truly "is-a" the inner type
  3. Typestate pattern for state machines:

    • Different states = different types — invalid transitions are compile errors
    • Use this for protocol implementations and lifecycle management
  4. From/Into conversions:

    • Implement From<A> for B (never Into directly)
    • Use impl From<X> for Error with thiserror's #[from] attribute
  5. Prefer T::new() over Default::default() for known types:

    • Use Vec::new(), String::new(), HashMap::new() — explicit, readable, idiomatic
    • Use Default::default() in generic contexts where T: Default bounds are needed
    • Use Default::default() in struct update syntax: MyStruct { field: value, ..Default::default() }
    // ✅ Idiomatic — explicit constructor for known types
    let items: Vec<String> = Vec::new();
    let name = String::new();
    let map: HashMap<String, i32> = HashMap::new();
    
    // ✅ Also good — capacity hint is valuable
    let items = Vec::with_capacity(100);
    
    // ✅ Default::default() in generic code — correct usage
    fn create_collection<T: Default>() -> T {
        T::default()
    }
    
    // ✅ Default::default() in struct update syntax
    let config = ServerConfig {
        port: 8080,
        ..Default::default()
    };
    
  6. Use stdlib convenience methods — avoid manual reimplementations:

    • str.split_once(pat) instead of manual splitn(2, pat) + indexing
    • a.min(b) / a.max(b) / a.clamp(lo, hi) instead of match a.cmp(&b) { ... }
    • Return expressions directly — don't bind to a variable and immediately return it
    • Use Ordering::then() / Ordering::then_with() for multi-field comparisons
    // ❌ Manual reimplementation
    let parts: Vec<&str> = s.splitn(2, ':').collect();
    let key = parts[0];
    let value = parts.get(1).unwrap_or(&"");
    
    // ✅ Idiomatic — clearer intent, less code
    let (key, value) = s.split_once(':').unwrap_or((s, ""));
    
    // ❌ Redundant match over Ordering
    match a.cmp(&b) {
        Ordering::Less | Ordering::Equal => a,
        Ordering::Greater => b,
    }
    
    // ✅ Direct
    a.min(b)
    
    // ❌ Redundant let-binding
    let result = compute_something();
    result
    
    // ✅ Return directly
    compute_something()
    
  7. Keep function complexity low (cyclomatic complexity < 10):

    • Functions exceeding this threshold must be decomposed
    • Common decomposition patterns for complex Rust functions:
      • Extract match arms into named helper functions
      • Use early returns (if !condition { return Err(...) }) to flatten nesting
      • Extract iterator chains with complex closures into named functions
      • Use the "parse, don't validate" pattern — convert unstructured data into typed structs early
    // ❌ High complexity — nested match + conditionals
    fn process(input: &Input) -> Result<Output> {
        match input.kind {
            Kind::A => {
                if input.flag {
                    // 20 lines...
                } else {
                    // 20 lines...
                }
            }
            Kind::B => { /* another 30 lines */ }
        }
    }
    
    // ✅ Decomposed — each function has single responsibility
    fn process(input: &Input) -> Result<Output> {
        match input.kind {
            Kind::A => process_kind_a(input),
            Kind::B => process_kind_b(input),
        }
    }
    

Testing

  1. Test organization (Rust-specific — differs from Go/TS):

    For the authoritative test layout rules (unit vs integration vs e2e placement, #[cfg(test)] conventions, tests/common/mod.rs pattern, #[tokio::test] usage), see references/project-structure.md §Testing Layout. The rules are co-located there to stay in sync with the directory layout they describe.

  2. Test naming: fn test_<function>_<scenario>_<expected>() (snake_case)

  3. Assertions:

    • Use assert_eq! / assert_ne! over assert!(a == b) — better error messages
    • Use assert!(matches!(result, Ok(_))) for enum variant checking
    • Never use assert!(true) or assert!(false):
      • assert!(false) / debug_assert!(false) → use unreachable!("reason") or panic!("reason")
      • assert!(true) → remove entirely (it tests nothing)
      • These are dead-code signals that should use proper constructs
  4. Property testing: Use proptest (preferred) or quickcheck for functions with wide input spaces. proptest is preferred for its superior strategy composability, automatic shrinking, and more expressive generators.

  5. Test coverage is non-negotiable for new code:

    • Every new pub fn, pub struct method, and impl block MUST have at least one test
    • Every new branch (if/else, match arm, error path) MUST be exercised by a test
    • When modifying existing code, add tests for the modified paths if none exist
    • Never leave a function untested with the intent to "add tests later"
    • Use cargo tarpaulin or cargo llvm-cov to verify coverage locally before committing
    # Quick coverage check during development
    cargo tarpaulin --workspace --skip-clean --out stdout
    
    # Generate detailed report
    cargo llvm-cov --workspace --lcov --output-path lcov.info
    
  6. Test double selection — choose the right tool:

    ApproachWhen to UseCrate
    Hand-written fakeSimple trait, few methods, test needs custom stateful behaviorNone (implement trait directly)
    mockallComplex trait, need to verify call counts, argument matching, or call orderingmockall
    Parameterized testsSame logic, multiple input/output pairs (like Go table-driven tests)rstest
    Snapshot testingLarge outputs (JSON responses, CLI output, error messages)insta
    // ✅ Hand-written fake — simple, debuggable, no macro magic
    struct FakeTaskStorage {
        tasks: HashMap<String, Task>,
    }
    impl TaskStorage for FakeTaskStorage {
        async fn get_by_id(&self, id: &str) -> Result<Task, StorageError> {
            self.tasks.get(id).cloned().ok_or(StorageError::NotFound)
        }
    }
    
    // ✅ mockall — when you need interaction verification
    #[cfg(test)]
    mock! {
        pub TaskStore {}
        impl TaskStorage for TaskStore {
            async fn get_by_id(&self, id: &str) -> Result<Task, StorageError>;
            async fn create(&self, task: &Task) -> Result<(), StorageError>;
        }
    }
    
    #[tokio::test]
    async fn test_service_calls_storage_once() {
        let mut mock = MockTaskStore::new();
        mock.expect_create()
            .times(1)
            .returning(|_| Ok(()));
        let service = TaskService::new(mock);
        service.create_task(request).await.unwrap();
    }
    
    // ✅ rstest — parameterized test cases
    use rstest::rstest;
    
    #[rstest]
    #[case("valid@email.com", true)]
    #[case("no-at-sign", false)]
    #[case("", false)]
    fn test_email_validation(#[case] input: &str, #[case] expected: bool) {
        assert_eq!(is_valid_email(input), expected);
    }
    
    // ✅ insta — snapshot testing for complex outputs
    use insta::assert_json_snapshot;
    
    #[test]
    fn test_task_response_shape() {
        let response = TaskResponse::from(sample_task());
        assert_json_snapshot!(response);
    }
    

    Prefer hand-written fakes for core domain traits — they are easier to debug and don't couple tests to implementation details. Use mockall only when the trait has many methods or you genuinely need interaction verification (call counts, argument matching, call ordering). Over-mocking with mockall leads to brittle tests that break on implementation changes.

Clippy and Formatting

  1. cargo check for fast iteration during development

    • cargo check: type-checks without producing a binary — fastest feedback loop
    • cargo clippy: includes cargo check plus lint rules — use before committing
    • cargo build: only when you need the actual binary/library artifact
    • Never run cargo build during TDD cycles — it is significantly slower than cargo check
  2. cargo clippy must pass with zero warnings before any commit

  3. Clippy suppression policy — fix the code, don't silence the lint:

    NEVER suppress these lints — they signal structural problems that must be fixed:

    LintWhat It SignalsWhat To Do Instead
    too_many_linesFunction is monolithicDecompose into smaller functions (see Idiomatic Patterns §7)
    cognitive_complexityToo many branches/nestingFlatten with early returns, extract match arms
    too_many_argumentsFunction has too many paramsIntroduce a params/config struct or builder
    type_complexityNested generics are unreadableCreate a type alias or newtype wrapper
    struct_excessive_boolsStruct has too many boolean fieldsReplace with an enum, bitflags, or config sub-struct
    large_enum_variantEnum variant is disproportionately largeBox the large variant's payload

    Decomposition strategies (use INSTEAD of #[allow]):

    // ❌ FORBIDDEN — agent took the lazy path
    #[allow(clippy::too_many_lines)]
    fn process_request(req: &Request) -> Result<Response> {
        // 200 lines of code...
    }
    
    // ✅ REQUIRED — decompose the function
    fn process_request(req: &Request) -> Result<Response> {
        let validated = validate_request(req)?;
        let enriched = enrich_with_context(&validated)?;
        build_response(&enriched)
    }
    
    // ❌ FORBIDDEN — too many arguments
    #[allow(clippy::too_many_arguments)]
    fn create_server(host: &str, port: u16, tls: bool, timeout: u64,
                     max_conn: usize, log_level: &str, cert: &Path) -> Server { ... }
    
    // ✅ REQUIRED — params struct
    struct ServerConfig {
        host: String,
        port: u16,
        tls: bool,
        timeout: Duration,
        max_connections: usize,
        log_level: Level,
        cert_path: PathBuf,
    }
    fn create_server(config: ServerConfig) -> Server { ... }
    
    // ❌ FORBIDDEN — hiding type complexity
    #[allow(clippy::type_complexity)]
    fn get_handlers() -> HashMap<String, Box<dyn Fn(&Request) -> Pin<Box<dyn Future<Output = Response>>>>> { ... }
    
    // ✅ REQUIRED — type alias
    type HandlerFn = Box<dyn Fn(&Request) -> Pin<Box<dyn Future<Output = Response>>>>;
    fn get_handlers() -> HashMap<String, HandlerFn> { ... }
    

    Acceptable suppressions (with mandatory // ALLOW: comment):

    LintWhen Acceptable
    unwrap_usedIn #[cfg(test)] modules only
    expect_usedIn #[cfg(test)] modules, OR with a // SAFETY: comment proving infallibility, OR in a CLI main() that owns the process exit (clear message + exit code). This reconciles with the expect_used = "warn" lint level in recommended-dependencies.mdwarn permits these uses while still surfacing every other expect() for review.
    module_name_repetitionsWhen the repetition is intentional API design
    must_use_candidateOn internal functions where the caller pattern is known
    missing_errors_docTemporarily during development (must be resolved before merge)
    needless_pass_by_valueWhen API stability requires it (with comment explaining why)
    items_after_statementsWhen locality of helper functions improves readability
    cast_possible_truncationWith bounds check or range validation immediately preceding the cast

    Rule of thumb: If you're about to write #[allow(clippy::...)], stop and ask: "Am I suppressing a real design problem?" If yes, fix the design. If the lint is genuinely a false positive for this specific context, suppress with a // ALLOW: comment explaining the rationale.

  4. cargo fmt is non-negotiable — all code must be formatted

  5. Recommended project-level Clippy configuration:

    For the standard [lints.clippy] and [lints.rust] blocks (single-crate and workspace variants), and the version pinning policy, see references/recommended-dependencies.md §Workspace Lint Configuration and §Starter Cargo.toml Template. Do not duplicate those blocks here — treat recommended-dependencies.md as the single source of truth.

  6. Document all public items:

    • Every pub fn, pub struct, pub enum, pub trait, and pub type MUST have a /// doc comment
    • At minimum: one-line summary. For complex items: summary + parameters + errors + examples
    • Enable the missing_docs lint in library crates:
    # In Cargo.toml
    [lints.rust]
    missing_docs = "warn"
    
    // ❌ Undocumented public item
    pub fn resolve_symbols(path: &Path) -> Result<Vec<Symbol>> { ... }
    
    // ✅ Documented
    /// Resolves all exported symbols from the file at `path`.
    ///
    /// Returns parsed symbol definitions including their span information.
    ///
    /// # Errors
    /// Returns `ParseError` if the file cannot be parsed by tree-sitter.
    pub fn resolve_symbols(path: &Path) -> Result<Vec<Symbol>> { ... }
    

Dependency Management

  1. Minimize dependency count — each dependency is an attack surface and compile-time cost
  2. Pin major versions in Cargo.toml — use dep = "1" not dep = "*"
  3. Audit regularly — run cargo audit to check for known vulnerabilities
  4. Prefer well-maintained crates — check download count, last commit date, and issue tracker

Cargo Features

  1. Features must be additive — enabling a feature must only add functionality, never change or remove existing behavior

  2. Use dep: syntax for optional dependencies to keep the feature namespace clean:

    [features]
    default = ["json"]
    json = ["dep:serde_json"]    # ✅ Uses dep: prefix
    grpc = ["dep:tonic"]         # ✅ Feature doesn't auto-expose dep as feature
    
  3. Guard feature-gated code with #[cfg(feature = "...")]:

    #[cfg(feature = "grpc")]
    pub mod grpc_handler;
    
  4. Test feature combinations in CI using cargo-hack:

    cargo hack test --feature-powerset --depth 2
    
  5. Never use features for mutually exclusive backends — use traits and runtime selection instead

Configuration and Environment

Shortened here. Read the whole file on GitHub.

Signals

GitHub stars
156
Forks
53
Last commit
Aug 2026
Advanced
Catalog kind
skill
Gateway key
rust-idioms
Source
github.com/irahardianto/awesome-agv