Deryks _dev

Full-Stack Web Developer & Technical Leader

<< Back / Lesson 17 / Part 2 / ~50 minutes

Paths That Aren't Strings

Five stages, and the picker stops lying about what is on disk.

A filename is not text. Rust has a type for it, and using the wrong one is the difference between opening a file and quietly inventing a new one.

Lesson 16 wrote one line on borrowed time: to_string_lossy(), turning every name the filesystem handed back into a String. It also let you click a directory and get a buffer for it. Both are the same mistake, made in the same place — the editor has been storing filenames in a type that cannot hold them — and this lesson pays for it.

By the end, Buffer, list_dir, the picker andmain all speak Path, PathBuf andOsString; a file with a name that isn't valid UTF-8 opens from the command line and from the picker; and clicking a directory in the picker shows you what is inside it.

Before you start

You should be at the end of lesson 16:cargo test reports twenty passing and src/main.rs is about 700 lines. Stage 1 changes nothing and breaks the editor twice; stages 2 to 5 fix it.

This is the first lesson where the compiler does most of the finding. Two stages start by changing one type and then doing whatever cargo check asks — so run it after every edit, and read what it says before you read what I say.

Type it, don't paste itSame as every lesson. About ninety lines change, and most of them are one word long.

adding   removing   existing code, shown only for context

Make a file the editor cannot open

You have watched the picker open the wrong file, and the command line refuse the right one.

No edits in this stage. It is an experiment, and the experiment needs a file whose name is not valid UTF-8. Linux will happily make one, because to Linux a filename is a sequence of bytes and nothing more:

cd editor
touch "$(printf 'caf\xe9.txt')"
ls | cat -v
→ cafM-i.txt among the others

\xe9 is the single byte that means é in Latin-1. In UTF-8 that byte can only ever be the second half of a two-byte character, so on its own it is not a character at all. cat -v shows it as M-i because it has no other way to draw it. Every file you have opened until now was named in ASCII; this one is the first that isn't.

The picker says "new file"

cargo run -- a.rs
→ Ctrl+O — the listing includes caf�.txt
→ click it
→ a second tab, empty, and the status bar says new file: caf�.txt

The file exists. The editor has just told you it doesn't. Look at the name it showed you: � is U+FFFD, the replacement character thatto_string_lossy substituted for the byte it could not represent. That altered string is what the click handed to Buffer::open, and there is no file by that name — so lesson 2's "a file that doesn't exist yet is a new file" rule kicked in and reported a new one. Press Ctrl+S in that tab and you would get a second file on disk, named with a literal �, beside the one you meant.

This is a real category of bug: two representations of a name that look identical on screen and differ on disk. The editor shows one, the filesystem has the other, and neither side is wrong — they are just talking about different things.

The command line refuses it outright

Close the window and try the other way in:

cargo run -- "$(printf 'caf\xe9.txt')"

thread 'main' panicked at /rustc/…/library/std/src/env.rs:876:51:
called `Result::unwrap()` on an `Err` value: "caf\xE9.txt"

No window at all. The panic is not in your code — the location is insidestd, in env.rs — and it is not a bug in std either. Read the documentation forenv::args: "The returned iterator will panic during iteration if any argument to the process is not valid Unicode." It says so, because it promised to give you Strings and this argument cannot be one. The same page tells you what to use instead.

Three pairs of types

You already know one pair: String owns its text and &strborrows it. Rust has two more pairs shaped exactly the same way, and this lesson is about meeting them:

OwnedBorrowedWhat it promises
String&strValid UTF-8, always
OsString&OsStrWhatever the operating system gave you — bytes on Linux
PathBuf&PathAn OsString that you intend to use as a location

Every method you know on String and &str has a counterpart: String::from and PathBuf::from,.to_owned() on both, &String quietly becoming&str and &PathBuf quietly becoming&Path. The difference is the promise in the third column. AString that holds caf\xe9.txt is impossible; aPathBuf that holds it is perfectly ordinary.

The editor stores every filename as a String. That is the bug — not the lossy line, which was only ever the symptom. Four stages fix it, from the inside out.

The buffer's path becomes a PathBuf

cargo test still reports twenty passing, andBuffer::name is shorter than it was.

Change one field's type and let the compiler tell you everything that depended on it.Nine edits, and you will not have to hunt for any of them.

First — the import:

editor/src/main.rsthe imports at the top

use std::ops::Range;
use std::path::PathBuf;
use std::sync::Arc;

Second — the field:

editor/src/main.rsthe Buffer struct

