How to list the files in a directory
Short answer: fs::read_dir("demo")? returns an iterator over a directory’s entries. Every entry is an io::Result, and the order is whatever the operating system hands back — sort it yourself if you need it stable.
From a fresh project — cargo new list-demo, then replace the contents of src/main.rs with each block below and run it with cargo run. Each block creates the directory it lists, so it runs on its own and in any order. The fs::create_dir_all and fs::write calls at the top are fixtures; in real code the directory would already exist.
read_dir, entry, path
Section titled “read_dir, entry, path”use std::fs;
fn main() -> std::io::Result<()> { // Removed first so the block can run twice. let _ = fs::remove_dir_all("demo"); fs::create_dir_all("demo/src")?; fs::write("demo/main.rs", "fn main() {}\n")?; fs::write("demo/README.md", "# demo\n")?;
for entry in fs::read_dir("demo")? { println!("{}", entry?.path().display()); } Ok(())}demo/README.mddemo/main.rsdemo/srcread_dir hands you two levels of Result: the call itself fails when the directory can’t be opened, and each item the iterator yields carries its own Result, because errors can also turn up partway through a listing. The next two sections take those one at a time.
entry?.path() is the path you passed in plus the entry’s name — pass a relative path like "demo" and you get relative paths back. The . and .. entries never appear; std skips them. And note the sequence: the documentation’s own warning is that the order “can change between calls”, so treat the listing above as one possible order, not the order.
read_dir is also flat. demo/src showed up as a single entry and nothing descended into it — walking a whole tree is its own problem, covered separately.
Names and a stable order
Section titled “Names and a stable order”entry.path() gives you a location; entry.file_name() gives you just the name. Filenames on disk aren’t guaranteed to be UTF-8, so file_name() returns an OsString and the block below goes through to_string_lossy() to collect plain Strings:
use std::fs;
fn main() -> std::io::Result<()> { // Removed first so the block can run twice. let _ = fs::remove_dir_all("demo"); fs::create_dir_all("demo/src")?; fs::write("demo/main.rs", "fn main() {}\n")?; fs::write("demo/README.md", "# demo\n")?;
let mut names = Vec::new(); for entry in fs::read_dir("demo")? { let entry = entry?; names.push(entry.file_name().to_string_lossy().into_owned()); } names.sort_unstable(); for name in &names { println!("{name}"); } Ok(())}README.mdmain.rssrcsort_unstable() orders the names byte by byte, so the listing is the same on every run and every platform. Do this whenever the output goes anywhere a human reads — a listing that reshuffles itself between runs looks like a bug even when it isn’t.
Two places it can go wrong
Section titled “Two places it can go wrong”The first is opening. read_dir errors when the path doesn’t exist, isn’t a directory, or the process lacks permission to view the contents. Match on the call when a failed listing needs a message rather than a crash:
use std::fs;use std::os::unix::fs::PermissionsExt;
fn main() { // Removed first so the block can run twice. let _ = fs::remove_dir_all("locked-dir"); fs::create_dir_all("locked-dir").unwrap(); fs::set_permissions("locked-dir", fs::Permissions::from_mode(0o000)).unwrap();
match fs::read_dir("locked-dir") { Ok(entries) => { for entry in entries.filter_map(|entry| entry.ok()) { println!("{}", entry.path().display()); } } Err(e) => println!("couldn't list locked-dir: {e}"), }
// Put it back so the block can run twice. fs::set_permissions("locked-dir", fs::Permissions::from_mode(0o755)).unwrap();}couldn't list locked-dir: Permission denied (os error 13)The 0o000 permission bit is a Unix demonstration; the same read_dir call reports whatever the platform’s own access check says on Windows.
The second place is inside the iteration. The standard library documents that new errors may be encountered after the iterator is constructed, which is why every item is a Result even when the directory opened fine. On a healthy local filesystem this is rare, but the type makes you decide what to do anyway. entry? stops the whole listing at the first bad entry; filter_map(|entry| entry.ok()) skips the bad ones and keeps going:
use std::fs;
fn main() -> std::io::Result<()> { // Removed first so the block can run twice. let _ = fs::remove_dir_all("demo"); fs::create_dir_all("demo/src")?; fs::write("demo/main.rs", "fn main() {}\n")?; fs::write("demo/README.md", "# demo\n")?;
for entry in fs::read_dir("demo")?.filter_map(|entry| entry.ok()) { println!("{}", entry.path().display()); } Ok(())}demo/README.mddemo/main.rsdemo/srcOn a directory where nothing goes wrong, that filter changes nothing — the output is the same as the first block’s. Pick based on what a gap would mean: skipping is right when you’re showing a user what’s there, but if your code might act on the absence of a file, a silently dropped entry lies to you, and entry? (or an explicit match) is the honest choice.
Related
Section titled “Related”- How to check whether a file or directory exists — asking about one path instead of listing
- How to delete a file or a whole directory — what you might do with each entry
- Reading and writing files — the rest of the chapter