Deryks _dev

Full-Stack Web Developer & Technical Leader

<< Back / Lesson 02 / Text Editor GUI in Rust / ~25 minutes

Opening a Real File

Four stages, ending with your editor displaying its own source code.

Lesson 1 gave you a window with a String in it. That string was a greeting you typed into the source. An editor that can only edit text it invented isn't an editor — so this lesson reads a file off disk.

Reading a file can fail. The file might not exist, or you might not be allowed to read it, or it might not be valid UTF-8. Rust will not let you pretend otherwise, and that refusal is the entire lesson.

In Rust, failure is a value, not an event.
Reading a file doesn't hand you a String — it hands you a box that contains either a String or an error, and you cannot get at the contents without saying what to do about both.

Where you're starting

Your editor/src/main.rs should be the 40 lines you finished lesson 1 with. If you're not sure, check it againstlesson 1's checkpoint.

adding   removing   existing code, shown only for context

Reach for a file, and get refused

The compiler rejects your code — and tells you exactly what's wrong.

Two edits. First, bring the filesystem module into scope:

editor/src/main.rstop of the file

use eframe::egui;
use std::fs;

std is Rust's standard library, always available, nothing to install.fs is its filesystem module. A use line doesn't import code so much as shorten a name — without it you'd writestd::fs::read_to_string(…) every time.

Second, swap the hardcoded greeting for a file read:

editor/src/main.rsin Default::default

        Self {
            text: "Hello, editor.\n".to_owned(),
            text: fs::read_to_string("src/main.rs"),
        }

cargo check

It doesn't compile. Verbatim:

error[E0308]: mismatched types
  --> src/main.rs:11:19
   |
11 |             text: fs::read_to_string("src/main.rs"),
   |                   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected `String`, found `Result<String, Error>`
   |
   = note: expected struct `String`
                found enum `std::result::Result<String, std::io::Error>`
help: consider using `Result::expect` to unwrap the `Result` value, panicking if the value is a `Result::Err`
   |
11 |             text: fs::read_to_string("src/main.rs").expect("REASON"),
   |                                                    +++++++++++++++++
Before reading on: your field wants a String, and the function returned Result<String, Error>. What is the language forcing you to do?

Account for the failure. The String you want is inside theResult, and the only way to get it out is to also say what happens when there isn't one.

This is the central move. In many languages, reading a missing file throws an exception — an invisible second exit from the function that you can ignore until it surprises you in production. Rust has no exceptions. Failure comes back as an ordinaryreturn value, of type Result, and the type system won't let you mistake it for the success case.

Result<String, Error> reads as: either a String, or an Error. It's an enum — a type that is exactly one of a fixed set of shapes. This one has two:

VariantMeansHolds
Ok(v)It workedThe value you wanted — here, the file's contents
Err(e)It didn'tWhy not — here, an io::Error

Note what the compiler suggested: .expect("REASON"). That compiles, and it's wrong here — stage 3 shows what it actually does. Thehelp: line is usually right, but it optimises for "make this compile", not "make this good."

Say what to do about both

Your editor opens, showing its own source code.

Replace the whole body of default:

editor/src/main.rsin Default::default

    fn default() -> Self {
        Self {
            text: fs::read_to_string("src/main.rs"),
        }
        let text = match fs::read_to_string("src/main.rs") {
            Ok(contents) => contents,
            Err(error) => format!("could not open src/main.rs\n\n{error}"),
        };
        Self { text }
    }

match takes a value apart. You list every shape it could have, and what to produce for each. It's exhaustive: delete the Err arm and the compiler rejects the program, because you'd have left a possible shape unhandled. That exhaustiveness is why Rust programs rarely die of an unconsidered case.

Read the arms as: if it's Ok, name the contentscontents and use them; if it's Err, name the problemerror and build a message instead. Either way the matchevaluates to a String, which is what text needs.

format! is println!'s sibling — same macro, but it returns the string instead of printing it. {error} drops the variable straight into the text.

Self { text } is shorthand. When a variable's name is already the field's name, you write it once instead of text: text. Rust leans on this constantly.

