Paths and directory walks

A path is not a string: it is a sequence of components with a separator that depends on the system, and Rust has a type for it. Path is the borrowed form and PathBuf the owned one, in the same relation as str and String. The filesystem functions take either one.

The lesson builds and inspects paths, creates directories, lists one and walks a tree with an explicit stack. Every listing is sorted before it prints, because the order of read_dir is the order the filesystem returns and not a promise. The last section is the error for storing a string literal in a PathBuf.

Path and PathBuf

Path::new borrows a path from a string, and join builds a child path without worrying about the separator. parent, file_name and extension answer the usual questions, and display turns the path into something printable.

The program joins two components and asks for the parent, the extension and the file name. The output is the four answers, and none of them allocates except the display.

use std::path::Path; fn main() { let path = Path::new("data").join("notes.txt"); println!("{}", path.display()); println!("{:?}", path.parent()); println!("{:?}", path.extension()); println!("{}", path.file_name().unwrap().to_string_lossy()); }

rustc --edition 2024 path.rs
./path

data/notes.txt
Some("data")
Some("txt")
notes.txt

A path is not validated when it is built: it may not exist, and the operation that uses it reports the error. The lesson on text files shows the error a missing path produces.

Creating directories

create_dir_all makes the whole chain of missing parents and succeeds when the directory already exists, which is the behaviour a program wants at start-up. A single create_dir fails when the parent is missing.

The program creates a nested directory, writes a file into it and reads the file back. The output is the text, which proves both the directory and the file were created.

use std::fs; fn main() { fs::create_dir_all("data/nested").unwrap(); fs::write("data/nested/file.txt", "hello").unwrap(); println!("{}", fs::read_to_string("data/nested/file.txt").unwrap()); }

rustc --edition 2024 create_dir.rs
./create_dir

hello

The relative path is resolved against the working directory, which the shell sets and the program can read with env::current_dir. A tool that writes files should say which directory it uses.

Listing a directory

read_dir returns an iterator of DirEntry results, and each entry knows its name and its path. The order is whatever the filesystem returns, so a report sorts the names before printing them.

The program creates three files and a directory, lists the directory and prints the sorted names. The four entries come out alphabetically with the directory last.

use std::fs; fn main() { fs::create_dir_all("data").unwrap(); for name in ["b.txt", "a.txt", "c.txt"] { fs::write(format!("data/{name}"), name).unwrap(); } let mut names: Vec<String> = fs::read_dir("data") .unwrap() .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned()) .collect(); names.sort(); println!("{names:?}"); }

rustc --edition 2024 read_dir.rs
./read_dir

["a.txt", "b.txt", "c.txt", "nested"]

A directory entry is not a file: file_type tells the two apart, and reading a directory as a file is an error. The next section uses the distinction to recurse.

A recursive walk

A walk can use recursion or an explicit stack. The stack version below is iterative, so a deep tree cannot overflow the call stack, and it collects the file paths before sorting and printing them.

The program builds a tree with one file at the top and two in subdirectories, then walks it. The three paths print in alphabetical order, which is the sorted result and not the walk order.

use std::fs; use std::path::PathBuf; fn main() { fs::create_dir_all("tree/a").unwrap(); fs::create_dir_all("tree/b").unwrap(); fs::write("tree/one.txt", "1").unwrap(); fs::write("tree/a/two.txt", "2").unwrap(); fs::write("tree/b/three.txt", "3").unwrap(); let mut stack = vec![PathBuf::from("tree")]; let mut found = Vec::new(); while let Some(directory) = stack.pop() { for entry in fs::read_dir(&directory).unwrap() { let path = entry.unwrap().path(); if path.is_dir() { stack.push(path); } else { found.push(path.display().to_string()); } } } found.sort(); for path in found { println!("{path}"); } }

rustc --edition 2024 walk.rs
./walk

tree/a/two.txt
tree/b/three.txt
tree/one.txt

The stack holds directories and the loop consumes them, which is the depth-first order. A production walk also skips symbolic links and reports unreadable directories instead of unwrapping them.

