GitHub Copilot for Rust

GitHub Copilot Programming LanguagesAcademy lesson 34Cluster 3 · Lesson 9 of 13Advanced13 min readVersion-sensitive
Published
Updated
Last technically verified
GitHub Copilot for RustGitHub Copilot Programming Languages9Advanced/github-copilot/languages/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.

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 Send and Sync traits are checked, so sharing something across threads that is not safe to share does not compile.
  • No null dereference; there is no null.
  • Every Result and Option is either handled or explicitly ignored with a visible marker.
  • Every match on 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.

Copilot promptState the ownership up frontCopilot Chat

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

copilot-rust-demo/ ├── Cargo.toml └── src/ ├── main.rs └── summary.rs

The prompt

Copilot promptGenerate the core moduleCopilot Chat, with Cargo.toml open

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 than len(). str::len returns 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 uses len() because it is shorter and because most examples are ASCII.
  • The generic IntoIterator bound. It lets the caller pass a Vec, a Lines iterator or an array without allocating. A suggestion left to itself takes &[String] and the caller ends up collecting — which is where an unnecessary clone() 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 --check

Traits, 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::sleep or a synchronous database driver inside an async fn stalls the executor thread. tokio::fs, tokio::time::sleep and spawn_blocking are the answers.
  • Send bounds across .await. Holding a non-Send value — a RefCell borrow, an Rc — 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::spawn returns 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 audit for 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

Accepting a Rust suggestion
  1. Run cargo clippy -- -D warningsClippy is close to a second reviewer here, not a style checker.
  2. Grep for unwrap, expect, clone and unsafeHuman judgementFour greps. Each hit is a place the compiler was talked out of something.
  3. Ask whether each clone was neededHuman judgementA clone inserted to satisfy the borrow checker usually means the ownership design is wrong.
  4. Check the error typeHuman judgementA typed enum the caller can match on, or a stringly-typed Box the caller cannot?
  5. For async, confirm nothing blocks the executorHuman judgement
  6. 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, no clone, no unsafe, 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 Trait and 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 &str by 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.

Primary sources