cargo run
→ your editor, showing the source code of your editor

Take a second with thisYou are looking at main.rs inside a program compiled frommain.rs. Scroll down in the window and you'll find the veryfs::read_to_string call that put it there. Edit the text and the buffer changes — it won't save yet, that's lesson 3.
Two things we're deliberately leaving crookedThe path "src/main.rs" is now written twice, and default() is doing file I/O, which is not what "default" means to a Rust reader. Both are fixed in stage 4 — noticing them now is the point.

Break it on purpose

Point at a file that doesn't exist. The editor still opens.

Misspell the filename — mian instead of main:

editor/src/main.rsin Default::default

        let text = match fs::read_to_string("src/main.rs") {
        let text = match fs::read_to_string("src/mian.rs") {

cargo run
→ a window containing:

could not open src/main.rs

No such file or directory (os error 2)

No crash. The Err arm ran, produced a String like any other, and the program carried on. Your editor degraded instead of dying — which for a tool someone else might run is the difference between "handled that badly" and "handled that".

Now read that message againIt says could not open src/main.rs. You asked it to opensrc/mian.rs. The message names a file that was never involved — because the path is written twice and you only changed one of them. This is a small bug you just shipped, and a preview of a large category: a duplicated literal drifts, and the copy that drifts is usually the one in the error message, where it does the most damage and gets noticed the least. Stage 4 makes it impossible.

What the compiler suggested instead

Back in stage 1, rustc offered you .expect("REASON"). It has a shorter sibling, .unwrap(). Both mean: give me theString, and if it's an Err, kill the program. Had you written fs::read_to_string("src/mian.rs").unwrap(), this is what you'd have got instead of a window:

thread 'main' panicked at src/main.rs:10:38:
called `Result::unwrap()` on an `Err` value: Os { code: 2, kind: NotFound,
message: "No such file or directory" }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

That's a panic: the program aborts immediately. It's one line of code instead of four, which is exactly why it's tempting and why it's everywhere in tutorials. Your mission says no unwrap() in the hot path, and this is why — a text editor that vanishes because a path was mistyped is not a text editor.

unwrap() is not forbidden, it's a claim. It says "this cannot fail, and I accept a crash if I'm wrong." That's fine in a test or a throwaway script. In code a reviewer is reading as a work sample, every unwrap() is a claim you'd better be able to defend.

Put it back before moving onChange src/mian.rs back to src/main.rs. Stage 4 builds on the working version, and it's easy to carry the typo forward and spend ten minutes wondering why the next stage looks broken.

Give it a name

Same behaviour, but the code now says what it does.

Two edits. default() is the wrong home for this: a Rust reader expectsDefault to hand back a cheap, obvious starting value, not to touch the disk. And the path is written twice, so the two copies can drift apart. Both problems disappear if the file-opening gets a name and a parameter.

First edit — replace the entire impl Default block:

editor/src/main.rsreplacing impl Default for Editor

impl Default for Editor {
    fn default() -> Self {
        let text = match fs::read_to_string("src/main.rs") {
            Ok(contents) => contents,
            Err(error) => format!("could not open src/main.rs\n\n{error}"),
        };
        Self { text }
    }
}
impl Editor {
    /// Open a file into a new editor. A failure becomes the buffer contents,
    /// so the window always opens.
    fn open(path: &str) -> Self {
        let text = match fs::read_to_string(path) {
            Ok(contents) => contents,
            Err(error) => format!("could not open {path}\n\n{error}"),
        };
        Self { text }
    }
}

impl Editor — no for. Lesson 1's impl Default for Editor was fulfilling somebody else's contract. This one just attaches a function of your own to your own type. Same keyword, two different jobs, and thefor is the tell.

The parameter is &str, not String — a borrowed view of text, because open only needs to read the path, not own it. That's the same distinction fromlesson 1, stage 4, now paying rent: take the borrowed type and callers can pass a literal without allocating anything.

Those /// lines are a doc comment. Not decoration — cargo doc --open turns them into real documentation, and a reviewer will look for them.

Second edit — Editor::default() no longer exists, so mainhas to call the new name:

editor/src/main.rsat the bottom of main

    eframe::run_native(
        "editor",
        options,
        Box::new(|_cc| Ok(Box::new(Editor::default()))),
        Box::new(|_cc| Ok(Box::new(Editor::open("src/main.rs")))),
    )

cargo run
→ identical to stage 2 — and that's the point
Then: cargo fmt && cargo clippy — both should be silent.

The path is now in exactly one placeBoth the read and the error message use path. Change the argument inmain and both follow. That's not tidiness for its own sake — it's the fix for the bug you watched happen instage 3, where the message named the wrong file. Misspell the argument now and the message reports the misspelling, because there is no second copy left to disagree with it.

Relative to what? cargo run starts your program with the working directory set to the crate root — editor/ — so"src/main.rs" resolves. Run the binary straight out oftarget/debug/ instead and it won't, because the working directory is wherever you were standing. Lesson 3 takes the path from the command line and the question stops mattering.

Where you ended up

45 lines. Yours should match this after cargo fmt.

editor/src/main.rs

use eframe::egui;
use std::fs;

struct Editor {
    text: String,
}

impl Editor {
    /// Open a file into a new editor. A failure becomes the buffer contents,
    /// so the window always opens.
    fn open(path: &str) -> Self {
        let text = match fs::read_to_string(path) {
            Ok(contents) => contents,
            Err(error) => format!("could not open {path}\n\n{error}"),
        };
        Self { text }
    }
}

impl eframe::App for Editor {
    fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
        egui::CentralPanel::default().show(ui, |ui| {
            ui.heading("editor");
            ui.add(
                egui::TextEdit::multiline(&mut self.text)
                    .font(egui::TextStyle::Monospace)
                    .desired_width(f32::INFINITY),
            );
            ui.label(format!("{} bytes", self.text.len()));
        });
    }
}

fn main() -> eframe::Result {
    let options = eframe::NativeOptions {
        viewport: egui::ViewportBuilder::default().with_inner_size([800.0, 600.0]),
        ..Default::default()
    };

    eframe::run_native(
        "editor",
        options,
        Box::new(|_cc| Ok(Box::new(Editor::open("src/main.rs")))),
    )
}

Check yourself

Two of these are about today; one is about lesson 1, on purpose. Coming back to older material after a gap is what turns it into something you still have in six months. Each explanation links back to where the idea was taught.

fs::read_to_string returns Result<String, Error>. What does that type actually tell you?

  • Exactly one of those two, never both
  • A string, plus an error code beside it
  • A string that may be empty on failure
  • A string, and errors raised as exceptions

Result is an enum, so a value is eitherOk(String) or Err(Error) — one shape at a time. Rust has no exceptions; failure travels as an ordinary return value, which is why the compiler can force you to deal with it.↑ Review: the two shapes of Result (stage 1)

You replace the match with .unwrap() and the file is missing. What happens?

  • The program panics and stops immediately
  • The buffer holds an empty string instead
  • The compiler refuses to build the program
  • The error appears inside the editor window

unwrap() compiles fine — that's what makes it dangerous. On an Err it panics and the process dies, so the window never opens at all. The match keeps the failure as data you can show the user.↑ Review: what unwrap does (stage 3)

From lesson 1: why does TextEdit::multiline need &mut self.text rather than &self.text?

  • Typing has to change the stored text
  • Widgets always take ownership of data
  • Immediate mode forbids all shared borrows
  • The struct field is declared as mutable

A shared borrow is read-only and cannot be upgraded. A text box writes into your buffer when a key is pressed, so it needs exclusive access — which you can only hand out because ui takes &mut self.↑ Review: the two kinds of borrow (lesson 1, stage 5)

Read this before lesson 3

Primary source:The Rust Programming Language, Chapter 9 — Error Handling(Brown University interactive edition). Read 9.1 and 9.2.

9.1 covers panic! — what unwrap() triggers, and when crashing is genuinely the right answer. 9.2 is Result proper, including the? operator, which collapses a match like today's into one character. We'll use ? in lesson 3 when saving gives us a function that can fail; you'll get much more out of it having written the long form first.