/// One open file.
struct Buffer {
    path: String,
    path: PathBuf,
    text: Text,

Now stop, and run cargo check. Do not fix anything yet.

error[E0308]: mismatched types
   --> src/main.rs:237:19
    |
237 |             path: path.to_owned(),
    |                   ^^^^^^^^^^^^^^^ expected `PathBuf`, found `String`

error[E0277]: `PathBuf` doesn't implement `std::fmt::Display`
   --> src/main.rs:248:72
    |
248 |         self.status = format!("saved {} bytes to {}", self.text.len(), self.path);
    |                                                                        ^^^^^^^^^ `PathBuf` cannot be formatted with the default formatter; call `.display()` on it
    |
    = note: call `.display()` or `.to_string_lossy()` to safely print paths, as they may contain non-Unicode data

error[E0599]: no method named `trim_end_matches` found for struct `PathBuf` in the current scope
   --> src/main.rs:258:14

error[E0277]: the trait bound `eframe::egui::RichText: From<&PathBuf>` is not satisfied
    --> src/main.rs:485:24
     |
 485 |             ui.heading(&buffer.path);

error: could not compile `editor` (bin "editor") due to 4 previous errors

Four places in the program treated path as text. The compiler found all four in under a second, and for two of them told you the fix. This is what a type change is for: you are not editing a field, you are asking the compiler to audit every use of it. Take them in order.

Third — the constructor

editor/src/main.rsin Buffer::open, the struct at the end

        Self {
            path: path.to_owned(),
            path: PathBuf::from(path),
            text: Text::new(&text),

path is still a &str here; that changes in stage 3.PathBuf::from accepts it, the same way String::from would — and, as the table said, the conversion cannot fail, because a PathBufpromises less than a String does.

Fourth — printing a path

editor/src/main.rsin Buffer::save

        self.dirty = false;
        self.status = format!("saved {} bytes to {}", self.text.len(), self.path);
        self.status = format!("saved {} bytes to {}", self.text.len(), self.path.display());
        Ok(())
    }

The compiler's note is the whole lesson in one line: call .display()or .to_string_lossy() to safely print paths, as they may contain non-Unicode data. PathBuf refuses to be a {} argument, on purpose, because printing it would need a decision about the bytes that aren't text, and it will not make that decision for you silently. .display() is you making it: show what can be shown, substitute what can't. It is fine here, because a status message is for reading, not for opening.

Fifth — the name, and the code you get to delete

editor/src/main.rsin impl Buffer, fn name — the whole thing

        self.text.line_col(char_index)
    }

    fn name(&self) -> &str {
        self.path
            .trim_end_matches('/')
            .rsplit('/')
            .next()
            .filter(|name| !name.is_empty())
            .unwrap_or(&self.path)
    }
    /// The last part of the path, for the tab. Lossy on purpose: a name that
    /// isn't UTF-8 still gets shown, and this string is never used to open anything.
    fn name(&self) -> String {
        match self.path.file_name() {
            Some(name) => name.to_string_lossy().into_owned(),
            None => self.path.display().to_string(),
        }
    }
}

Lesson 9 wrote seven lines to find the last part of a path: trim a trailing slash, split on the rest, take the first piece, fall back to the whole thing. That was a correct implementation of something std already had — on the type we weren't using. Path::file_namedoes exactly that work, including the trailing slash, and returnsOption<&OsStr>: None when there is no last part, which is what a path like .. or / has.

Two things changed in the signature. It returns String rather than&str, because to_string_lossy may have to build a new string to put the � in — it cannot promise to hand back a borrow of what was there. And the doc comment says lossy on purpose, because it is: this string goes on a tab, and a tab is for eyes. The rule this lesson is really teaching is not "never be lossy"; it is "be lossy at the display, never in the data".

Lesson 10's three name_of_… tests are untouched and still pass. A test that survives a rewrite of the thing it tests is the test earning its place — it pinned the behaviour, not the implementation.

Sixth — the tab row

This edit compiles either waybuffer.name() is a String now, so .to_owned() on it makes a copy of a copy. The compiler accepts it and clippy says nothing, so nothing will tell you it's there — delete the suffix now while you know why.

editor/src/main.rsin fn ui, the tab row

                    let label = if buffer.dirty {
                        format!("• {}", buffer.name())
                    } else {
                        buffer.name().to_owned()
                        buffer.name()
                    };

Seventh — the heading

