#186 Jun 2026

186. str::split_inclusive — Split a String and Keep the Separator With Each Chunk

"a\nb\n".split('\n') swallows every newline and hands you a phantom "" at the end. split_inclusive keeps each separator glued to the chunk it belongs to — no ghost element, and you can .concat() straight back to the original.

The split that loses information

The default split is destructive: the matched character is gone, and a trailing separator becomes an empty string.

1
2
3
4
5
let log = "INFO\nWARN\nERROR\n";

let parts: Vec<&str> = log.split('\n').collect();
assert_eq!(parts, ["INFO", "WARN", "ERROR", ""]);
//                                            ^^ phantom empty tail

That phantom empty string is the source of a hundred Stack Overflow questions. You usually paper over it with .filter(|s| !s.is_empty()) or trim_end() before splitting.

split_inclusive keeps the terminator

Each piece keeps the separator that ended it. The trailing newline isn’t an empty string — it’s the end of the last real chunk.

1
2
3
4
5
let log = "INFO\nWARN\nERROR\n";

let parts: Vec<&str> = log.split_inclusive('\n').collect();
assert_eq!(parts, ["INFO\n", "WARN\n", "ERROR\n"]);
assert_eq!(parts.concat(), log); // round-trips exactly

When you actually want this

Reformatting line by line without losing the trailing newline:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
let src = "fn main() {\n    println!(\"hi\");\n}\n";

let numbered: String = src
    .split_inclusive('\n')
    .enumerate()
    .map(|(i, line)| format!("{:>2} | {line}", i + 1))
    .collect();

assert_eq!(
    numbered,
    " 1 | fn main() {\n 2 |     println!(\"hi\");\n 3 | }\n"
);

No newline added, none lost — every \n is already where it should be.

Works on slices too

[T]::split_inclusive exists with the same shape, taking a predicate instead of a pattern. Useful for batching consecutive items up to a delimiter element.

1
2
3
let bytes = [1, 2, 0, 3, 0, 4];
let chunks: Vec<&[u8]> = bytes.split_inclusive(|&b| b == 0).collect();
assert_eq!(chunks, [&[1, 2, 0][..], &[3, 0][..], &[4][..]]);

Reach for split_inclusive whenever the separator is part of the data — line endings, statement terminators, record markers — not noise to throw away.

#185 Jun 2026

185. Range<NonZeroU32> — Iterate NonZero Integers Without Re-Wrapping Every Step

NonZeroU32 keeps Option<Id> at 4 bytes — but until Rust 1.96 you couldn’t iterate lo..hi over them. You’d drop back to u32 and re-wrap every step.

NonZeroU32 and friends are great for indices and IDs because Option<NonZeroU32> fits the niche and stays 4 bytes wide. The catch: Range<NonZeroU32> wasn’t an iterator. The moment you wanted to walk a range of IDs, you fell back to plain u32 and unwrapped your way back in:

1
2
3
4
5
6
7
8
9
use std::num::NonZeroU32;

let lo = NonZeroU32::new(1).unwrap();
let hi = NonZeroU32::new(5).unwrap();

// Pre-1.96: lift, iterate plain ints, re-wrap each step.
let ids: Vec<NonZeroU32> = (lo.get()..hi.get())
    .map(|n| NonZeroU32::new(n).unwrap())
    .collect();

Three problems: the Range drops the invariant, every step pays for an unwrap, and a future refactor that changes the bound type silently swaps your iterator out from under you.