A string where a PathBuf belongs

PathBuf is an owned type, and a string literal is a &str. Assigning one to the other is E0308, and the fix is PathBuf::from or a Path borrow.

The program declares a PathBuf and assigns a literal. The error names both types, and the help suggests the conversion.

use std::path::PathBuf; fn main() { let path: PathBuf = "data/notes.txt"; println!("{}", path.display()); }

rustc --edition 2024 pathbuf_err.rs

error[E0308]: mismatched types
--> pathbuf_err.rs:4:25
|
4 | let path: PathBuf = "data/notes.txt";
| ------- ^^^^^^^^^^^^^^^^ expected `PathBuf`, found `&str`
| |
| expected due to this
|
help: call `Into::into` on this expression to convert `&'static str` into `PathBuf`
|
4 | let path: PathBuf = "data/notes.txt".into();
| +++++++

error: aborting due to 1 previous error

For more information about this error, try `rustc --explain E0308`.

Most functions accept impl AsRef<Path>, so a literal works at a call and not in a binding. That is why the error appears here and not at every filesystem call.

Exercises

  1. Build a path from three components and print the file stem and the extension.
  2. List a directory and count the entries that are files.
  3. Walk a tree and print only the files with the txt extension.

Questions

A new set of questions in a new order every time. Choose one answer for each, then check.

  1. What is the difference between Path and PathBuf?

    Answer

    Path borrows and PathBuf owns

    The pair mirrors str and String: one borrows a slice of a path, the other owns one.

  2. What does create_dir_all do when the directory exists?

    Answer

    It succeeds

    The function is idempotent, which is what start-up code needs.

  3. What order does read_dir use?

    Answer

    The order the filesystem returns

    No order is promised, which is why the program sorts the names.

  4. Why walk with an explicit stack?

    Answer

    A deep tree cannot overflow the call stack

    The heap-held stack grows as needed, unlike the call stack.

  5. What does this program print? use std::path::Path; fn main() { let path = Path::new("data").join("notes.txt"); println!("{}", path.display()); println!("{:?}", path.extension()); }

    Answer

    data/notes.txt / Some("txt")

    The join builds the child path, and the extension is the part after the last dot.

  6. What does this program print? use std::path::Path; fn main() { let path = Path::new("data/notes.txt"); println!("{:?}", path.parent()); println!("{}", path.file_name().unwrap().to_string_lossy()); }

    Answer

    Some("data") / notes.txt

    The parent is the directory component and the file name is the last part.

  7. What does this program print? use std::fs; fn main() { fs::create_dir_all("data/nested").unwrap(); fs::write("data/nested/file.txt", "hello").unwrap(); println!("{}", fs::read_to_string("data/nested/file.txt").unwrap()); }

    Answer

    hello

    The directory and the file are created, and the text is read back.

  8. What does this program print? use std::fs; fn main() { let _ = fs::remove_dir_all("list-test"); fs::create_dir_all("list-test").unwrap(); for name in ["b.txt", "a.txt"] { fs::write(format!("list-test/{name}"), name).unwrap(); } let mut names: Vec<String> = fs::read_dir("list-test") .unwrap() .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned()) .collect(); names.sort(); println!("{names:?}"); }

    Answer

    ["a.txt", "b.txt"]

    The names are collected and sorted, so the order is alphabetical.

  9. What is the first line of the compiler output for this program? use std::path::PathBuf; fn main() { let path: PathBuf = "data/notes.txt"; println!("{}", path.display()); }

    Answer

    error[E0308]: mismatched types

    The binding wants an owned PathBuf and the literal is a &str.

  10. What does this program print? use std::path::Path; fn main() { let path = Path::new("archive.tar.gz"); println!("{:?}", path.file_stem()); println!("{:?}", path.extension()); }

    Answer

    Some("archive.tar") / Some("gz")

    The stem is everything before the last dot, and the extension is after it.

The code on this page was built with rustc 1.96.1 and run on Alpine Linux 3.24.3 (x86_64) in a Docker container.

Article author: Arthur Isaev