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 bool — true 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.