Rust’s borrow checker, lifetime annotations, and trait system create an uniquely high AI assistance ceiling — and floor. An AI that understands ownership deeply can cut hours from your development cycle. One that doesn’t will generate code that looks plausible but won’t compile.
This comparison tests Claude Code and GitHub Copilot on four Rust tasks that require real language understanding.
Task 1: Fixing Borrow Checker Errors
Given this code that fails to compile:
fn longest<'a>(x: &str, y: &str) -> &str {
if x.len() > y.len() {
x
} else {
y
}
}
fn main() {
let result;
{
let string2 = String::from("long string");
result = longest("hello", string2.as_str());
}
println!("Longest: {}", result); // result may outlive string2
}
Copilot’s suggestion:
Add lifetime parameter
'ato the return type:-> &'a str
Partially correct — but Copilot didn’t explain that the lifetime of the result is tied to the shorter-lived input, or that the real fix is restructuring the code to not use result outside string2’s scope. It generated the annotation without the understanding.
Claude’s response:
// Claude explained the issue first:
// The problem: `result` is declared outside string2's scope, but longest()
// returns a reference that borrows from string2. When string2 is dropped,
// result becomes a dangling reference.
//
// Fix 1: Move result inside string2's scope
fn main() {
let string2 = String::from("long string");
let result = longest("hello", string2.as_str());
println!("Longest: {}", result);
}
// Fix 2: If you need result in the outer scope, own the data
fn longest_owned(x: &str, y: &str) -> String {
if x.len() > y.len() {
x.to_owned()
} else {
y.to_owned()
}
}
fn main() {
let result;
{
let string2 = String::from("long string");
result = longest_owned("hello", string2.as_str());
// string2 is dropped here, but result owns its data
}
println!("Longest: {}", result); // Works
}
// The function signature with explicit lifetimes (needed if you keep the reference version):
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
// This tells the compiler: result lives at most as long as the shorter input.
Claude provided two fixes and explained why the lifetime annotation alone doesn’t solve the scope problem.
Task 2: Async Error Handling with ?
A common Rust async pattern that trips up AI tools:
// Prompt: "Write an async function that fetches a URL,
// parses the JSON body, and returns a typed struct.
// Handle network errors and parse errors separately."
Copilot’s output:
async fn fetch_user(url: &str) -> Result<User, Box<dyn Error>> {
let resp = reqwest::get(url).await?;
let user: User = resp.json().await?;
Ok(user)
}
Correct but minimal. Uses Box<dyn Error> which loses error type information — fine for scripts, problematic for library code.
Claude’s output:
use thiserror::Error;
use serde::Deserialize;
#[derive(Debug, Error)]
pub enum FetchError {
#[error("Network request failed: {0}")]
Network(#[from] reqwest::Error),
#[error("Failed to parse response body: {0}")]
Parse(String),
#[error("Unexpected status {status}: {body}")]
HttpError { status: u16, body: String },
}
#[derive(Deserialize, Debug)]
pub struct User {
pub id: u64,
pub name: String,
pub email: String,
}
pub async fn fetch_user(url: &str) -> Result<User, FetchError> {
let resp = reqwest::get(url)
.await
.map_err(FetchError::Network)?;
if !resp.status().is_success() {
let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();
return Err(FetchError::HttpError { status, body });
}
let user = resp
.json::<User>()
.await
.map_err(|e| FetchError::Parse(e.to_string()))?;
Ok(user)
}
// Caller can now match on specific error types
async fn main() {
match fetch_user("https://api.example.com/users/1").await {
Ok(user) => println!("Got user: {:?}", user),
Err(FetchError::Network(e)) => eprintln!("Network error: {}", e),
Err(FetchError::HttpError { status, body }) => {
eprintln!("HTTP {}: {}", status, body)
}
Err(FetchError::Parse(msg)) => eprintln!("Parse error: {}", msg),
}
}
Claude chose thiserror over Box<dyn Error>, separated HTTP errors from parse errors, and showed the caller code to demonstrate why typed errors matter.
Task 3: Trait Implementation with Generics
// Prompt: "Implement a generic LRU cache in Rust with
// a get() and put() method. Capacity specified at creation."
Both tools produced working implementations. The quality difference was in the API design.
Copilot’s implementation:
use std::collections::HashMap;
struct LruCache {
capacity: usize,
map: HashMap<i32, i32>,
order: Vec<i32>,
}
// Non-generic, uses i32 only
Claude’s implementation:
use std::collections::HashMap;
use std::hash::Hash;
pub struct LruCache<K, V> {
capacity: usize,
map: HashMap<K, V>,
// Uses a Vec for simplicity; production would use a doubly-linked list
order: Vec<K>,
}
impl<K: Eq + Hash + Clone, V: Clone> LruCache<K, V> {
pub fn new(capacity: usize) -> Self {
assert!(capacity > 0, "LruCache capacity must be > 0");
Self {
capacity,
map: HashMap::with_capacity(capacity),
order: Vec::with_capacity(capacity),
}
}
pub fn get(&mut self, key: &K) -> Option<&V> {
if self.map.contains_key(key) {
// Move to front (most recently used)
self.order.retain(|k| k != key);
self.order.push(key.clone());
self.map.get(key)
} else {
None
}
}
pub fn put(&mut self, key: K, value: V) {
if self.map.contains_key(&key) {
self.order.retain(|k| k != &key);
} else if self.map.len() == self.capacity {
// Evict least recently used (front of vec)
if let Some(lru_key) = self.order.first().cloned() {
self.order.remove(0);
self.map.remove(&lru_key);
}
}
self.order.push(key.clone());
self.map.insert(key, value);
}
pub fn len(&self) -> usize {
self.map.len()
}
pub fn is_empty(&self) -> bool {
self.map.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_basic_lru_eviction() {
let mut cache = LruCache::new(2);
cache.put("a", 1);
cache.put("b", 2);
cache.get(&"a"); // a is now most recently used
cache.put("c", 3); // should evict b, not a
assert!(cache.get(&"b").is_none());
assert_eq!(cache.get(&"a"), Some(&1));
}
}
Claude made it generic with proper trait bounds, added a capacity assertion, included len() and is_empty(), and added a test demonstrating the eviction order. Copilot’s was narrowly typed and untested.
Task 4: Channel-Based Concurrency
// Prompt: "Write a worker pool that processes jobs from a
// channel using N threads, with graceful shutdown."
Claude’s output:
use std::sync::mpsc;
use std::thread;
type Job = Box<dyn FnOnce() + Send + 'static>;
pub struct WorkerPool {
workers: Vec<Worker>,
sender: Option<mpsc::Sender<Job>>,
}
struct Worker {
id: usize,
handle: Option<thread::JoinHandle<()>>,
}
impl WorkerPool {
pub fn new(size: usize) -> Self {
assert!(size > 0, "Pool size must be > 0");
let (sender, receiver) = mpsc::channel::<Job>();
let receiver = std::sync::Arc::new(std::sync::Mutex::new(receiver));
let workers = (0..size)
.map(|id| {
let receiver = std::sync::Arc::clone(&receiver);
let handle = thread::spawn(move || loop {
let job = receiver.lock().unwrap().recv();
match job {
Ok(job) => job(),
Err(_) => {
// Channel closed — sender dropped, time to exit
break;
}
}
});
Worker { id, handle: Some(handle) }
})
.collect();
WorkerPool { workers, sender: Some(sender) }
}
pub fn execute<F: FnOnce() + Send + 'static>(&self, f: F) {
self.sender
.as_ref()
.expect("Pool is shutting down")
.send(Box::new(f))
.expect("All workers have exited");
}
}
impl Drop for WorkerPool {
fn drop(&mut self) {
// Drop the sender first to close the channel
drop(self.sender.take());
// Then wait for all workers to finish their current jobs
for worker in &mut self.workers {
if let Some(handle) = worker.handle.take() {
handle.join().expect(&format!("Worker {} panicked", worker.id));
}
}
}
}
Copilot produced a similar pattern but didn’t implement Drop — meaning the pool would exit before workers finished processing. A subtle but critical bug for real workloads.