Rust 1.95 and 1.96 landed within weeks of each other this spring, and together they bring a surprisingly impactful set of additions: copy-able range types that fix a long-standing ergonomic gap, a built-in macro that eliminates a popular third-party dependency, and pattern matching upgrades that simplify control flow. If you’ve been tracking Rust releases casually, here’s what you might have missed and why it matters for your code.
The Range Problem, Finally Solved
One of Rust’s quiet footguns has been that range types like Range<usize> and RangeInclusive<usize> aren’t Copy. This is technically correct — they implement Iterator directly, and combining Iterator with Copy is a recipe for accidentally consuming the same iterator multiple times. But in practice, it meant you couldn’t store a range in a struct that derives Copy without manually splitting it into start and end fields.
Rust 1.96 introduces new range types in core::range that implement IntoIterator instead of Iterator directly. This means they can be Copy, and they are. The three new types are:
use core::range::Range;
#[derive(Clone, Copy)]
pub struct Span(Range<usize>);
impl Span {
pub fn new(start: usize, end: usize) -> Self {
Span(Range { start, end })
}
pub fn of(self, s: &str) -> &str {
&s[self.0]
}
}
// Now Span is fully Copy — no more manual field splitting
let span = Span::new(0, 10);
let span2 = span; // Copy, not move
println!("{}", span.of("hello, world")); // "hello, wor"
The new RangeInclusive also makes its fields public, unlike the legacy version which hid them to manage the exhausted iterator state. Since the new types require an explicit conversion to begin iteration, there’s no ambiguity about whether the range has been partially consumed.
Library authors should prefer using impl RangeBounds in public APIs, which accepts both legacy and new range types. Range syntax like 0..10 still produces the legacy types for now, but a future Rust edition will update it to produce the new core::range types — so adopting RangeBounds generics now is a forward-compatible move.
cfg_select!: Goodbye, cfg-if Crate
Rust 1.95 stabilizes cfg_select!, a macro that acts like a compile-time match expression for configuration predicates. If you’ve been using the cfg-if crate — and most non-trivial Rust projects do — this is your chance to drop that dependency.
// Platform-specific socket initialization
cfg_select! {
unix => {
fn init_socket() -> i32 {
unsafe { libc::socket(libc::AF_INET, libc::SOCK_STREAM, 0) }
}
}
windows => {
fn init_socket() -> i32 {
unsafe { winapi::um::winsock2::socket(2, 1, 0) }
}
}
_ => {
fn init_socket() -> i32 {
compile_error!("Unsupported platform for socket operations");
}
}
}
// Also works as an expression
let backend = cfg_select! {
feature = "redis" => "redis",
feature = "postgres" => "postgres",
_ => "sqlite",
};
The syntax differs slightly from cfg-if! — it uses => arms instead of conditional blocks — but the mental model is straightforward. The macro expands to the right-hand side of the first arm whose configuration predicate evaluates to true. A _ wildcard arm acts as the fallback.
if-let Guards in Match Expressions
Rust 1.88 stabilized let chains, and Rust 1.95 extends that capability into match expressions with if-let guards. This feature lets you bind variables inside match arms with additional conditional logic, reducing the boilerplate of nested matches and temporary variables.
fn process_event(event: &Event) -> Action {
match event {
// Bind `key` from the pattern AND validate it
Some(key) if let Ok(parsed) = parse_key(key) => {
Action::Process(parsed)
}
// Multiple conditions chain naturally
Some(key) if key.len() > 32 => {
Action::Reject("key too long")
}
Some(_) => Action::Skip,
None => Action::Noop,
}
}
One caveat: the compiler doesn’t include patterns matched in if-let guards as part of exhaustiveness checking for the overall match. This means you still need a catch-all arm even if the if-let guard theoretically covers all remaining cases. This is consistent with how regular if guards work in match expressions.
assert_matches!: Pattern Assertions That Actually Help Debug
Rust 1.96 stabilizes assert_matches! and debug_assert_matches!, which check that a value matches a given pattern and panic with the value’s Debug representation on failure. You could previously write assert!(matches!(value, pattern)), but when the assertion failed you’d get no information about what the value actually was.
use core::assert_matches;
#[derive(Debug)]
enum State {
Active { id: u32 },
Inactive { reason: String },
}
fn transition(state: State) -> State {
let next = apply_transition(state);
// If this fails, you see the actual value in the panic message
assert_matches!(next, State::Active { id: _ });
next
}
These macros are intentionally not in the prelude because they’d collide with popular third-party crates that provide the same name. Import them explicitly from core::assert_matches or std::assert_matches.
Atomic Update Operations
Rust 1.95 adds update and try_update methods to atomic types: AtomicPtr, AtomicBool, AtomicI*, and AtomicU*. These methods take a closure that receives the current value and returns the new value, using a compare-and-swap loop internally. This eliminates a common pattern where you’d manually loop on compare_exchange_weak.
use std::sync::atomic::{AtomicUsize, Ordering};
static ACTIVE_CONNECTIONS: AtomicUsize = AtomicUsize::new(0);
fn increment_connections() {
// Atomic read-modify-write in one call
ACTIVE_CONNECTIONS.update(|current| current + 1, Ordering::Relaxed);
}
fn decrement_connections() {
ACTIVE_CONNECTIONS.update(|current| current.saturating_sub(1), Ordering::Relaxed);
}
The try_update variant returns Result instead of looping, giving you control over what happens if the CAS keeps failing under contention.
Mutable Push and Insert Methods
Another quality-of-life addition in 1.95: Vec::push_mut, Vec::insert_mut, and equivalent methods on VecDeque and LinkedList. These methods return a mutable reference to the pushed or inserted element, which is useful when you want to initialize a value in-place after adding it to a collection.
let mut items: Vec<String> = Vec::new();
// push_mut returns &mut String, so you can modify it immediately
let item = items.push_mut(String::new());
item.push_str("initialized in-place");
// Same pattern for insert_mut
let inserted = items.insert_mut(0, String::new());
inserted.push_str("prepended");
WebAssembly Gets Stricter
Rust 1.96 changes how WebAssembly targets handle linking: the compiler no longer passes --allow-undefined to the linker by default. Previously, undefined symbols during linking were silently converted to WebAssembly imports from the “env” module, which could mask bugs in your code or misconfigurations in your build setup.
If your WASM project relies on importing host functions through undefined symbols — a common pattern in Emscripten-based projects — you’ll need to either define those symbols in your Rust code with #[link(wasm_import_module = "env")] or re-enable the old behavior with RUSTFLAGS=-Clink-arg=--allow-undefined. This change was announced in advance and now takes effect in 1.96.
What’s Worth Adopting Now
The most immediately impactful changes across these two releases are the new core::range types and cfg_select!. If you maintain libraries with public APIs that accept ranges, start using impl RangeBounds to accept both old and new types. If your project depends on cfg-if, swap it out for cfg_select! — it’s one fewer dependency in your tree and the syntax is cleaner.
The if-let guards and assert_matches! are smaller additions, but they smooth over daily friction points in match expressions and test assertions. Update to 1.96 with rustup update stable and check out the full release notes and 1.95 release notes for the complete changelog.