GitHub Copilot for Rust
Rust is the best case in this entire cluster for AI-assisted coding, and the reason is not that suggestions are better here. It is that the compiler checks the thing that matters.
GitHub Copilot for C++ spends most of its length teaching you to ask three questions by hand: who owns this, how long does it live, does anything outlive it. Rust asks the compiler those questions and refuses to build until they are answered. Whole categories of defect that require careful human reading in C++ are compile errors here.
Which relocates the review work rather than eliminating it. The failure mode in Rust is not wrong code — wrong code does not compile. It is code that satisfies the compiler dishonestly, and there are exactly four ways to do that.
Rust quick referenceVerified August 21, 2026
- Type system
- Statically typed with ownership, borrowing and lifetimes checked at compile time; the checker is what makes generated Rust verifiable.
- Package management
- Cargo (crates.io)
- Manifest Copilot should see
- Cargo.toml
- Testing
- the built-in test harness, proptest
- Formatting
- rustfmt
- Linting
- Clippy
- Static analysis
- the borrow checker, Clippy, cargo-audit
- Common frameworks
- Tokio, Axum, Serde, Clap
- Typical Copilot work
- Enum and trait definitions, match arms, iterator chains, Serde derives, unit tests
- First thing to review
- Fighting the borrow checker with clone() and unwrap() rather than fixing the design
Fastest honest checkcargo clippy -- -D warnings && cargo test
Key takeaways
- The borrow checker is the strongest automated reviewer any language in this
cluster provides.
cargo checkis genuinely a review step, not a formality. - The four escape hatches are
clone(),unwrap()/expect(),unsafe, and interior mutability reached for as a workaround. Grep for all four. - A suggestion that fights the borrow checker and wins by cloning has usually told you the ownership design is wrong, not that a copy was needed.
- Clippy is unusually good — closer to a second reviewer than a style checker.
Run it with
-D warnings. - The fastest honest check is
cargo clippy -- -D warnings && cargo test.
The compiler is the reviewer
Consider what a clean cargo check has already established about a generated
function, before you have read a line:
- No use-after-free, no double free, no dangling reference.
- No data race between threads — the
SendandSynctraits are checked, so sharing something across threads that is not safe to share does not compile. - No null dereference; there is no null.
- Every
ResultandOptionis either handled or explicitly ignored with a visible marker. - Every
matchon an enum is exhaustive.
That is most of the manual review checklist from C++, plus the null handling from Java and C#, performed mechanically in seconds.
The practical consequence is that iteration is cheap and honest in Rust. Ask for a change, compile, read the errors, feed them back. The loop converges on something correct, and the compiler is not fooled by fluent output. This is the single most effective agent-mode workflow in the cluster.
The four escape hatches
Everything that goes wrong with generated Rust goes wrong here.
clone(). Copying data to sidestep a borrow. Sometimes correct — a cheap
clone of a small value is fine, and Arc::clone is not a data copy at all. But a
clone() inserted specifically to make an error go away is a design signal: the
function probably wanted a reference with a different lifetime, or the ownership
should have moved. A suggestion with .clone() on every line is one that lost an
argument with the borrow checker.
unwrap() and expect(). Turning a Result or Option into a value by
promising it will never fail. In tests and in main, fine. In library code and
request handlers, it is a panic waiting for the input that proves it wrong.
Generated code uses unwrap() heavily because it is the shortest thing that
compiles. The honest forms are ? for propagation, match for handling, and
unwrap_or_default / ok_or where a fallback is genuinely correct.
unsafe. The one that matters most. Inside an unsafe block, every guarantee
in the list above is suspended and you are back in
C++ territory, without C++‘s decades of tooling
aimed at that problem. A generated unsafe block is a stop-and-think, always. In
almost all application code the correct number is zero, and if a suggestion
introduces one, the right response is to ask what safe API it is working around.
Interior mutability as a workaround. RefCell moves borrow checking from
compile time to runtime — the checks still happen, they just panic instead of
failing the build. Mutex in single-threaded code adds a lock for no reason.
Both are legitimate tools with real uses; both are also what a model reaches for
when it cannot express the ownership it wants, and the difference is visible only
by reading why.
Ownership and borrowing, from a prompting point of view
The borrow checker is not a hurdle to get past; it is the specification you are writing against. Prompting well in Rust mostly means stating ownership decisions that the model would otherwise guess.
Three decisions are worth stating explicitly, every time.
Does the function take ownership, or borrow? fn process(data: Vec<String>)
consumes the caller’s vector; fn process(data: &[String]) does not. The
difference is invisible in a description like “a function that processes the
lines” and enormous in the resulting API. Say “borrow, do not take ownership”
when the caller needs the data afterwards, and the whole shape of the suggestion
changes.
Does the returned value own its data, or reference the input? A function
returning &str ties the result’s lifetime to an argument, which constrains every
caller. Returning String allocates and frees them. Neither is right in general;
the point is that a model asked for “a function that extracts the domain from a
URL” will pick one silently, and the choice propagates through everything that
calls it.
Where is the data shared, and across what? Rc for shared ownership within a
thread, Arc for shared ownership across threads, Arc<Mutex<T>> for shared
mutable state. Generated code reaches for Arc<Mutex<T>> early because it always
compiles, and it is frequently more machinery than the problem needs — a channel,
or simply passing ownership along, is often the better design.
Write a function that groups these records by their category field.
Borrow the input; do not take ownership and do not clone the records. Return a map from category to a vector of references, with a lifetime tied to the input.
If that signature is not expressible, tell me why rather than cloning to make it compile.
The last sentence is the most valuable one in this lesson. Rust is the one language here where “tell me why this cannot be done” produces a genuinely useful answer, because the constraint is formal and the compiler is the arbiter. In a dynamic language, asking the model why something is impossible gets you a plausible essay. In Rust it gets you a real explanation of an aliasing conflict — or a working signature, because the constraint turned out to be satisfiable after all.
The pattern to distrust is the reverse: a suggestion that quietly changes your signature to make its body compile. If you asked for a borrowing function and got an owning one, the model did not solve your problem — it changed it. That substitution is easy to miss when the body reads well, and it is worth checking the signature against what you asked for before reading anything else.
One more habit worth building: when the borrow checker rejects a generated
function, read the error before asking for a fix. Rust’s errors distinguish
between “this is borrowed here and used there”, “this does not live long enough”
and “cannot borrow as mutable more than once” — three genuinely different
problems with three different correct fixes. Asking for a fix without reading
which one you have will reliably produce a clone(), because cloning resolves
all three.
Practical project: a small CLI
Practical example
A log-summarising CLI with real error handling and no unwraps
Show the shape a well-prompted Rust suggestion takes, and how the prompt removes the escape hatches before they appear.
- Status
- Example implementation
- Not executed here
- This code was written against the documentation cited at the end of the lesson but was not run while writing it. Treat the commands as the ones to run, not as output that has been observed.
- Requires
- A Rust toolchain (stable), Cargo, and network access to fetch crates
Files
copilot-rust-demo/ ├── Cargo.toml └── src/ ├── main.rs └── summary.rs
The prompt
Rust, stable, 2021 edition.
Write a module with a Summary struct (line_count, error_count, longest_line) and a function summarise that takes an iterator of string slices and returns Summary.
Constraints: no unwrap, no expect, no clone, no unsafe. Return Result where the operation can fail and use the ? operator to propagate.
Define a thiserror-style error enum rather than using Box dyn Error.
Handle the empty-input case explicitly.
Then write unit tests in the same file for: empty input, no matches, all matches, and a line where ERROR appears inside a longer word.
The constraint list is doing the work here. Without it you get unwrap() on the
file read, clone() on the lines, and Box<dyn Error> as the error type — all of
which compile.
Implementation
#[derive(Debug, Default, PartialEq, Eq)]
pub struct Summary {
pub line_count: usize,
pub error_count: usize,
pub longest_line: usize,
}
pub fn summarise<'a, I>(lines: I) -> Summary
where
I: IntoIterator<Item = &'a str>,
{
lines.into_iter().fold(Summary::default(), |mut acc, line| {
acc.line_count += 1;
if line.contains("ERROR") {
acc.error_count += 1;
}
acc.longest_line = acc.longest_line.max(line.chars().count());
acc
})
}Two details worth naming:
chars().count()rather thanlen().str::lenreturns bytes, not characters. For ASCII logs they agree; for anything else they do not, and the bug is invisible until a non-ASCII line arrives. Generated Rust useslen()because it is shorter and because most examples are ASCII.- The generic
IntoIteratorbound. It lets the caller pass aVec, aLinesiterator or an array without allocating. A suggestion left to itself takes&[String]and the caller ends up collecting — which is where an unnecessaryclone()usually enters.
Error handling
#[derive(Debug, thiserror::Error)]
pub enum SummaryError {
#[error("could not read {path}: {source}")]
Read {
path: String,
#[source]
source: std::io::Error,
},
#[error("input was empty")]
Empty,
}
pub fn summarise_file(path: &std::path::Path) -> Result<Summary, SummaryError> {
let text = std::fs::read_to_string(path).map_err(|source| SummaryError::Read {
path: path.display().to_string(),
source,
})?;
if text.is_empty() {
return Err(SummaryError::Empty);
}
Ok(summarise(text.lines()))
}A typed error enum is the difference between a caller that can respond to a
missing file differently from an empty one, and a caller that gets a string. It is
also the thing generated Rust omits most often, because Box<dyn Error> compiles
and is one line.
Run
cargo check
cargo clippy -- -D warnings
cargo test
cargo fmt --checkTraits, generics and over-engineering
Copilot writes straightforward traits and generics well, and — as in TypeScript — over-reaches when the problem gets interesting.
The specific Rust failure is a suggestion that introduces generic parameters and
trait bounds where a concrete type would have been clearer. fn process<T: AsRef<str> + Clone + Debug>(input: T)
is more general than fn process(input: &str) and worse in every way that
matters if the caller only ever passes a &str: worse error messages, slower
compiles, more to read.
Two more trait-related things to check:
- A blanket implementation that conflicts with something else in the crate. This is a compile error, so the risk is the workaround the model then produces.
- Lifetime parameters that are not needed. If a suggestion adds
<'a>to a struct that owns all its data, it has misunderstood ownership. If it adds one to a struct that borrows, check whether owning would be simpler — a borrowed struct constrains every caller.
impl Trait in argument and return position removes a great deal of unnecessary
generic machinery, and is worth asking for by name.
Async Rust
Async is where generated Rust is least reliable, because the ecosystem is less uniform than the rest of the language and public code spans several eras of it.
Points to check:
- The runtime is named. Tokio and async-std are not interchangeable, and a suggestion mixing crates from both will not work. Say “Tokio” in the prompt.
- Blocking calls inside async functions.
std::fs,std::thread::sleepor a synchronous database driver inside anasync fnstalls the executor thread.tokio::fs,tokio::time::sleepandspawn_blockingare the answers. Sendbounds across.await. Holding a non-Sendvalue — aRefCellborrow, anRc— across an await point produces a compile error whose message is about trait bounds rather than about what you did. Worth recognising the shape.- Spawned tasks that are never awaited.
tokio::spawnreturns a handle. If nothing joins it, a panic inside is silently swallowed.
The general point is that async Rust is the one part of the language where the compiler’s guarantees thin out. Ownership and borrowing are still checked, but whether a future is polled, whether a task is joined, and whether a blocking call has stalled the executor are all runtime properties. Review generated async Rust with roughly the suspicion you would apply to generated Go concurrency rather than the confidence the rest of Rust earns.
Cargo and the crate graph
Cargo is the best dependency tooling in this cluster, and the review points are correspondingly few.
Cargo.toml and Cargo.lock together give an exact, verified dependency set, and
a hallucinated crate name simply fails to resolve. What remains worth checking:
- Crate identity. Names are first-come on crates.io, so read the name and check the repository link, downloads and recent releases before accepting a new dependency.
- Feature flags. A crate compiled without the feature a suggestion needs produces a “no method named” error rather than a helpful one. Suggestions frequently omit the feature list.
cargo auditfor known advisories against your lock file. Worth running in CI regardless of how the code was written.
Rust-specific risks
Excessive cloning. Covered above. clippy::redundant_clone catches some.
Inappropriate unwrap. Covered above. Panic in production.
unsafe blocks. Covered above. Always a stop.
Incorrect lifetime reasoning. Usually a compile error, so the risk is the
workaround — a 'static bound added to make it build, which forces every caller
to own their data.
Byte versus character indexing. len(), slicing a &str at a non-boundary
index (which panics), and chars() versus bytes().
Integer overflow. Panics in debug builds and wraps in release builds by
default. A suggestion that computes a size or an index from external input should
use checked_add, saturating_sub or their relatives.
Trait-bound and generic over-engineering. Covered above.
Testing, debugging and refactoring
Rust’s test harness is built in, so generated tests target one thing. Ask for
#[cfg(test)] modules in the same file for unit tests and tests/ for
integration tests — the distinction matters because only the former can reach
private functions.
For anything that parses or transforms, proptest is worth asking for: property
tests generate inputs you would not have written, and Rust’s exhaustive matching
makes the resulting failures easy to localise.
Debugging is mostly compile-time, and the compiler’s own suggestions are usually
right. For runtime panics, the message plus RUST_BACKTRACE=1 gives a precise
location, and pasting it into chat works well.
Refactoring is where Rust is strongest in this whole cluster. Change a type,
compile, and the compiler produces an exhaustive list of every place that needs
updating — including every match that is no longer exhaustive. That is a
genuinely verified refactor, and it is the reason large mechanical changes in
Rust are less frightening than the same change in
Python or
Ruby.
Review workflow
- Run cargo clippy -- -D warningsClippy is close to a second reviewer here, not a style checker.
- Grep for unwrap, expect, clone and unsafeHuman judgementFour greps. Each hit is a place the compiler was talked out of something.
- Ask whether each clone was neededHuman judgementA clone inserted to satisfy the borrow checker usually means the ownership design is wrong.
- Check the error typeHuman judgementA typed enum the caller can match on, or a stringly-typed Box the caller cannot?
- For async, confirm nothing blocks the executorHuman judgement
- Run cargo test and cargo audit
Steps marked Human judgement are the ones that do not get delegated — they are where you decide whether what Copilot produced is actually right.
Best practices
- Put the constraints in the prompt: no
unwrap, noclone, nounsafe, use?and a typed error enum. - Enforce them with Clippy rather than with memory.
- Feed compiler errors back verbatim; the diagnostics are the best context available in any language here.
- Name the async runtime.
- Prefer
impl Traitand concrete types over speculative generics.
Common mistakes
- Treating a clean build as a clean review while ignoring four
unwrap()calls. - Accepting
clone()as the fix for a borrow error without asking what the error meant. - Letting
Box<dyn Error>become the project’s error type by default. - Slicing a
&strby byte index. - Assuming release builds panic on integer overflow. They wrap.
Where to go next
GitHub Copilot for C++ is the direct contrast — the same problem domain with the ownership questions left to you. GitHub Copilot for Go is the other systems language here, and the Language Guide pillar places all three against the rest of the cluster.
Sources
Every version-sensitive claim on this page was checked against first-party documentation. Only sources actually used are listed.
Your progress
Saved in this browser only. No account, no server, and nothing leaves your device. Clearing site data resets it.
Was this lesson helpful?
Your answer is stored in this browser and is not sent anywhere.