Rust Ownership skill for Claude Code

Satisfy the borrow checker without clone spam. Claude reads rustc's three spans, finds where a borrow can end sooner or a value can have a single owner, and fixes errors structurally instead of cloning or wrapping everything in Rc<RefCell>. It applies the thiserror-for-libraries, anyhow-for-binaries split, sets up edition 2024 workspaces with shared lints, and keeps clippy at zero warnings.

  • namerust-ownership
  • size201 lines · ≈2,150 tokens
  • files2
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Resolves Rust borrow checker and lifetime errors by restructuring ownership instead of cloning blindly, and writes idiomatic Rust with thiserror/anyhow error handling, smart pointer choices (Box, Rc, Arc, RefCell, Mutex), cargo workspaces, and clippy-clean code. Use when rustc reports E0382, E0499, E0502, E0505, E0597, or lifetime errors, when designing structs that hold references, choosing error types, or setting up cargo, clippy, and rustfmt.

Try asking

  • rustc says cannot borrow self.items as mutable because it is also borrowed — fix it properly
  • Design error types for this crate: library plus CLI in one workspace
  • This struct holds a &str and lifetimes are spreading everywhere — should it own the data?

Use it when

  • rustc reports a borrow, move, or lifetime error you cannot explain
  • Choosing between Box, Rc, Arc, RefCell, Mutex, or Cow
  • Defining error types or replacing unwrap calls
  • Setting up a cargo workspace with clippy and rustfmt in CI

Skip it when

How to check the result

cargo check, cargo clippy --all-targets -- -D warnings and cargo test pass, and every clone added in the diff has a stated reason.

The common mistake it prevents

Reaching for clone or Rc<RefCell<T>> at the first borrow error, which compiles but moves the conflict to runtime panics and needless allocations.

~/.claude/skills/rust-ownership/SKILL.md

--- name: rust-ownership description: Resolves Rust borrow checker and lifetime errors by restructuring ownership instead of cloning blindly, and writes idiomatic Rust with… license: MIT ---

Done well means: the code compiles without .clone() used as a sedative, each value has one obvious owner, lifetimes appear only where a struct or function truly borrows, errors carry context, and cargo clippy --all-targets -- -D warnings is clean.

Ground rules

  • Read the full rustc message, including the “borrow later used here” span. The fix is usually moving that later use, not adding a clone.
  • Prefer restructuring (shorter borrows, splitting structs, passing indices or IDs) over Rc<RefCell<_>>. Interior mutability is a design choice, not an error fix.
  • Owned types in structs by default (String, Vec<T>). Reach for &'a str fields only for short-lived views where profiling shows allocation matters.
  • Function parameters: borrow (&str, &[T], &T) unless the function stores or consumes the value; then take ownership (String, Vec<T>, T).
  • No unwrap()/expect() in library code paths that can fail on input. expect with a message stating the invariant is acceptable for true invariants.

Workflow for a borrow checker error

- [ ] 1. Identify the error code (rustc --explain E0502 for the canonical example)
- [ ] 2. Mark the three spans: where the borrow starts, the conflicting action, the later use
- [ ] 3. Ask: can the later use move earlier, or the borrow end sooner?
- [ ] 4. Ask: does this need two owners, or one owner and a reference passed down?
- [ ] 5. Apply the smallest structural fix from the table below
- [ ] 6. Re-run cargo check; if a new error appears in a different place, repeat
- [ ] 7. Run clippy and tests

For the error-code catalog with before/after fixes, read references/borrow-errors.md when the error is not one of the common patterns below.

Fix patterns, cheapest first

SymptomFix
Mutate while iterating a collectionCollect indices/keys first, then mutate; or use retain, iter_mut, drain(..)
Borrow one field mutably, another immutably via &mut self methodBorrow fields directly (let a = &mut self.a; let b = &self.b;) or split into a helper taking the fields
Value moved into closure, used afterClone before the closure only the cheap part, or restructure so the closure runs last; move + Arc for threads
Value used after move into functionPass &value; if callee needs ownership, clone at that call or return the value back
Returning reference to a localReturn the owned value (String, Vec)
HashMap get-then-insertmap.entry(k).or_insert_with(..)
Option field needs taking out of &mut selfself.field.take() or std::mem::replace / mem::take
Self-referential structStore indices or offsets instead of references; or separate the owner from the view
Graph / cyclic dataArena + usize IDs (or slotmap); Rc + Weak only if arenas do not fit
// E0502: cannot borrow `v` as mutable because it is also borrowed as immutable
let first = &v[0];
v.push(4);            // conflict
println!("{first}");