editor/src/main.rsin fn ui, above the scroll area

            if save_requested && let Err(error) = buffer.save() {
                buffer.status = format!("save failed: {error}");
            }

            ui.heading(&buffer.path);
            ui.heading(buffer.path.display().to_string());
            egui::ScrollArea::vertical().show(ui, |ui| {

The same move as the status message: .display() makes a value that can be printed, and .to_string() makes it a String egui will take.

cargo check is silent now. cargo test is not — the tests build a Buffer by hand, and hand it a String. Two edits.

Eighth — the helper:

editor/src/main.rsin mod tests, buffer_with

    fn buffer_with(text: &str) -> Buffer {
        Buffer {
            path: "test.rs".to_owned(),
            path: PathBuf::from("test.rs"),
            text: Text::new(text),

Ninth — the two tests that set a path:

editor/src/main.rsin mod tests, the two name tests that set a path

    fn name_of_a_nested_path() {
        let mut b = buffer_with("");
        b.path = "src/widgets/main.rs".to_owned();
        b.path = PathBuf::from("src/widgets/main.rs");
        assert_eq!(b.name(), "main.rs");
    }

    #[test]
    fn name_of_a_trailing_slash() {
        let mut b = buffer_with("");
        b.path = "src/".to_owned();
        b.path = PathBuf::from("src/");
        assert_eq!(b.name(), "src");
    }

cargo clippy --all-targets && cargo test
→ silent, test result: ok. 20 passed; 0 failed

Nothing visible has changed. The buffer stores the right type now, but every way ofgetting a path into it still goes through &str. That is stage 3.

The door takes a Path

cargo run -- "$(printf 'caf\xe9.txt')" opens the file instead of panicking.

Buffer::open, Editor::open and open_path all take &str, and main builds its list withenv::args(). Change the signatures to &Path, andmain to the function that doesn't panic. Seven edits.

First — the borrowed half of the pair joins the import:

editor/src/main.rsthe imports at the top

use std::ops::Range;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::sync::Arc;

Second — Buffer::open. The parameter changes, and the two messages that printed it need .display() for the same reason save did.cargo fmt will split the last arm across four lines once it grows; type it that way:

editor/src/main.rsin impl Buffer, fn open — the signature and the match

    /// Open a file into a new editor. A file that doesn't exist yet is not an
    /// error — it's a new file, and starts empty.
    fn open(path: &str) -> Self {
        let (text, status) = match fs::read_to_string(path) {
            Ok(contents) => (contents, String::new()),
            Err(error) if error.kind() == io::ErrorKind::NotFound => {
                (String::new(), format!("new file: {path}"))
            }
            Err(error) => (String::new(), format!("could not open {path}: {error}")),
        };
    fn open(path: &Path) -> Self {
        let (text, status) = match fs::read_to_string(path) {
            Ok(contents) => (contents, String::new()),
            Err(error) if error.kind() == io::ErrorKind::NotFound => {
                (String::new(), format!("new file: {}", path.display()))
            }
            Err(error) => (
                String::new(),
                format!("could not open {}: {error}", path.display()),
            ),
        };
        Self {
            path: PathBuf::from(path),

Nothing else in the body changes. fs::read_to_string(path) accepted a&str before and accepts a &Path now — openits signature and you'll see why: the parameter is P: AsRef<Path>, "anything that can be viewed as a path". It was generic all along. We were handing it the narrow type.

Third — Editor::open:

editor/src/main.rsin impl Editor, fn open

        self.active = self.active.min(self.buffers.len() - 1);
    }

    fn open(paths: &[String]) -> Self {
    fn open(paths: &[PathBuf]) -> Self {
        let buffers = paths.iter().map(|p| Buffer::open(p)).collect();

Fourth — open_path:

editor/src/main.rsin impl Editor, fn open_path

    /// Open a file into a new buffer, and switch to it.
    fn open_path(&mut self, path: &str) {
    fn open_path(&mut self, path: &Path) {
        self.buffers.push(Buffer::open(path));

Fifth — the picker still holds Strings until stage 4, so its call needs a bridge:

editor/src/main.rsin fn pick, the bottom

        if let Some(name) = chosen {
            self.open_path(&name);
            self.open_path(Path::new(&name));
            self.picker = None;

Path::new does not copy anything. It is a view: the same bytes, looked at as a path. This line is a stopgap and stage 5 replaces it.

Sixth — the function that doesn't panic

editor/src/main.rsin fn main

fn main() -> eframe::Result {
    let paths: Vec<String> = env::args().skip(1).collect();
    let paths: Vec<PathBuf> = env::args_os().skip(1).map(PathBuf::from).collect();
    let paths = if paths.is_empty() {
        vec!["scratch.md".to_owned()]
        vec![PathBuf::from("scratch.md")]
    } else {
        paths
    };

env::args_os is the same iterator with the promise removed: it yieldsOsString, so an argument that isn't UTF-8 is just an argument..map(PathBuf::from) turns each one into the type Editor::openwants.

Look at that map next to the one in Editor::open:.map(|p| Buffer::open(p)). Both pass a function to map. One wraps it in a closure that does nothing but call it; the other passes the function itself. PathBuf::from without parentheses is a value — the function, not its result — and map is happy to take it. The closure form isn't wrong; it's just a longer way of writing the same thing.

Seventh — the test helper that builds an editor from names:

editor/src/main.rsin mod tests, editor_with

    fn editor_with(names: &[&str]) -> Editor {
        let paths: Vec<String> = names.iter().map(|n| (*n).to_owned()).collect();
        let paths: Vec<PathBuf> = names.iter().map(PathBuf::from).collect();
        Editor::open(&paths)
    }

cargo clippy --all-targets && cargo test
→ silent, test result: ok. 20 passed; 0 failed

cargo run -- "$(printf 'caf\xe9.txt')"
→ a window, with the tab and heading reading caf�.txt
→ type something, Ctrl+S: saved … bytes to caf�.txt
→ ls | cat -v still shows one cafM-i.txt, and it has your text in it

The � on the tab is .display() doing its job — showing you the name — while the PathBuf underneath still holds the real bytes, which is why the save went to the right file. Display lossy, data exact.

The picker, though, is still broken: it lists Strings. Stage 4.

The names stop being strings

cargo test reports twenty-one passing, and clickingcaf�.txt in the picker opens the file.

This time, write the test before the fix, so you see it fail for the right reason.Nine edits.

First — a new import, for the type the test is about to need:

editor/src/main.rsthe imports at the top

use std::env;
use std::ffi::OsString;
use std::fs;

Second — the test

editor/src/main.rsin mod tests, at the end

        assert!(list_dir("no/such/directory").is_err());
    }

    #[test]
    fn list_dir_names_files_that_exist() {
        use std::os::unix::ffi::OsStringExt;

        let dir = env::temp_dir().join("editor-lesson-17");
        fs::create_dir_all(&dir).unwrap();
        let name = OsString::from_vec(b"caf\xe9.txt".to_vec());
        fs::write(dir.join(&name), "").unwrap();

        for listed in list_dir(&dir).unwrap() {
            assert!(
                dir.join(&listed).exists(),
                "{listed:?} was listed but does not exist"
            );
        }
    }
}

Read it as a sentence: every name list_dir returns must be the name of something that exists. That is the contract the lossy line broke.

The setup is the interesting part. env::temp_dir() is/tmp on this machine; create_dir_all makes the directory and does nothing if it is already there, so the test can run twice.b"caf\xe9.txt" is a byte string — a &[u8], not a&str — and that matters, because the ordinary"caf\xe9.txt" would not compile: Rust will not let you write invalid UTF-8 into a string literal any more than it will let you store it in a String.OsString::from_vec takes those bytes as they are.

from_vec is not on OsString by default. It comes from the trait OsStringExt, and the use inside the function brings it into scope. That's how Rust attaches platform-specific methods: the trait only exists on Unix, so the method is only callable where "an OsString is aVec<u8>" is actually true. This test is Linux-only. So is the bug.

{listed:?} in the message is the Debugformat, which is what you use when a type refuses {}. For a name that isn't UTF-8 it prints the replacement character, which is why the failure below is readable at all.

Now run it:

error[E0308]: mismatched types
   --> src/main.rs:715:32
    |
715 |         for listed in list_dir(&dir).unwrap() {
    |                       -------- ^^^^ expected `&str`, found `&PathBuf`

The test cannot even be compiled, because list_dir wants a&str and a directory from temp_dir is aPathBuf. That is the signature admitting it was wrong before the body gets a chance to.

Third — the parameter:

editor/src/main.rsfn list_dir, the signature

/// The names in a directory, sorted. Not the paths — just the names.
fn list_dir(dir: &str) -> io::Result<Vec<String>> {
fn list_dir(dir: &Path) -> io::Result<Vec<String>> {
    let mut names = Vec::new();

cargo test again, and the compiler lists four callers that hand it a string literal — one in fn ui, three in the tests. Path::new at each. Fourth:

editor/src/main.rsin fn ui, the Ctrl+O handler

            if open_requested {
                match list_dir(".") {
                match list_dir(Path::new(".")) {
                    Ok(names) => self.picker = Some(names),

Fifth — the three tests from lesson 16:

editor/src/main.rsin mod tests, the three list_dir tests from lesson 16

    fn list_dir_finds_the_crate_manifest() {
        let names = list_dir(".").unwrap();
        let names = list_dir(Path::new(".")).unwrap();
        assert!(names.contains(&"Cargo.toml".to_owned()));
    }

    #[test]
    fn list_dir_sorts_its_names() {
        let names = list_dir(".").unwrap();
        let names = list_dir(Path::new(".")).unwrap();
        let mut sorted = names.clone();
        sorted.sort();
        assert_eq!(names, sorted);
    }

    #[test]
    fn list_dir_reports_a_directory_that_is_not_there() {
        assert!(list_dir("no/such/directory").is_err());
        assert!(list_dir(Path::new("no/such/directory")).is_err());
    }

It compiles. Run it.

thread 'tests::list_dir_names_files_that_exist' panicked at src/main.rs:716:13:
"caf�.txt" was listed but does not exist

test result: FAILED. 20 passed; 1 failed

There it is: the exact lie from stage 1, caught by a test instead of by you. The name that was listed is not the name on disk.

Sixth — keep what the filesystem gave you

editor/src/main.rsfn list_dir — the return type and the push

/// The names in a directory, sorted. Not the paths — just the names.
fn list_dir(dir: &Path) -> io::Result<Vec<String>> {
fn list_dir(dir: &Path) -> io::Result<Vec<OsString>> {
    let mut names = Vec::new();
    for entry in fs::read_dir(dir)? {
        names.push(entry?.file_name().to_string_lossy().into_owned());
        names.push(entry?.file_name());
    }
    names.sort();
    Ok(names)
}

entry.file_name() already returned an OsString; lesson 16 converted it and threw the bytes away. Now we keep it. That is the whole fix — one deleted method call — and the rest of this stage is the compiler walking you to the places that assumed a String.

Seventh — the field:

editor/src/main.rsthe Editor struct

    /// The directory listing on screen, if the picker is open.
    picker: Option<Vec<String>>,
    picker: Option<Vec<OsString>>,
    position: String,

Eighth — the label:

editor/src/main.rsin fn pick, inside the modal

                for name in names {
                    if ui.button(name).clicked() {
                    if ui.button(name.to_string_lossy()).clicked() {
                        chosen = Some(name.clone());
                    }
                }

The lossy conversion is back, and it has moved to the only place it is harmless: the button's text. The name that goes into chosen, and from there toopen_path, is the OsString with every byte intact. Compare this line with the one you deleted in edit six. Same call, different side of the boundary.

Ninth — the test that looked for Cargo.toml as a String:

editor/src/main.rsin mod tests, list_dir_finds_the_crate_manifest

    fn list_dir_finds_the_crate_manifest() {
        let names = list_dir(Path::new(".")).unwrap();
        assert!(names.contains(&"Cargo.toml".to_owned()));
        assert!(names.contains(&OsString::from("Cargo.toml")));
    }

cargo clippy --all-targets && cargo test
→ silent, test result: ok. 21 passed; 0 failed

cargo run -- a.rs
→ Ctrl+O, click caf�.txt
→ a second tab, with the text you saved in stage 3, and an empty status line

Same click as stage 1; different file. The label still says �, because it must — but the click carries the real name now.

A directory is not a file

Clicking src/ in the picker shows what is inside it,.. goes back up, and clicking a file still opens it.

Click src in the picker right now and you get a tab for a directory: it reads as empty, the status bar says could not open src: Is a directory, and saving would fail the same way. The listing knows names; it doesn't know what they are. This stage gives it a type that does, and a directory to be relative to.Eight edits.

First — two structs, and a list_dir that fills one of them

editor/src/main.rsabove the Pending enum — list_dir, with two structs in front of it

/// The names in a directory, sorted. Not the paths — just the names.
fn list_dir(dir: &Path) -> io::Result<Vec<OsString>> {
    let mut names = Vec::new();
    for entry in fs::read_dir(dir)? {
        names.push(entry?.file_name());
    }
    names.sort();
    Ok(names)
}
/// One thing in a directory: its name, and whether it is a directory itself.
struct Entry {
    name: OsString,
    is_dir: bool,
}

/// What the picker is showing: a directory, and what is in it.
struct Picker {
    dir: PathBuf,
    entries: Vec<Entry>,
}

/// What is in a directory, sorted by name. Not the paths — just the names.
fn list_dir(dir: &Path) -> io::Result<Vec<Entry>> {
    let mut entries = Vec::new();
    for entry in fs::read_dir(dir)? {
        let entry = entry?;
        entries.push(Entry {
            name: entry.file_name(),
            is_dir: entry.file_type()?.is_dir(),
        });
    }
    entries.sort_by(|a, b| a.name.cmp(&b.name));
    Ok(entries)
}

/// Something the editor is waiting for permission to do.

An Entry is one thing in a directory: its name, and whether it is a directory itself. A Picker is what is on screen: which directory, and its entries. Lesson 16's picker only had the second half, because the directory was always "."; the moment you can navigate, the listing needs to remember where it is.

Three things to notice in the new list_dir. let entry = entry?;unwraps once, because the entry is now used twice. entry.file_type()? is athird question mark — asking what kind of thing an entry is can fail separately from reading the directory and from reading the entry, and the type says so. And sort_by takes a closure that compares two entries, since aVec<Entry> has no order of its own the way Vec<OsString>did.

file_type() describes the entry itself, not what it points at. A symbolic link to a directory reports as a link, so it will show without the / and open as a file. fs::metadata(dir.join(&name))follows links, at the price of one more system call per entry. Left as is — it's a real trade-off, not an oversight, and you now know where to make it.

Second — the field holds a Picker:

editor/src/main.rsthe Editor struct

    /// The directory listing on screen, if the picker is open.
    picker: Option<Vec<OsString>>,
    picker: Option<Picker>,
    position: String,

Third — one place that lists a directory and puts it on screen, or says why it can't:

editor/src/main.rsin impl Editor, after fn open_path

    fn open_path(&mut self, path: &Path) {
        self.buffers.push(Buffer::open(path));
        self.active = self.buffers.len() - 1;
    }

    /// Show a directory in the picker, or say why it can't be shown.
    fn browse(&mut self, dir: PathBuf) {
        match list_dir(&dir) {
            Ok(entries) => self.picker = Some(Picker { dir, entries }),
            Err(error) => {
                self.buffers[self.active].status =
                    format!("cannot read {}: {error}", dir.display());
            }
        }
    }

    /// Close a buffer, keeping `active` pointing at the file it was on.

This is the match that used to live in the Ctrl+Ohandler, made into a method so the picker can call it too. dir is taken by value — a PathBuf, not a &Path — because on success it moves into the Picker and stays there. Passing a borrow would mean cloning it a line later.

Fourth — the shortcut starts from where the editor is running:

editor/src/main.rsin fn ui, the Ctrl+O handler

            let open_requested = ui.input(|i| i.modifiers.command && i.key_pressed(egui::Key::O));
            if open_requested {
                match list_dir(Path::new(".")) {
                    Ok(names) => self.picker = Some(names),
                    Err(error) => {
                        self.buffers[self.active].status =
                            format!("cannot read this directory: {error}");
                    }
                }
            }
            if open_requested {
                let dir = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
                self.browse(dir);
            }

            let close_requested = ui.input(|i| i.modifiers.command && i.key_pressed(egui::Key::W));

env::current_dir() can fail, in theory — the directory could have been deleted under you. If it does, "." makes read_dir fail in the same way a line later, and browse puts that in the status bar. One fallback, one message.

editor/src/main.rsin fn pick, the modal

        if let Some(names) = &self.picker {
        if let Some(picker) = &self.picker {
            egui::Modal::new(egui::Id::new("picker")).show(ui.ctx(), |ui| {
                ui.heading("Open file");
                ui.heading(picker.dir.display().to_string());
                ui.add_space(8.0);
                for name in names {
                    if ui.button(name.to_string_lossy()).clicked() {
                        chosen = Some(name.clone());
                    }
                }
                if let Some(parent) = picker.dir.parent()
                    && ui.button("..").clicked()
                {
                    chosen = Some((parent.to_owned(), true));
                }
                for entry in &picker.entries {
                    let label = if entry.is_dir {
                        format!("{}/", entry.name.to_string_lossy())
                    } else {
                        entry.name.to_string_lossy().into_owned()
                    };
                    if ui.button(label).clicked() {
                        chosen = Some((picker.dir.join(&entry.name), entry.is_dir));
                    }
                }
                ui.add_space(8.0);
                if ui.button("Cancel").clicked() {

Three changes. The heading is the directory, so you can see where you are. A.. button appears when the directory has a parent — parent()returns an Option, and for / it is None, so the root has no way up. And each entry's button records a pair: the full path, built withpicker.dir.join(&entry.name), and whether it is a directory.

That join is what Path::new(&name) in stage 3 was standing in for. A name is relative to the directory it was listed from; now that the picker knows which directory that is, it can build the real path instead of hoping the process is running in the right place.

chosen holds a tuple, (PathBuf, bool). A struct would be ceremony for a value that lives for twenty lines and is destructured once.

Sixth — act on the pair:

editor/src/main.rsin fn pick, the bottom

        if let Some(name) = chosen {
            self.open_path(Path::new(&name));
            self.picker = None;
        if let Some((path, is_dir)) = chosen {
            if is_dir {
                self.browse(path);
            } else {
                self.open_path(&path);
                self.picker = None;
            }
        } else if cancelled {
            self.picker = None;
        }
    }

A directory re-lists — browse replaces the picker with a new one and the modal stays open, showing the new directory next frame. A file opens and closes the picker, as before. Same decide-then-act shape as lesson 16: the closure records, the code after it moves.

Seventh — the tests that read the listing. Two are adjusted for Entry, and a new one between them checks that src and Cargo.toml are told apart:

editor/src/main.rsin mod tests, the list_dir tests — two change, one is new

    fn list_dir_finds_the_crate_manifest() {
        let names = list_dir(Path::new(".")).unwrap();
        assert!(names.contains(&OsString::from("Cargo.toml")));
        let entries = list_dir(Path::new(".")).unwrap();
        assert!(entries.iter().any(|entry| entry.name == "Cargo.toml"));
    }

    #[test]
    fn list_dir_sorts_its_names() {
        let names = list_dir(Path::new(".")).unwrap();
        let entries = list_dir(Path::new(".")).unwrap();
        let names: Vec<&OsString> = entries.iter().map(|entry| &entry.name).collect();
        let mut sorted = names.clone();
        sorted.sort();
        assert_eq!(names, sorted);
    }

    #[test]
    fn list_dir_knows_a_directory_from_a_file() {
        let entries = list_dir(Path::new(".")).unwrap();
        let src = entries.iter().find(|e| e.name == "src").unwrap();
        let toml = entries.iter().find(|e| e.name == "Cargo.toml").unwrap();
        assert!(src.is_dir);
        assert!(!toml.is_dir);
    }

    #[test]
    fn list_dir_reports_a_directory_that_is_not_there() {

iter().any(...) and iter().find(...) are the questionscontains used to answer, asked through a closure because anEntry has to be looked inside. The sort test collects the names first so it can compare them the way it did before.

Eighth — the test from stage 4 reads the name out of the entry:

editor/src/main.rsin mod tests, list_dir_names_files_that_exist — the assert

        for listed in list_dir(&dir).unwrap() {
            assert!(
                dir.join(&listed).exists(),
                "{listed:?} was listed but does not exist"
                dir.join(&listed.name).exists(),
                "{:?} was listed but does not exist",
                listed.name
            );
        }

cargo clippy --all-targets && cargo test
→ silent, test result: ok. 22 passed; 0 failed

cargo run -- a.rs
→ Ctrl+O: the heading is the full path of editor, and src/ and target/ have slashes
→ click src/: the heading ends in /src, and the list is .. and main.rs
→ click ..: back where you started
→ click Cargo.toml: it opens, and the picker closes

One thing this gets wrong, on purposeClick a directory you cannot read — /root, from .. up to/ — and browse writes the error to the status bar, which is behind the modal. The picker stays where it was, so nothing is lost, but the message is invisible until you cancel. Putting the error inside the picker means givingPicker a place to hold it. It is a small change, and it is yours if it bothers you.
Remove the experimentThe file from stage 1 is still in editor/, and it will sit in the picker forever. rm "$(printf 'caf\xe9.txt')" from that directory. The test makes its own copy under /tmp and does not need this one.

Where you ended up

769 lines, twenty-two tests green. Two new structs, one new method, two rewritten ones, and not a String left anywhere a filename is stored.

editor/src/main.rsthe imports at the top

use eframe::egui;
use egui::text::{ByteIndex, CharIndex, LayoutJob};
use ropey::Rope;
use std::char;
use std::env;
use std::ffi::OsString;
use std::fs;
use std::io;
use std::ops::Range;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Instant;
Between these: about 185 lines, untouchedThe colours, scan, highlight, Text and itsTextBuffer impl are exactly as lesson 11 left them.

editor/src/main.rsabove the Pending enum

/// One thing in a directory: its name, and whether it is a directory itself.
struct Entry {
    name: OsString,
    is_dir: bool,
}

/// What the picker is showing: a directory, and what is in it.
struct Picker {
    dir: PathBuf,
    entries: Vec<Entry>,
}

/// What is in a directory, sorted by name. Not the paths — just the names.
fn list_dir(dir: &Path) -> io::Result<Vec<Entry>> {
    let mut entries = Vec::new();
    for entry in fs::read_dir(dir)? {
        let entry = entry?;
        entries.push(Entry {
            name: entry.file_name(),
            is_dir: entry.file_type()?.is_dir(),
        });
    }
    entries.sort_by(|a, b| a.name.cmp(&b.name));
    Ok(entries)
}
Between these: the Pending enum, unchanged

editor/src/main.rsstruct Buffer, and the two methods that changed

/// One open file.
struct Buffer {
    path: PathBuf,
    text: Text,
    status: String,
    /// Does this buffer hold edits that aren't on disk?
    dirty: bool,
}

impl Buffer {
    /// Open a file into a new editor. A file that doesn't exist yet is not an
    /// error — it's a new file, and starts empty.
    fn open(path: &Path) -> Self {
        let (text, status) = match fs::read_to_string(path) {
            Ok(contents) => (contents, String::new()),
            Err(error) if error.kind() == io::ErrorKind::NotFound => {
                (String::new(), format!("new file: {}", path.display()))
            }
            Err(error) => (
                String::new(),
                format!("could not open {}: {error}", path.display()),
            ),
        };
        Self {
            path: PathBuf::from(path),
            text: Text::new(&text),
            status,
            dirty: false,
        }
    }
    /// Write the buffer back to the file it came from.
    fn save(&mut self) -> io::Result<()> {
        fs::write(&self.path, self.text.as_str())?;
        self.text.mark_saved();
        self.dirty = false;
        self.status = format!("saved {} bytes to {}", self.text.len(), self.path.display());
        Ok(())
    }
fn line_col, unchanged — 4 lines
    /// The last part of the path, for the tab. Lossy on purpose: a name that
    /// isn't UTF-8 still gets shown, and this string is never used to open anything.
    fn name(&self) -> String {
        match self.path.file_name() {
            Some(name) => name.to_string_lossy().into_owned(),
            None => self.path.display().to_string(),
        }
    }
}

editor/src/main.rsthe Editor struct — one field changed

struct Editor {
    buffers: Vec<Buffer>,
    active: usize,
    /// An action held up until the question on screen is answered.
    confirm: Option<Pending>,
    /// Set once the user has answered "quit anyway", so the close we send
    /// ourselves isn't mistaken for a fresh request and cancelled again.
    quitting: bool,
    /// The directory listing on screen, if the picker is open.
    picker: Option<Picker>,
    position: String,
    frame_ms: f32,
    line_col_us: f32,
}
Between these: unsaved, unchanged

editor/src/main.rsin impl Editor — open_path and browse

    /// Open a file into a new buffer, and switch to it.
    fn open_path(&mut self, path: &Path) {
        self.buffers.push(Buffer::open(path));
        self.active = self.buffers.len() - 1;
    }

    /// Show a directory in the picker, or say why it can't be shown.
    fn browse(&mut self, dir: PathBuf) {
        match list_dir(&dir) {
            Ok(entries) => self.picker = Some(Picker { dir, entries }),
            Err(error) => {
                self.buffers[self.active].status =
                    format!("cannot read {}: {error}", dir.display());
            }
        }
    }
Between these: close, open, ask — about 60 lines, unchanged

editor/src/main.rsin impl Editor — fn pick

    /// Draw the file picker, and act on what was clicked.
    fn pick(&mut self, ui: &egui::Ui) {
        let mut chosen = None;
        let mut cancelled = false;

        if let Some(picker) = &self.picker {
            egui::Modal::new(egui::Id::new("picker")).show(ui.ctx(), |ui| {
                ui.heading(picker.dir.display().to_string());
                ui.add_space(8.0);
                if let Some(parent) = picker.dir.parent()
                    && ui.button("..").clicked()
                {
                    chosen = Some((parent.to_owned(), true));
                }
                for entry in &picker.entries {
                    let label = if entry.is_dir {
                        format!("{}/", entry.name.to_string_lossy())
                    } else {
                        entry.name.to_string_lossy().into_owned()
                    };
                    if ui.button(label).clicked() {
                        chosen = Some((picker.dir.join(&entry.name), entry.is_dir));
                    }
                }
                ui.add_space(8.0);
                if ui.button("Cancel").clicked() {
                    cancelled = true;
                }
            });
        }

        if let Some((path, is_dir)) = chosen {
            if is_dir {
                self.browse(path);
            } else {
                self.open_path(&path);
                self.picker = None;
            }
        } else if cancelled {
            self.picker = None;
        }
    }
Between these: save_for, carry_out, and the start of fn ui — about 50 lines, unchanged

editor/src/main.rsin fn ui — two lines changed, the rest elided

            let open_requested = ui.input(|i| i.modifiers.command && i.key_pressed(egui::Key::O));
            if open_requested {
                let dir = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
                self.browse(dir);
            }
the close shortcut, the tab row and the save shortcut, unchanged — about 30 lines
            ui.heading(buffer.path.display().to_string());

editor/src/main.rsfn main — the first lines

fn main() -> eframe::Result {
    let paths: Vec<PathBuf> = env::args_os().skip(1).map(PathBuf::from).collect();
    let paths = if paths.is_empty() {
        vec![PathBuf::from("scratch.md")]
    } else {
        paths
    };

editor/src/main.rsin mod tests — the helpers and the list_dir tests

    fn buffer_with(text: &str) -> Buffer {
        Buffer {
            path: PathBuf::from("test.rs"),
            text: Text::new(text),
            status: String::new(),
            dirty: false,
        }
    }
the line_col, name and scan tests, unchanged apart from two PathBuf::from — about 55 lines
    fn editor_with(names: &[&str]) -> Editor {
        let paths: Vec<PathBuf> = names.iter().map(PathBuf::from).collect();
        Editor::open(&paths)
    }
the close and unsaved tests, unchanged — about 50 lines
    #[test]
    fn list_dir_finds_the_crate_manifest() {
        let entries = list_dir(Path::new(".")).unwrap();
        assert!(entries.iter().any(|entry| entry.name == "Cargo.toml"));
    }

    #[test]
    fn list_dir_sorts_its_names() {
        let entries = list_dir(Path::new(".")).unwrap();
        let names: Vec<&OsString> = entries.iter().map(|entry| &entry.name).collect();
        let mut sorted = names.clone();
        sorted.sort();
        assert_eq!(names, sorted);
    }

    #[test]
    fn list_dir_knows_a_directory_from_a_file() {
        let entries = list_dir(Path::new(".")).unwrap();
        let src = entries.iter().find(|e| e.name == "src").unwrap();
        let toml = entries.iter().find(|e| e.name == "Cargo.toml").unwrap();
        assert!(src.is_dir);
        assert!(!toml.is_dir);
    }

    #[test]
    fn list_dir_reports_a_directory_that_is_not_there() {
        assert!(list_dir(Path::new("no/such/directory")).is_err());
    }

    #[test]
    fn list_dir_names_files_that_exist() {
        use std::os::unix::ffi::OsStringExt;

        let dir = env::temp_dir().join("editor-lesson-17");
        fs::create_dir_all(&dir).unwrap();
        let name = OsString::from_vec(b"caf\xe9.txt".to_vec());
        fs::write(dir.join(&name), "").unwrap();

        for listed in list_dir(&dir).unwrap() {
            assert!(
                dir.join(&listed.name).exists(),
                "{:?} was listed but does not exist",
                listed.name
            );
        }
    }
}

Check yourself

Why does env::args() panic on caf\xe9.txt when env::args_os() does not?

  • args() promised Strings, and this argument cannot be one
  • args() reads the arguments before the process has a locale set
  • args_os() replaces the invalid byte with U+FFFD silently
  • args() is only meant for arguments that name existing files

A String is valid UTF-8 by definition. args()yields Strings, so an argument that isn't UTF-8 has no honest value to yield — and its documentation says it panics. args_os() yieldsOsString, which promises nothing about the bytes.↑ Review: the command line refuses it outright (stage 1)

Why is it fine for Buffer::name to be lossy when it was a bug for list_dir to be?

  • A tab label is only read; a listed name is used to open the file
  • By the time name runs, the file has already been read
  • Tab labels are short enough that a substituted byte is not noticeable
  • Buffer::name is only called from tests, never from the UI

The rule is where the loss happens, not whether. A name that has had a byte replaced no longer names the file — harmless on a button, fatal in a path. ThePathBuf underneath the tab still holds the real bytes.↑ Review: the name, and the code you get to delete (stage 2)

After changing Buffer.path to PathBuf, what did cargo check do?

  • Listed every place that treated the path as text, with fixes for two
  • Compiled, because PathBuf and String convert implicitly
  • Reported one error at the field and stopped until it was fixed
  • Warned about the old name implementation and kept going

Four errors at once, each at a use site, two with the exact method to call. Changing a type is asking the compiler to audit every use of it — which is why the stage said to run cargo check before fixing anything.↑ Review: the constructor (stage 2)

There are three ?s in the finished list_dir. What does the third one handle?

  • Asking the filesystem what kind of thing an entry is
  • Sorting the entries, which can fail on names that don't compare
  • Reading the entry's name, which can fail if it isn't UTF-8
  • Joining the name onto the directory to make the full path

entry.file_type() is a separate call with its ownio::Result. Opening the directory, reading each entry, and asking each entry's type are three questions to the filesystem, and each can be answered "no".↑ Review: two structs, and a list_dir that fills one of them (stage 5)

Read this before lesson 18

Primary source:std::path — the module page, not a tutorial about it. It is short. Read the opening section onPath versus PathBuf, then skim the method list onPath and notice how many of them return Option.

Then two minutes onOsString— specifically the paragraph on "Conversions", which is a list of the decisions this lesson made you take by hand.