Rust 1.96 stabilized Step for NonZero integers (PR #127534). Now the range itself is an iterator that yields NonZeroU32:

1
2
3
4
5
6
7
8
use std::num::NonZeroU32;

let lo = NonZeroU32::new(1).unwrap();
let hi = NonZeroU32::new(5).unwrap();

let ids: Vec<NonZeroU32> = (lo..hi).collect();
assert_eq!(ids.len(), 4);
assert_eq!(ids[0].get(), 1);

It works for the inclusive form too, so you can sweep the whole representable range without overflow gymnastics:

1
2
3
4
5
6
use std::num::NonZeroU8;

let total: u32 = (NonZeroU8::MIN..=NonZeroU8::MAX)
    .map(|n| n.get() as u32)
    .sum();
assert_eq!(total, 32_640); // 1 + 2 + ... + 255

If you keep your IDs in a NonZeroU32 newtype to shrink Option, the iteration story now matches: the range yields the right type the whole way through, no per-step unwrap, no invariant laundering through u32.

#184 Jun 2026

184. Option::take — The Ergonomic Sibling of mem::take, Just for Option

You’ve got an Option<T> behind &mut self and you need the T out. mem::take works, but the field is already an Option.take() reads better and does the same job.

This morning’s bite on std::mem::take covered the general move: swap in T::default(), hand back the original. For Option<T>, the default is None — and the standard library exposes that exact operation as Option::take, so the call site stops looking like memory plumbing:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
struct Connection {
    handle: Option<Socket>,
}

impl Connection {
    fn disconnect(&mut self) -> Option<Socket> {
        // std::mem::take(&mut self.handle)  // works, reads like low-level glue
        self.handle.take()                  // same thing, reads like English
    }
}
# struct Socket;

Internally Option::take is one line: mem::replace(self, None). The win is purely about the call site.

The pattern earns its keep in Drop, where you need to consume an owned resource by value but only have &mut self:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
struct Worker {
    join: Option<std::thread::JoinHandle<()>>,
}

impl Drop for Worker {
    fn drop(&mut self) {
        if let Some(handle) = self.join.take() {
            let _ = handle.join();   // join() consumes the handle by value
        }
    }
}

Without .take(), you can’t move self.join out (you only have &mut self), and JoinHandle::join takes self by value — so you’re stuck. take() swaps None in and gives you the owned JoinHandle to consume.

It also kills the classic “transfer once” pattern in builders and state machines:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
struct Pending {
    payload: Option<String>,
}

impl Pending {
    fn send(&mut self) -> Option<String> {
        // Hand the payload to the caller; we no longer own it.
        // Subsequent calls return None.
        self.payload.take()
    }
}

Reach for Option::take whenever the field is already Option<T>. Reach for std::mem::take when the field is some other T: Default and you want the empty version left behind.

#183 Jun 2026

183. std::mem::take — Move Out of &mut self Without the Clone

You need the Vec out of &mut self, the borrow checker says no, so you .clone() it. Don’t. mem::take swaps in the default and hands you the original — zero allocation.

The borrow checker won’t let you move a field out of &mut self, because that would leave self half-initialized. The usual workarounds are ugly: clone the whole thing, or refactor the API to take self by value.

std::mem::take does the right thing in one line. It replaces the field with T::default() and returns the old value. For collections, Default is empty — so there’s no allocation, just a pointer swap.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
use std::mem;

struct Buffer {
    items: Vec<String>,
}

impl Buffer {
    fn drain_all(&mut self) -> Vec<String> {
        // self.items                 // ❌ cannot move out of borrowed content
        // self.items.clone()         // ❌ allocates + copies every String
        mem::take(&mut self.items)    // ✅ swaps in empty Vec, returns the real one
    }
}

Where it really earns its keep is state-machine transitions, where you need to consume the data inside the current variant before swapping the variant:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
use std::mem;

enum Stream {
    Buffering(Vec<u8>),
    Done,
}

impl Stream {
    fn finish(&mut self) -> Vec<u8> {
        if let Stream::Buffering(buf) = self {
            let bytes = mem::take(buf);   // pull the Vec out, leave [] behind
            *self = Stream::Done;          // now safe to overwrite self
            return bytes;
        }
        Vec::new()
    }
}

Without mem::take, that pattern usually devolves into a mem::replace(self, Stream::Done) and a match on the returned value. mem::take is shorter and reads top-to-bottom.

It works for any T: DefaultString, HashMap, Option, Box<[T]>, your own structs that derive Default. If Default isn’t free for your type, reach for mem::replace and pass the sentinel you actually want.

#182 Jun 2026

182. Path::with_extension — Swap a File Extension Without Slicing Strings

You have report.txt and want report.md. Reaching for replace(".txt", ".md") or a rfind('.')? Stop — Path::with_extension returns a fresh PathBuf with the extension swapped, and it gets every edge case right.

The string-slicing trap

The naïve fix looks reasonable until you read it carefully:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
fn change_ext_bad(name: &str, ext: &str) -> String {
    match name.rfind('.') {
        Some(i) => format!("{}.{}", &name[..i], ext),
        None    => format!("{}.{}", name, ext),
    }
}

assert_eq!(change_ext_bad("report.txt", "md"), "report.md");
// But...
assert_eq!(change_ext_bad("./.bashrc", "bak"), "./.bak"); // ate the dotfile name

That second case is the bug: ./.bashrc has no extension — the leading dot is part of the name. Manual rfind('.') doesn’t know that.

The fix: Path::with_extension

1
2
3
4
5
6
use std::path::{Path, PathBuf};

let p = Path::new("reports/q1.txt");
let renamed: PathBuf = p.with_extension("md");

assert_eq!(renamed, PathBuf::from("reports/q1.md"));

It returns a new PathBuf — original path untouched — and stays in OsStr land the whole way through, so non-UTF-8 paths survive intact.

Dotfiles are handled the way you’d want:

1
2
3
4
use std::path::{Path, PathBuf};

assert_eq!(Path::new(".bashrc").with_extension("bak"),
           PathBuf::from(".bashrc.bak"));

No extension to start? It adds one instead of failing:

1
2
3
4
use std::path::{Path, PathBuf};

assert_eq!(Path::new("Makefile").with_extension("bak"),
           PathBuf::from("Makefile.bak"));

Pass "" to strip the extension

The same method, with an empty string, removes the extension entirely — no separate without_extension API needed:

1
2
3
4
use std::path::{Path, PathBuf};

let src = Path::new("build/main.o");
assert_eq!(src.with_extension(""), PathBuf::from("build/main"));

Common pattern in build scripts: derive an output path from an input path.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
use std::path::{Path, PathBuf};

fn object_for(src: &Path) -> PathBuf {
    src.with_extension("o")
}

assert_eq!(object_for(Path::new("src/main.rs")),
           PathBuf::from("src/main.o"));
assert_eq!(object_for(Path::new("src/lib.rs")),
           PathBuf::from("src/lib.o"));

Only the last extension changes

with_extension replaces from the last dot — same rule as file_stem. For archive.tar.gz, that means only .gz gets swapped:

1
2
3
4
use std::path::{Path, PathBuf};

assert_eq!(Path::new("archive.tar.gz").with_extension("zst"),
           PathBuf::from("archive.tar.zst"));

That’s almost always what you want for compression tools. If you need to strip the whole .tar.gz and start over, call with_extension("") twice — or reach for file_prefix (see bite 116).

set_extension if you already own the PathBuf

The mutating sibling lives on PathBuf and avoids the allocation when you already own the path:

1
2
3
4
5
use std::path::PathBuf;

let mut p = PathBuf::from("notes/draft.md");
p.set_extension("html");
assert_eq!(p, PathBuf::from("notes/draft.html"));

Returns booltrue if the extension was set, false if the path had no file name to attach one to. Most callers ignore it.

Reach for with_extension (or set_extension) any time you’d otherwise write a rfind('.') or a replace(".old", ".new"). It’s been stable since Rust 1.0 — there’s no excuse left.

#181 Jun 2026

181. Option::get_or_insert_with — Lazy Default That Returns &mut

You have an Option<Vec<T>> field and want to push to it. If it’s None, allocate first; if it’s Some, just push. get_or_insert_with does both in one call — and hands you back a &mut so you can use it on the same line.

The dance you don’t have to do

The naïve version checks, assigns, then unwraps:

1
2
3
4
5
6
7
8
let mut tags: Option<Vec<String>> = None;

if tags.is_none() {
    tags = Some(Vec::new());
}
tags.as_mut().unwrap().push("verbose".into());

assert_eq!(tags, Some(vec!["verbose".to_string()]));

Three lines, an unwrap, and you re-borrow the Option twice. get_or_insert_with collapses it:

1
2
3
4
5
let mut tags: Option<Vec<String>> = None;

tags.get_or_insert_with(Vec::new).push("clean".into());

assert_eq!(tags, Some(vec!["clean".to_string()]));

It initializes the Option to Some(f()) if and only if it was None, and returns &mut T to the inner value either way. No unwrap, no double-check.

The closure only runs when needed

That’s the whole point of the _with suffix: the default is lazy. If the value’s already there, your closure never fires, which matters when the default is expensive or has side effects:

1
2
3
4
5
6
7
8
let mut cache: Option<Vec<u8>> = Some(vec![1, 2, 3]);

let buf = cache.get_or_insert_with(|| {
    panic!("would allocate a huge buffer");
});
buf.push(4);

assert_eq!(cache, Some(vec![1, 2, 3, 4]));

If your default is cheap (a String::new(), a 0u32), use the eager sibling get_or_insert and skip the closure:

1
2
3
let mut log: Option<String> = None;
log.get_or_insert(String::new()).push_str("hi");
assert_eq!(log.as_deref(), Some("hi"));

Why the &mut return matters

get_or_insert_with returns &mut T, not T or Option<T>. That lets you keep chaining — push, mutate, hand to another function — without ever re-borrowing the Option:

1
2
3
4
5
6
let mut counters: Option<Vec<u32>> = None;

counters.get_or_insert_with(Vec::new).extend([1, 2, 3]);
counters.get_or_insert_with(Vec::new).push(4);

assert_eq!(counters, Some(vec![1, 2, 3, 4]));

The classic case is builders and config structs with Option<Vec<_>> fields that should only allocate when the caller actually adds something. One line per add, no upfront Some(Vec::new()), no unwrap.

Stable since Rust 1.20.

#180 Jun 2026

180. Option::unzip — Split an Optional Pair Into a Pair of Options

When you have an Option<(A, B)> but the rest of your code wants (Option<A>, Option<B>), the obvious move is two map calls. Option::unzip does both halves in a single call.

The two-map dance

Say a parser returns Option<(&str, i32)> — a name and an age, or nothing. Downstream you want them as separate optional columns:

1
2
3
4
5
6
7
let parsed: Option<(&str, i32)> = Some(("ada", 36));

let name = parsed.map(|(n, _)| n);
let age  = parsed.map(|(_, a)| a);

assert_eq!(name, Some("ada"));
assert_eq!(age,  Some(36));

That’s two passes over the same Option, two closures that throw away half their input, and an easy place to typo n for a. Option::unzip collapses it:

1
2
3
4
5
6
let parsed: Option<(&str, i32)> = Some(("ada", 36));

let (name, age) = parsed.unzip();

assert_eq!(name, Some("ada"));
assert_eq!(age,  Some(36));

Some((a, b)) becomes (Some(a), Some(b)). No closures, no destructuring noise.

The None case is the whole point

unzip shines on the None branch. None becomes (None, None) — both sides empty, no special-casing:

1
2
3
4
5
let missing: Option<(&str, i32)> = None;
let (name, age) = missing.unzip();

assert_eq!(name, None);
assert_eq!(age,  None);

Compare with the manual version: you’d still write two maps and rely on each one to short-circuit. Same behavior, more lines, more chances to drift apart when someone edits one branch and forgets the other.

Round-trip with zip

Option::zip is the inverse — it builds Option<(A, B)> from (Option<A>, Option<B>), returning Some only if both are Some:

1
2
3
4
5
6
7
8
9
let name: Option<&str> = Some("ada");
let age: Option<i32>   = Some(36);

let joined = name.zip(age);
assert_eq!(joined, Some(("ada", 36)));

let (n, a) = joined.unzip();
assert_eq!(n, Some("ada"));
assert_eq!(a, Some(36));

One mental model: Option<(A, B)> and (Option<A>, Option<B>) are almost the same shape, and these two methods let you flip between them without match.

When to reach for it

Any time a single optional value naturally carries two parts that the rest of the code wants to handle independently — coordinates, key/value pairs, name/age, parsed/raw. If you find yourself writing opt.map(|(x, _)| x) and opt.map(|(_, y)| y) back-to-back, that’s the signal.

Stable since Rust 1.66.

#179 Jun 2026

179. Iterator::max_by_key — Find the Best Element Without a Manual Fold

Finding the “best” item in a collection — longest string, heaviest order, latest timestamp — is one of those tasks where a hand-rolled fold keeps showing up. Iterator::max_by_key does the same job in one call, and min_by_key is right there next to it.

The fold you keep writing

You have a slice of strings and want the longest one. The DIY version looks something like this:

1
2
3
4
5
6
7
8
9
let words = ["pear", "raspberry", "fig", "kiwi"];

let longest = words.iter().fold(None, |best, w| match best {
    None => Some(w),
    Some(b) if w.len() > b.len() => Some(w),
    other => other,
});

assert_eq!(longest, Some(&"raspberry"));

That’s a lot of code for a one-liner concept. max_by_key collapses the whole pattern:

1
2
3
let words = ["pear", "raspberry", "fig", "kiwi"];
let longest = words.iter().max_by_key(|w| w.len());
assert_eq!(longest, Some(&"raspberry"));

You hand it a closure that extracts the key — the thing you want to maximise — and it returns the item that produced the largest key.

Works on anything Ord

The key doesn’t have to be a number. It just has to implement Ord, which means strings, tuples, dates, your own types — anything totally ordered:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
#[derive(Debug, PartialEq)]
struct Order { id: u32, total_cents: u64 }

let orders = vec![
    Order { id: 1, total_cents: 1299 },
    Order { id: 2, total_cents: 4500 },
    Order { id: 3, total_cents: 800 },
];

let biggest = orders.iter().max_by_key(|o| o.total_cents);
assert_eq!(biggest, Some(&Order { id: 2, total_cents: 4500 }));

Tuples are where this really shines — you get multi-key sorting for free, because (A, B): Ord compares lexicographically:

1
2
3
4
5
let words = ["pear", "fig", "kiwi", "lime"];

// Longest word, then alphabetically last among ties.
let pick = words.iter().max_by_key(|w| (w.len(), *w));
assert_eq!(pick, Some(&"pear"));

Same trick with min_by_key — the entire _by_key family follows the same shape.

The tie-break rule, and why it matters

When two elements produce equal keys, max_by_key returns the last one and min_by_key returns the first. That asymmetry is in the docs and it bites people:

1
2
3
4
5
6
7
let nums = [3, 1, 4, 1, 5, 9, 2, 6, 5];

let max = nums.iter().max_by_key(|&&n| n);
let min = nums.iter().min_by_key(|&&n| n);

assert_eq!(max, Some(&9));
assert_eq!(min, Some(&1)); // the first 1, not the second

If you need a specific tie-break — “longest word, but earliest in the list when tied” — encode it in the key itself with a tuple, instead of relying on iteration order.

Floats need min_by / max_by

f32 and f64 don’t implement Ord (because NaN), so max_by_key won’t compile if your key is a float. Reach for max_by and pass a comparator instead:

1
2
3
4
5
6
let measurements = [1.2_f64, 3.4, 0.5, 2.8];

let biggest = measurements.iter()
    .max_by(|a, b| a.partial_cmp(b).unwrap());

assert_eq!(biggest, Some(&3.4));

Or wrap your floats in something like ordered_float::OrderedFloat and stay with max_by_key.

Takeaway

Any time you find yourself folding to track the “best so far,” check whether max_by_key or min_by_key says it in one line. The closure extracts the score; the iterator returns the winner. For ties you control the rule by shaping the key.

#178 Jun 2026

178. Ord::clamp — Stop Writing min(max, max(min, x))

Bounding a value between two limits is one of those tiny operations where everyone hand-rolls a confusing nest of min/max calls. Ord::clamp is the single-call version, and it works on anything that’s Ord or PartialOrd — not just numbers.

You’ve seen this somewhere in every codebase:

1
2
let volume = raw.max(0).min(100);          // OK
let other  = std::cmp::min(100, std::cmp::max(0, raw)); // worse

Both work. Both make you stop and squint to figure out which bound is which. clamp says exactly what it does:

1
let volume = raw.clamp(0, 100);

It’s defined for Ord on integers, and as f32::clamp / f64::clamp for floats (which need PartialOrd because of NaN). Same shape on all of them:

1
2
3
4
5
6
assert_eq!((-5_i32).clamp(0, 10), 0);
assert_eq!(7_i32.clamp(0, 10), 7);
assert_eq!(99_i32.clamp(0, 10), 10);

assert_eq!(2.5_f64.clamp(0.0, 1.0), 1.0);
assert_eq!((-0.3_f64).clamp(0.0, 1.0), 0.0);

It’s not just for numbers. Anything Ord works — char, &str, String, your own types:

1
2
assert_eq!('z'.clamp('a', 'f'), 'f');
assert_eq!("zebra".clamp("apple", "mango"), "mango");

One gotcha: clamp panics if min > max. That’s deliberate — a backwards range is almost always a bug, and silently returning either bound would hide it. If your bounds come from user input or config, validate them once at the boundary:

1
2
3
4
5
6
fn safe_clamp(x: i32, lo: i32, hi: i32) -> i32 {
    let (lo, hi) = if lo <= hi { (lo, hi) } else { (hi, lo) };
    x.clamp(lo, hi)
}

assert_eq!(safe_clamp(5, 10, 0), 5); // bounds got swapped, still works

For floats there’s one more wrinkle: f64::clamp propagates NaN if the input is NaN, but panics if either bound is NaN. So x.clamp(0.0, 1.0) is safe as long as your bounds are real numbers — which they always should be.

#177 Jun 2026

177. BTreeMap::range — Iterate a Sorted Map by Key Range

You have a sorted map keyed by time, port number, or version, and you want every entry between two keys. Filtering on iter() walks the whole map — BTreeMap::range walks just the slice you asked for.

The filter-everything pattern

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
use std::collections::BTreeMap;

let mut map = BTreeMap::new();
map.insert(1, "a");
map.insert(3, "c");
map.insert(5, "e");
map.insert(7, "g");

// Walk every entry, skip the ones you don't want.
let hits: Vec<_> = map.iter().filter(|(k, _)| (3..7).contains(*k)).collect();
assert_eq!(hits, vec![(&3, &"c"), (&5, &"e")]);

Correct, but with a million entries you still traversed all of them. BTreeMap::range takes a RangeBounds and seeks straight to the start key, iterating only through the requested window:

1
2
3
4
5
6
7
use std::collections::BTreeMap;

let map: BTreeMap<i32, &str> =
    BTreeMap::from([(1, "a"), (3, "c"), (5, "e"), (7, "g")]);

let hits: Vec<_> = map.range(3..7).collect();
assert_eq!(hits, vec![(&3, &"c"), (&5, &"e")]);

Same result, log-N lookup to the first key, in-order iteration from there.

All the usual range syntax works

Any RangeBounds<K> is fair game — .., a..b, a..=b, ..b, a..:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
use std::collections::BTreeMap;

let map: BTreeMap<i32, &str> =
    BTreeMap::from([(1, "a"), (3, "c"), (5, "e"), (7, "g")]);

let inclusive: Vec<_> = map.range(3..=7).collect();
assert_eq!(inclusive, vec![(&3, &"c"), (&5, &"e"), (&7, &"g")]);

let from_five: Vec<_> = map.range(5..).collect();
assert_eq!(from_five, vec![(&5, &"e"), (&7, &"g")]);

let up_to_five: Vec<_> = map.range(..5).collect();
assert_eq!(up_to_five, vec![(&1, &"a"), (&3, &"c")]);

For an exclusive start (rare but it happens), reach for Bound::Excluded:

1
2
3
4
5
6
7
8
use std::collections::BTreeMap;
use std::ops::Bound::{Excluded, Unbounded};

let map: BTreeMap<i32, &str> =
    BTreeMap::from([(1, "a"), (3, "c"), (5, "e"), (7, "g")]);

let after_three: Vec<_> = map.range((Excluded(3), Unbounded)).collect();
assert_eq!(after_three, vec![(&5, &"e"), (&7, &"g")]);

Mutate in place with range_mut

range_mut gives you &mut V for each entry in the window — keys stay immutable (they have to, the map’s invariant depends on them):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
use std::collections::BTreeMap;

let mut scores: BTreeMap<i32, i32> =
    BTreeMap::from([(1, 10), (3, 10), (5, 10), (7, 10)]);

for (_, v) in scores.range_mut(3..=5) {
    *v += 1;
}

assert_eq!(scores[&1], 10);
assert_eq!(scores[&3], 11);
assert_eq!(scores[&5], 11);
assert_eq!(scores[&7], 10);

The same trick works on BTreeSet::range.

Takeaway

When you find yourself filtering a BTreeMap by a range of keys, swap the filter for range. You get O(log n) seek plus O(k) iteration over just the matches, and the code reads more like the intent.