// Fix: end the shared borrow before mutating (copy the value out if it is Copy)
let first = v[0];
v.push(4);
println!("{first}");

Choosing ownership containers

NeedUse
Heap allocate, single owner, recursive typeBox<T>
Shared read-only, single threadRc<T>
Shared read-only, across threadsArc<T>
Shared + mutable, single threadRc<RefCell<T>> (runtime borrow panics possible)
Shared + mutable, threads, short critical sectionsArc<Mutex<T>> (parking_lot or std)
Many readers, rare writersArc<RwLock<T>>
Simple counter/flag across threadsAtomicU64, AtomicBool
Maybe-owned string (avoid alloc on fast path)Cow<'a, str>
Cheap clones of large immutable strings/slicesArc<str>, Arc<[T]>

Never hold a std::sync::Mutex guard across .await; use tokio::sync::Mutex only when the lock must span an await, otherwise scope the guard in a block.

Lifetimes

  • Elision covers most functions: one input reference, or &self, gives the output the same lifetime. Write lifetimes only when the compiler asks.
  • Name lifetimes by meaning when there are several: 'src, 'buf.
  • T: 'static means “owns all its data or holds only ‘static references”, not “lives forever”. Thread spawns and tokio::spawn require it; pass owned data or Arc.
  • Struct holding a reference: struct Parser<'a> { input: &'a str }. Every method returning slices of input should return &'a str, not &str tied to &self.
  • impl Trait in return position captures all in-scope lifetimes in edition 2024; use + use<'a> precise capturing when you need to restrict it.

Error handling

