Files and I/O: Arguments, Environment Variables, and Writing Files

Files and I/O: Arguments, Environment Variables, and Writing Files

Every file this course has opened — hello.txt, username.txt, count.txt — has had its name hardcoded directly into the source. Today that changes: std::env::args reads what was typed on the command line (an iterator, fed straight into the collect from the iterators lesson), std::env::var reads environment variables, and fs::write finally covers the other half of file I/O. Closes with Option::map and unwrap_or, tying Option directly to the Iterator methods from last lesson and the closures lesson before that.

13 min read4 learning objectives

What You'll Learn

  • Use std::env::args to read command-line arguments as an iterator, collect them into a Vec<String>, and access them by index or with .get()
  • Read a file whose path comes from a command-line argument, reusing the main() -> Result<(), Box<dyn Error>> pattern from the propagating-errors and traits lessons
  • Use std::env::var to read an environment variable and match on the Result it returns
  • Write to a file with fs::write as the counterpart to fs::read_to_string, and use Option::map and unwrap_or to supply a default value

Every file this course has ever touched — hello.txt, username.txt, count.txt — has had its name written directly into the source code, a string literal sitting right there in fs::read_to_string(...). That's about to change. Two new sources of input today — the command line and the environment — plus the half of file I/O that hasn't come up yet: writing.

Command-line arguments: std::env::args

std::env::args() returns every argument the program was started with, as an iterator of Strings. And the very first thing this lesson does with that iterator is the very last thing the previous one taught:

env::args() — an iterator — straight into .collect(), exactly where the iterators lesson left off, producing a Vec<String>. Run this with cargo run -- count.txt, and the printed vector has two elements: the path to your compiled binary, always present as element 0, and "count.txt" right after it. Whatever you type after -- on the command line shows up here, in order, as plain strings — yours to do with as you like.

A real path, from outside the program

Here's read_to_string again — but this time, the path isn't a literal. It's args[1], and the rest of the function is exactly the shape the propagating-errors lesson and the traits lesson already built:

main() -> Result<(), Box<dyn Error>> — the exact signature from two lessons you've already finished — is what lets that ? after read_to_string(path) work at all. Run this without a second argument, and args[1] panics: index out of bounds, the exact same panic as the very first one this course ever showed you, back in the panic-vs-Result lesson. Same Vec, same indexing operator, same panic — except this time the index came from how someone ran your program, not from a number you typed into the source yourself.

Environment variables: std::env::var

Arguments are typed fresh every run. Environment variables are ambient — set once in your shell, read by every program that asks. std::env::var asks:

env::var(name) returns a Result Ok(value) if a variable by that name is set, Err(...) if it isn't. Nothing here is new: it's match on a Result, exactly like every Ok/Err pair since the panic-vs-Result lesson, with Err(_) using the wildcard pattern from the pattern-matching lesson because — for this purpose — why the variable isn't set doesn't matter, only that it isn't.

Writing files: fs::write

Every fs call so far has been a read. Here's the other half — and notice it's shaped like a mirror image:

fs::write(path, contents) creates the file if it doesn't exist, overwrites it completely if it does, and writes contents in one shot — no separate "open" step. std::io::Result<()> is the same io::Error from the custom-error-types lesson, just spelled as the type alias the standard library itself uses; ? works on it for the same reason it always has. Two calls — write, then read the same path right back — are enough to prove to yourself that what landed on disk is exactly what you sent.

A safer default, the Option way

Indexing args[1] panics if it's missing. The vectors lesson's answer to that was .get(), returning an Option instead of panicking — and Option has a method that should look very familiar after last lesson:

args.get(1) is Option<&String> Some if a second argument was given, None if not. .map(String::as_str) is Iterator::map's sibling on Option: where Iterator::map transforms each item a sequence produces, Option::map transforms the one item a container holds — if it holds one — and leaves None alone. String::as_str, passed by name, is the same "function used as a value" trick as or_insert_with(Vec::new) and .map_err(CountError::Io) from a few lessons back. And .unwrap_or("count.txt") is .unwrap_or_else(|| String::from("Anonymous"))'s plainer cousin from the closures lesson: unwrap_or_else takes a closure because building its default took real work; unwrap_or takes the value directly, because "count.txt" is just a literal — there's nothing to delay.

Quick exercise

  1. Run the first code block in this lesson with cargo run -- hello world. Count the elements in the printed vector, and confirm element 0 is your binary's path, not "hello".
  2. Take read_count_from_file from the custom-error-types lesson. Instead of always reading "count.txt", collect std::env::args() into a Vec<String> and use the exact pattern from the last code block — args.get(1).map(String::as_str).unwrap_or("count.txt") — to pick the path: whatever was passed on the command line, or "count.txt" if nothing was.

Six lessons ago, or_insert_with(Vec::new) and .map_err(CountError::Io) were "functions passed as values." Two lessons ago, they became closures' quiet cousins. Today, String::as_str joined them, and Option::map turned out to be Iterator::map wearing a smaller hat — every container in this course, from Vec to Option to Result, keeps turning out to share the same handful of moves. env::args, env::var, and fs::write are three new functions; nothing about using them was new at all.

There's one more shape of "code someone else wrote" left to meet. Lesson 4 added rand to Cargo.toml and called rand::thread_rng().gen_range(...) — one external crate, used once, before derive, traits, or modules meant anything yet. The next lesson adds a second one: serde, and the realization that derive — the same #[derive(Debug)] you've written since the structs lesson — extends to crates you didn't write at all. A couple of attributes, and any struct in this course can turn itself into JSON and back — reading and writing not just text files, but structured ones: serde and JSON.