12 ways agents write bad Rust
Agents do not write bad Rust randomly. They write bad Rust in a small, recognizable set of patterns. Once you can name the patterns, code review becomes pattern matching, not deep reading.
Each section below names the failure, shows the smell, explains the cost, and gives a prompt that surfaces the fix.
1. Clone spam
The agent hit a borrow checker error and reached for .clone() until the compiler stopped complaining. The code compiles. It also allocates and copies on every call.
fn build_query(parts: &Vec<String>, table: String) -> String {
let parts = parts.clone(); // clone 1
let mut out = table.clone(); // clone 2
for p in parts {
out = out.clone() + "." + &p.clone(); // clones 3, 4 per iter
}
out
}fn build_query(parts: &[String], table: &str) -> String {
let mut out = String::with_capacity(64);
out.push_str(table);
for p in parts {
out.push('.');
out.push_str(p);
}
out
}2. Premature Arc<Mutex<T>>
Agents reach for shared mutable state because the prompt mentioned "threads" or "async." Most async code does not need a mutex. Most state does not need to be shared. Arc<Mutex<T>> is a real lock and a real allocation. Use it only when the data is actually shared and actually mutated from multiple tasks.
pub struct Counter {
value: Arc<Mutex<u64>>,
}
impl Counter {
pub fn incr(&self) {
let mut v = self.value.lock().unwrap();
*v += 1;
}
}use std::sync::atomic::{AtomicU64, Ordering};
pub struct Counter {
value: AtomicU64,
}
impl Counter {
pub fn incr(&self) {
self.value.fetch_add(1, Ordering::Relaxed);
}
}3. unwrap() in libraries
unwrap() and expect("...") panic on the unhappy path. Inside main() or a test, that is sometimes fine. Inside a library, it crashes your caller. Every unwrap in non-test library code is a contract violation waiting to happen.
pub fn parse_config(s: &str) -> Config {
let json: Value = serde_json::from_str(s).unwrap();
let port = json["port"].as_u64().unwrap() as u16;
Config { port }
}pub fn parse_config(s: &str) -> Result<Config, ConfigError> {
let json: Value = serde_json::from_str(s)
.map_err(ConfigError::Json)?;
let port = json["port"]
.as_u64()
.ok_or(ConfigError::MissingPort)? as u16;
Ok(Config { port })
}4. Swallowed errors via Box<dyn Error> or anyhow!
Box<dyn Error> and anyhow::Error are great in application top levels. They are bad as the return type of library functions, because callers lose the ability to match on specific failures. Agents reach for them to "make it compile faster." Push back.
pub fn read_catalog(path: &str)
-> Result<Catalog, Box<dyn std::error::Error>>
{
let bytes = std::fs::read(path)?;
let cat: Catalog = serde_json::from_slice(&bytes)?;
Ok(cat)
}#[derive(Debug, thiserror::Error)]
pub enum CatalogError {
#[error("read failed at {path}: {source}")]
Io { path: String, source: std::io::Error },
#[error("parse failed: {0}")]
Parse(#[from] serde_json::Error),
}
pub fn read_catalog(path: &str) -> Result<Catalog, CatalogError> {
let bytes = std::fs::read(path).map_err(|e| CatalogError::Io {
path: path.to_owned(),
source: e,
})?;
Ok(serde_json::from_slice(&bytes)?)
}5. Unjustified unsafe
unsafe turns off some compiler checks. Sometimes it is the right call (FFI, real performance work, raw pointers with verified invariants). Most of the time the agent reached for it to silence a complaint. Every unsafe block needs a comment justifying the invariants you are upholding by hand.
6. Hand-rolled futures and async wrong-think
Agents sometimes invent their own Future impls when async fn would do, or fire-and-forget with tokio::spawn and lose error visibility, or hold a lock across .await. The async failure modes look syntactically reasonable.
async fn process(state: Arc<Mutex<State>>) -> Result<()> {
let mut s = state.lock().unwrap(); // sync mutex, blocks runtime
s.touch();
fetch_external().await?; // lock held across await!
s.commit();
Ok(())
}async fn process(state: Arc<Mutex<State>>) -> Result<()> {
{
let mut s = state.lock().unwrap();
s.touch();
} // lock released
fetch_external().await?;
{
let mut s = state.lock().unwrap();
s.commit();
}
Ok(())
}7. Manual match instead of ?
The agent wrote a five-arm match on every Result instead of using ?. The code works. It is also five times the lines and ten times the noise.
fn load(p: &Path) -> Result<Doc, MyError> {
let bytes = match std::fs::read(p) {
Ok(b) => b,
Err(e) => return Err(MyError::Io(e)),
};
let s = match std::str::from_utf8(&bytes) {
Ok(s) => s,
Err(e) => return Err(MyError::Utf8(e)),
};
Ok(Doc::new(s))
}fn load(p: &Path) -> Result<Doc, MyError> {
let bytes = std::fs::read(p)?;
let s = std::str::from_utf8(&bytes)?;
Ok(Doc::new(s))
}The ? operator needs From<E1> for E2 impls (or #[from] via thiserror). If those are missing, the agent should add them, not work around them with match.
8. dyn Trait when generics work
Box<dyn Trait> is dynamic dispatch with a heap allocation. Generic T: Trait is static dispatch, often inlined, no heap. Both are valid. Agents default to dyn because it is easier to satisfy the type system. Push for generics in hot paths.
fn run(plans: Vec<Box<dyn LogicalPlan>>) {
for p in plans {
p.execute();
}
}fn run<P: LogicalPlan>(plans: Vec<P>) {
for p in plans {
p.execute();
}
}Use dyn deliberately when you need heterogeneous collections or stable ABI. Otherwise generics.
9. Panic in From/TryFrom impls
From says "this conversion cannot fail." If your From impl panics on bad input, you have lied to every caller. Use TryFrom for fallible conversions.
impl From<String> for Port {
fn from(s: String) -> Self {
Port(s.parse().unwrap()) // panics on invalid input
}
}impl TryFrom<String> for Port {
type Error = ParsePortError;
fn try_from(s: String) -> Result<Self, Self::Error> {
let n = s.parse().map_err(|_| ParsePortError)?;
Ok(Port(n))
}
}10. Missing Send/Sync bounds in async
If an async function will be tokio::spawn-ed, its future must be Send. Agents sometimes write async code that compiles standalone but breaks the moment you try to spawn it because some non-Send value (an Rc, a raw pointer, a RefCell) snuck in.
11. 'static lifetimes as a workaround
Lifetime errors confuse agents. The lazy fix is to slap 'static on everything, often by cloning into owned types or using Box::leak. The code compiles. It also forfeits Rust's whole point: borrowing without copying.
12. Untested error paths
The happy path has tests. The error path does not. Agents test what they wrote, not what could go wrong. Every Result-returning function should have at least one test for the Err side.
#[test]
fn parses_valid_config() {
let c = parse_config(r#"{"port":8080}"#).unwrap();
assert_eq!(c.port, 8080);
}
// no test for missing port, invalid json, wrong type#[test]
fn parses_valid_config() { /* ... */ }
#[test]
fn rejects_invalid_json() {
let err = parse_config("{not json").unwrap_err();
assert!(matches!(err, ConfigError::Json(_)));
}
#[test]
fn rejects_missing_port() {
let err = parse_config("{}").unwrap_err();
assert!(matches!(err, ConfigError::MissingPort));
}The orchestrator's grep checklist
Run before any agent-written Rust PR is merged:
# Risk surface, in order of severity.
rg "unsafe" --type rust # justify each one
rg "\.unwrap\(\)" --type rust # libraries: zero in non-test code
rg "\.expect\(" --type rust # same
rg "Box<dyn Error" --type rust # library APIs: zero
rg "'static" --type rust # late additions are suspicious
rg "Arc<Mutex" --type rust # justify each one
rg "\.clone\(\)" --type rust | wc -l # count, compare to baselineThe numbers should be small and stable. New entries are review checkpoints.