Crate kindUse
Library (others match on errors)thiserror enum per module or crate
Binary / applicationanyhow::Result with .context() at each layer
Both in one workspacethiserror in lib crates, anyhow in the bin
// library
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
    #[error("config file not found at {path}")]
    NotFound { path: std::path::PathBuf },
    #[error("invalid TOML in config")]
    Parse(#[from] toml::de::Error),
    #[error(transparent)]
    Io(#[from] std::io::Error),
}

// application
use anyhow::{Context, Result};
fn load(path: &std::path::Path) -> Result<Config> {
    let text = std::fs::read_to_string(path)
        .with_context(|| format!("reading config {}", path.display()))?;
    let cfg = toml::from_str(&text).context("parsing config")?;
    Ok(cfg)
}
  • ? converts via From; add #[from] only when the conversion is unambiguous.
  • Do not stringify errors (map_err(|e| e.to_string())); it destroys the source chain.
  • main can return anyhow::Result<()>; print with {:#} or {:?} to see the chain.

Cargo and tooling

cargo new --lib mylib          # or --bin
cargo add serde --features derive
cargo add --dev proptest
cargo check                    # fastest feedback loop
cargo clippy --all-targets --all-features -- -D warnings
cargo fmt --all -- --check
cargo test --all-features
cargo tree -d                  # duplicate dependency versions
cargo update -p some-crate     # bump one lockfile entry

Workspace root Cargo.toml:

[workspace]
resolver = "3"                 # edition 2024 default
members = ["crates/*"]

[workspace.package]
edition = "2024"
rust-version = "1.85"

[workspace.dependencies]
serde = { version = "1", features = ["derive"] }
thiserror = "2"
anyhow = "1"

[workspace.lints.clippy]
pedantic = { level = "warn", priority = -1 }
unwrap_used = "warn"
module_name_repetitions = "allow"

Member crates use serde.workspace = true and [lints] workspace = true.

Idioms clippy and reviewers expect

  • Iterators over index loops: items.iter().filter(..).map(..).collect::<Vec<_>>().
  • if let Some(x) = opt / let Some(x) = opt else { return ... }; (let-else).
  • impl From<A> for B rather than ad-hoc to_b(); implement Display for user-facing types, derive Debug everywhere.
  • Accept impl AsRef<Path> or &Path for paths; impl Into<String> for constructors that store strings.
  • #[must_use] on builders and functions whose result must not be ignored.
  • Newtypes (struct UserId(u64);) to stop mixing IDs.

Gotchas

  • .clone() on an Arc is cheap; on a Vec<String> it is not. Clippy’s redundant_clone (nursery) and reading the type tell you which.
  • RefCell borrow errors are runtime panics (already mutably borrowed); they appear in production, not in cargo check.
  • async fn in traits is stable, but the returned futures are not Send by default for generic callers; use the trait_variant crate or explicit impl Future + Send.
  • Deadlocks: acquiring two Mutexes in different orders in different code paths. Define a lock order or merge the state.
  • unsafe to bypass the borrow checker is almost never correct; if you think you need it, document the invariant and run cargo miri test.
  • Edition 2024 changes temporaries in if let and tail expressions; when migrating, run cargo fix --edition and review drop-order sensitive code (locks, guards).

Verification

  1. cargo check and cargo build --all-targets succeed.
  2. cargo clippy --all-targets --all-features -- -D warnings clean.
  3. cargo test passes; for concurrency changes also run tests under --release.
  4. Count clones you added: git diff | grep -c '\.clone()'. Justify each in the summary.
  5. For unsafe or tricky lifetimes: cargo +nightly miri test on the affected module.

Bundled files

Loaded only when a step in the skill needs them. Review them here before installing.

references/borrow-errors.md 123 lines · 4.0 KB
# Borrow checker error catalog

Run `rustc --explain <CODE>` for the official explanation. Below: what the error really
means and the structural fix to try first.

## E0382 — use of moved value

The value was moved (into a function, a `let`, a closure, or a `for` loop) and used
again.

```rust
let names = vec!["a".to_string()];
for n in names { println!("{n}"); }   // moves names
println!("{}", names.len());          // E0382
```

Fixes, in order: iterate by reference (`for n in &names`); pass `&names`; if the callee
truly needs ownership and you need it after, clone at the call site and say why.

## E0499 — two mutable borrows at once

```rust
let a = &mut self.items[0];
let b = &mut self.items[1];           // E0499
```

Fixes: `let [a, b, ..] = &mut self.items[..] else { return };` (slice patterns), or
`split_at_mut`, or `get_disjoint_mut` on slices/HashMap (stable since 1.86). For struct
fields, borrow distinct fields directly; the checker tracks fields, not methods.

## E0502 — mutable borrow while shared borrow alive

The shared borrow is still used later. Move the later use before the mutation, copy the
needed value out, or shrink the scope with a block.

## E0505 — move out while borrowed

```rust
let r = &s;
drop(s);       // E0505
println!("{r}");
```

Fix: finish using `r` first; or make `r` own a copy.

## E0506 — assign to borrowed value

Same shape as E0505 but with assignment. End the borrow first.

## E0507 — cannot move out of borrowed content

Moving a field out of `&self` or out of a `Vec` by index.

Fixes: `.clone()` if cheap; `std::mem::take(&mut self.field)` (leaves `Default`);
`Option::take()`; `vec.swap_remove(i)` / `vec.remove(i)`; iterate with `into_iter()` if
you own the collection.

## E0515 — return reference to local data

Return the owned value instead. If you are returning a slice of a parameter, tie the
lifetime to that parameter.

## E0597 — borrowed value does not live long enough

A reference outlives its owner, often stored into a struct or collection that lives
longer than a loop iteration.

```rust
let mut refs: Vec<&str> = vec![];
for line in reader.lines() {
    let line = line?;
    refs.push(&line);                 // E0597: line dropped at end of iteration
}
```

Fix: store owned values (`Vec<String>`), or keep the owners in an outer collection that
outlives the references.

## E0716 — temporary value dropped while borrowed

```rust
let s: &str = &String::from("x").to_uppercase(); // ok (temporary lifetime extension)
let parts = text.to_lowercase().split(',');       // E0716 later when used
```

Fix: bind the temporary: `let lower = text.to_lowercase(); let parts = lower.split(',');`

## E0373 — closure may outlive the current function (thread/task spawn)

Add `move` and make captured data owned or `Arc`. For `std::thread::scope`, borrows of
locals are allowed without `'static`:

```rust
std::thread::scope(|s| {
    for chunk in data.chunks(1024) {
        s.spawn(move || process(chunk));
    }
});
```

## E0106 — missing lifetime specifier

Struct field or return type references need a named lifetime. Before adding `<'a>`,
ask whether the struct should own the data instead.

## E0596 — cannot borrow as mutable

The binding is not `mut`, or you have `&T` where `&mut T` is needed. Add `mut` to the
binding or change the parameter type; if behind `Rc`, that is the signal for `RefCell`
or a redesign.

## "cannot return value referencing function parameter" with iterators

Returning `impl Iterator<Item = &T>` built from a local `Vec`. Return the `Vec`, or take
the collection as a parameter and return `impl Iterator<Item = &'a T> + 'a`.

## Async-specific

- "future cannot be sent between threads safely": a non-Send type (`Rc`, `RefCell`
  guard, `std::sync::MutexGuard`) is alive across an `.await`. Drop it in a block before
  awaiting or switch to `Arc` / `tokio::sync::Mutex`.
- "borrowed value does not live long enough" with `tokio::spawn`: spawned tasks need
  `'static`; move owned data or `Arc` clones in.

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc