Files and I/O
Standard library · 1 of 5
Reading and writing files lives in std::fs (whole files at once) and std::io (streams, buffers, line by line). Almost every call that touches the disk returns an io::Result, and ? passes the error up to the caller.
Coming from Gofs::write is os.WriteFile, and fs::read_to_string is os.ReadFile plus string() plus a UTF-8 check that can fail. BufReader::lines() takes the place of bufio.Scanner, and ? replaces each if err != nil. A File closes at the end of its scope, but unlike f.Close() that cannot return an error.
Coming from Kotlinfs::write and fs::read_to_string are writeText and readText. Failures come back as io::Result instead of exceptions, and a file closes when it goes out of scope, as if every use were wrapped in use { }, except that a failed close is not reported.
Coming from Pythonfs::read_to_string is path.read_text() and fs::write is path.write_text(). A file closes when its variable goes out of scope, like the end of a with block, and errors come back as a Result instead of a raised OSError. Bad UTF-8 is an io::Error too, not a UnicodeDecodeError.
Coming from TypeScriptstd::fs is like the *Sync functions in node:fs: plain blocking calls, and reading line by line needs no async. Errors come back as a Result, and e.kind() plays the role of err.code.
Coming from Javafs::write and fs::read_to_string are Files.writeString and Files.readString, and a call that can fail returns io::Result instead of throwing IOException. Files close when they go out of scope, like try-with-resources but automatic, except that a failed close is not reported. You do need use std::io::Read to get methods like read_to_string on a File.
Coming from C#fs::write and fs::read_to_string are File.WriteAllText and File.ReadAllText, but errors come back as io::Result for ?, not as exceptions. A File closes when it goes out of scope, like using without the keyword (and without an exception from a failed close), and trait methods like read_to_string need a use std::io::Read.
Coming from CFile is closer to a file descriptor than to a FILE *: it has no buffer (BufReader and BufWriter add one), and it closes itself at the end of its scope, so there's no fclose to forget. A failing call returns a Result with the error kind inside, instead of NULL plus errno.
Coming from C++File is RAII like std::fstream, closed at the end of its scope, but it has no buffer where an fstream has one. Every read and write returns an io::Result that you must handle, instead of a stream fail bit that's easy to ignore.
The quick comparison
use std::fs;use std::io::{BufRead, BufReader, Write};
fn main() -> std::io::Result<()> { let path = std::env::temp_dir().join("rbc-notes.txt");
// write a whole file at once (creates it, or empties it first) fs::write(&path, "milk\neggs\n")?;
// open for appending and add a line let mut f = fs::OpenOptions::new().append(true).open(&path)?; writeln!(f, "bread")?;
// read the whole file into one String let all = fs::read_to_string(&path)?; println!("{} bytes", all.len());
// read line by line, without loading the whole file let reader = BufReader::new(fs::File::open(&path)?); for (i, line) in reader.lines().enumerate() { let line = line?; // each line can fail on its own (bad UTF-8, disk error) println!("{}: {line}", i + 1); }
println!("file name: {:?}", path.file_name().unwrap());
// a missing file is an Err you can inspect, not a crash match fs::read_to_string("/no/such/file.txt") { Ok(_) => println!("found it?"), Err(e) => println!("error: {e} (kind {:?})", e.kind()), }
fs::remove_file(&path)?; Ok(())}16 bytes 1: milk 2: eggs 3: bread file name: "rbc-notes.txt" error: No such file or directory (os error 2) (kind NotFound)
package main
import ( "bufio" "errors" "fmt" "io/fs" "os" "path/filepath")
func main() { path := filepath.Join(os.TempDir(), "rbc-notes.txt")
if err := os.WriteFile(path, []byte("milk\neggs\n"), 0o644); err != nil { panic(err) }
f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0) if err != nil { panic(err) } fmt.Fprintln(f, "bread") f.Close()
all, err := os.ReadFile(path) if err != nil { panic(err) } fmt.Println(len(all), "bytes")
r, err := os.Open(path) if err != nil { panic(err) } sc := bufio.NewScanner(r) for i := 1; sc.Scan(); i++ { fmt.Printf("%d: %s\n", i, sc.Text()) } r.Close()
fmt.Printf("file name: %q\n", filepath.Base(path))
_, err = os.ReadFile("/no/such/file.txt") fmt.Println("error:", err, "| not found:", errors.Is(err, fs.ErrNotExist))
os.Remove(path)}16 bytes 1: milk 2: eggs 3: bread file name: "rbc-notes.txt" error: open /no/such/file.txt: no such file or directory | not found: true
import java.io.Fileimport java.io.FileNotFoundException
fun main() { val file = File(System.getProperty("java.io.tmpdir"), "rbc-notes.txt")
file.writeText("milk\neggs\n") file.appendText("bread\n")
val all = file.readText() println("${all.length} chars")
file.useLines { lines -> lines.forEachIndexed { i, line -> println("${i + 1}: $line") } }
println("file name: \"${file.name}\"")
try { File("/no/such/file.txt").readText() } catch (e: FileNotFoundException) { println("error: ${e.message}") }
file.delete()}16 chars 1: milk 2: eggs 3: bread file name: "rbc-notes.txt" error: /no/such/file.txt (No such file or directory)
import tempfilefrom pathlib import Path
path = Path(tempfile.gettempdir()) / "rbc-notes.txt"
# write a whole file at once (creates it, or empties it first)path.write_text("milk\neggs\n")
# open for appending and add a linewith open(path, "a") as f: f.write("bread\n")
# read the whole file into one strtext = path.read_text()print(f"{len(text)} chars")
# read line by line, without loading the whole filewith open(path) as f: # `with` closes the file at the end of the block for i, line in enumerate(f, start=1): print(f"{i}: {line.rstrip()}")
print(f"file name: {path.name!r}")
# a missing file raises an exception you can catchtry: Path("/no/such/file.txt").read_text()except FileNotFoundError as e: print(f"error: {e.strerror} (errno {e.errno})")
path.unlink()16 chars 1: milk 2: eggs 3: bread file name: 'rbc-notes.txt' error: No such file or directory (errno 2)
import * as fs from "node:fs";import * as os from "node:os";import * as path from "node:path";import * as readline from "node:readline";
async function main() { const file = path.join(os.tmpdir(), "rbc-notes.txt");
// write a whole file at once (creates it, or empties it first) fs.writeFileSync(file, "milk\neggs\n");
// add a line at the end fs.appendFileSync(file, "bread\n");
// read the whole file into one string const all = fs.readFileSync(file, "utf8"); console.log(`${all.length} chars`);
// read line by line, without loading the whole file const lines = readline.createInterface({ input: fs.createReadStream(file) }); let i = 1; for await (const line of lines) { console.log(`${i++}: ${line}`); }
console.log(`file name: ${JSON.stringify(path.basename(file))}`);
// a missing file throws; the error carries a code you can check try { fs.readFileSync("/no/such/file.txt", "utf8"); } catch (e) { const err = e as NodeJS.ErrnoException; console.log(`error: ${err.message} (code ${err.code})`); }
fs.unlinkSync(file);}
main();16 chars 1: milk 2: eggs 3: bread file name: "rbc-notes.txt" error: ENOENT: no such file or directory, open '/no/such/file.txt' (code ENOENT)
import java.io.BufferedReader;import java.io.IOException;import java.nio.file.Files;import java.nio.file.NoSuchFileException;import java.nio.file.Path;import java.nio.file.StandardOpenOption;
public class Main { public static void main(String[] args) throws IOException { Path path = Path.of(System.getProperty("java.io.tmpdir"), "rbc-notes.txt");
// write a whole file at once (creates it, or empties it first) Files.writeString(path, "milk\neggs\n");
// open for appending and add a line Files.writeString(path, "bread\n", StandardOpenOption.APPEND);
// read the whole file into one String String all = Files.readString(path); System.out.println(all.length() + " chars");
// read line by line; try-with-resources closes the reader try (BufferedReader reader = Files.newBufferedReader(path)) { int i = 1; for (String line; (line = reader.readLine()) != null; i++) { System.out.println(i + ": " + line); } }
System.out.println("file name: \"" + path.getFileName() + "\"");
// a missing file is a checked exception you have to catch or declare try { Files.readString(Path.of("/no/such/file.txt")); } catch (NoSuchFileException e) { System.out.println("error: " + e.getClass().getSimpleName() + ": " + e.getMessage()); }
Files.delete(path); }}16 chars 1: milk 2: eggs 3: bread file name: "rbc-notes.txt" error: NoSuchFileException: /no/such/file.txt
var path = Path.Combine(Path.GetTempPath(), "rbc-notes.txt");
// write a whole file at once (creates it, or empties it first)File.WriteAllText(path, "milk\neggs\n");
// open for appending and add a lineFile.AppendAllText(path, "bread\n");
// read the whole file into one stringvar all = File.ReadAllText(path);Console.WriteLine($"{all.Length} chars");
// read line by line, without loading the whole filevar i = 1;foreach (var line in File.ReadLines(path)){ Console.WriteLine($"{i++}: {line}");}
Console.WriteLine($"file name: \"{Path.GetFileName(path)}\"");
// a missing file throws; catch the IOException familytry{ File.ReadAllText("/no/such/file.txt");}catch (IOException e){ Console.WriteLine($"error: {e.Message} ({e.GetType().Name})");}
File.Delete(path);16 chars 1: milk 2: eggs 3: bread file name: "rbc-notes.txt" error: Could not find a part of the path '/no/such/file.txt'. (DirectoryNotFoundException)
#include <errno.h>#include <stdio.h>#include <stdlib.h>#include <string.h>
int main(void) { const char *tmp = getenv("TMPDIR"); char path[512]; snprintf(path, sizeof path, "%s/rbc-notes.txt", tmp ? tmp : "/tmp");
// "w" creates the file, or empties it first FILE *f = fopen(path, "w"); if (!f) { perror(path); return 1; } fputs("milk\neggs\n", f); fclose(f);
// "a" opens for appending f = fopen(path, "a"); if (!f) { perror(path); return 1; } fprintf(f, "bread\n"); fclose(f);
// C has no "read the whole file": find the size, then fread it all f = fopen(path, "r"); if (!f) { perror(path); return 1; } fseek(f, 0, SEEK_END); long size = ftell(f); rewind(f); char *all = malloc((size_t)size + 1); size_t n = fread(all, 1, (size_t)size, f); all[n] = '\0'; printf("%zu bytes\n", n); free(all);
// read line by line: fgets keeps the '\n', so cut it off rewind(f); char line[128]; for (int i = 1; fgets(line, sizeof line, f); i++) { line[strcspn(line, "\n")] = '\0'; printf("%d: %s\n", i, line); } fclose(f);
printf("file name: \"%s\"\n", strrchr(path, '/') + 1);
// a missing file gives NULL, and errno says why FILE *missing = fopen("/no/such/file.txt", "r"); if (!missing) { printf("error: %s (not found: %s)\n", strerror(errno), errno == ENOENT ? "true" : "false"); } else { fclose(missing); }
remove(path); return 0;}16 bytes 1: milk 2: eggs 3: bread file name: "rbc-notes.txt" error: No such file or directory (not found: true)
#include <filesystem>#include <fstream>#include <iostream>#include <sstream>#include <string>
namespace fs = std::filesystem;
int main() { fs::path path = fs::temp_directory_path() / "rbc-notes.txt";
// write a whole file (std::ofstream creates it, or empties it first) std::ofstream(path) << "milk\neggs\n";
// open for appending and add a line. The file closes at the end of the block. { std::ofstream f(path, std::ios::app); f << "bread\n"; }
// read the whole file into one string std::ifstream in(path); std::stringstream buf; buf << in.rdbuf(); std::string all = buf.str(); std::cout << all.size() << " bytes\n";
// read line by line std::ifstream lines(path); std::string line; for (int i = 1; std::getline(lines, line); i++) { std::cout << i << ": " << line << "\n"; }
// a path prints with quotes std::cout << "file name: " << path.filename() << "\n";
// streams only say "it failed". std::filesystem can tell you why. std::error_code ec; auto size = fs::file_size("/no/such/file.txt", ec); if (!ec) { std::cout << "found it? " << size << " bytes\n"; } else { std::cout << "error: " << ec.message() << " (not found: " << std::boolalpha << (ec == std::errc::no_such_file_or_directory) << ")\n"; }
fs::remove(path);}16 bytes 1: milk 2: eggs 3: bread file name: "rbc-notes.txt" error: No such file or directory (not found: true)
What's different
fs::write,fs::read_to_stringandfs::read(bytes) are the one-liners. Reach forFileplusBufReaderorBufWriterwhen the file is big or you want it line by line.reader.lines()gives youResult<String>items, not plain Strings. That's why there's alet line = line?;inside the loop. Each read can fail, and you get that error on the line where it happened. Ignoring aResultdraws a compiler warning.- A
Filehas no buffer of its own:lines()needs aBufReader, and many small writes want aBufWriter. Dropping aFilecloses it but cannot report a failed close, so callsync_allorflushwhere you must know. - Paths are their own type,
Path(borrowed) andPathBuf(owned), like&strandString. File names on Linux don't have to be valid UTF-8, so a path isn't just a string..join()isfilepath.Join, except that an absolute second part replaces the first. fs::write(&path, ...)borrows the path. Functions like this takeimpl AsRef<Path>, which means "anything that can be read as a path": a&str, aString, aPathBuf. Reading signatures explains that bound.- Writing with
writeln!needsuse std::io::Write, and.lines()needsuse std::io::BufRead. Those methods come from traits, and a trait has to be imported before its methods show up. That's the gotcha below.
Calling a method that's "right there" on a File
Looks fine: in Go, Kotlin, Python, TypeScript, Java and C# it runs fine. Rust won't compile it.
Works in Go. In Rust it's just a rule.Works in Kotlin. In Rust it's just a rule.Works in Python. In Rust it's just a rule.Works in TypeScript. In Rust it's just a rule.Works in Java. In Rust it's just a rule.Works in C#. In Rust it's just a rule.C refuses this too. Same idea, different words.C++ refuses this too. Same idea, different words.use std::fs::{self, File};
fn main() -> std::io::Result<()> { let path = std::env::temp_dir().join("rbc-hello.txt"); fs::write(&path, "hello")?;
let mut f = File::open(&path)?; let mut text = String::new(); f.read_to_string(&mut text)?; println!("{text}"); Ok(())}package main
import ( "fmt" "os" "path/filepath")
func main() { path := filepath.Join(os.TempDir(), "rbc-hello.txt") os.WriteFile(path, []byte("hello"), 0o644)
f, _ := os.Open(path) buf := make([]byte, 64) n, _ := f.Read(buf) fmt.Println(string(buf[:n])) f.Close() os.Remove(path)}hello
import java.io.File
fun main() { val file = File(System.getProperty("java.io.tmpdir"), "rbc-hello.txt") file.writeText("hello")
val text = file.reader().use { it.readText() } println(text) file.delete()}hello
import tempfilefrom pathlib import Path
path = Path(tempfile.gettempdir()) / "rbc-hello.txt"path.write_text("hello")
with open(path) as f: text = f.read()print(text)path.unlink()hello
import * as fs from "node:fs";import * as os from "node:os";import * as path from "node:path";
const file = path.join(os.tmpdir(), "rbc-hello.txt");fs.writeFileSync(file, "hello");
const fd = fs.openSync(file, "r");try { const buf = Buffer.alloc(64); const n = fs.readSync(fd, buf); console.log(buf.toString("utf8", 0, n));} finally { fs.closeSync(fd);}fs.unlinkSync(file);hello
import java.io.FileInputStream;import java.io.IOException;import java.nio.file.Files;import java.nio.file.Path;
public class Main { public static void main(String[] args) throws IOException { Path path = Path.of(System.getProperty("java.io.tmpdir"), "rbc-hello.txt"); Files.writeString(path, "hello");
try (FileInputStream f = new FileInputStream(path.toFile())) { String text = new String(f.readAllBytes()); System.out.println(text); } Files.delete(path); }}hello
var path = Path.Combine(Path.GetTempPath(), "rbc-hello.txt");File.WriteAllText(path, "hello");
using (var f = File.OpenText(path)){ var text = f.ReadToEnd(); Console.WriteLine(text);}File.Delete(path);hello
C has a cousin of this mistake: forget #include <string.h> and GCC 14 refuses strcspn and strlen, and even suggests the header, much like rustc suggests use std::io::Read.
#include <stdio.h>
int main(void) { FILE *f = fopen("/tmp/rbc-hello.txt", "w"); fputs("hello\n", f); fclose(f);
f = fopen("/tmp/rbc-hello.txt", "r"); char text[64]; fgets(text, sizeof text, f); text[strcspn(text, "\n")] = '\0'; printf("%s (%zu bytes)\n", text, strlen(text)); fclose(f); remove("/tmp/rbc-hello.txt"); return 0;}main.c: In function ‘main’:
main.c:11:10: error: implicit declaration of function ‘strcspn’ [-Wimplicit-function-declaration]
11 | text[strcspn(text, "\n")] = '\0';
| ^~~~~~~
main.c:2:1: note: include ‘<string.h>’ or provide a declaration of ‘strcspn’
1 | #include <stdio.h>
+++ |+#include <string.h>
2 |
main.c:11:10: warning: incompatible implicit declaration of built-in function ‘strcspn’ [-Wbuiltin-declaration-mismatch]
11 | text[strcspn(text, "\n")] = '\0';
| ^~~~~~~
main.c:11:10: note: include ‘<string.h>’ or provide a declaration of ‘strcspn’
main.c:12:38: error: implicit declaration of function ‘strlen’ [-Wimplicit-function-declaration]
12 | printf("%s (%zu bytes)\n", text, strlen(text));
| ^~~~~~
main.c:12:38: note: include ‘<string.h>’ or provide a declaration of ‘strlen’
main.c:12:38: warning: incompatible implicit declaration of built-in function ‘strlen’ [-Wbuiltin-declaration-mismatch]
main.c:12:38: note: include ‘<string.h>’ or provide a declaration of ‘strlen’C++ refuses this too: without #include <fstream>, std::ofstream and std::ifstream are only declared, so g++ calls them incomplete types. For std::ifstream it also names the header to add, <fstream>.
#include <filesystem>#include <iostream>#include <string>
int main() { auto path = std::filesystem::temp_directory_path() / "rbc-hello.txt"; std::ofstream(path) << "hello";
std::ifstream f(path); std::string text; std::getline(f, text); std::cout << text << "\n";}main.cpp: In function ‘int main()’:
main.cpp:7:23: error: invalid use of incomplete type ‘std::ofstream’ {aka ‘class std::basic_ofstream<char>’}
7 | std::ofstream(path) << "hello";
| ^
In file included from /usr/include/c++/14/system_error:42,
from /usr/include/c++/14/bits/fs_fwd.h:35,
from /usr/include/c++/14/filesystem:51,
from main.cpp:1:
/usr/include/c++/14/iosfwd:121:11: note: declaration of ‘std::ofstream’ {aka ‘class std::basic_ofstream<char>’}
121 | class basic_ofstream;
| ^~~~~~~~~~~~~~
main.cpp:9:25: error: variable ‘std::ifstream f’ has initializer but incomplete type
9 | std::ifstream f(path);
| ^
main.cpp:3:1: note: ‘std::ifstream’ is defined in header ‘<fstream>’; this is probably fixable by adding ‘#include <fstream>’
2 | #include <iostream>
+++ |+#include <fstream>
3 | #include <string>What does the compiler say? Guess first, then open.
error[E0599]: no method named `read_to_string` found for struct `File` in the current scope --> src/main.rs:9:7 | 9 | f.read_to_string(&mut text)?; | ^^^^^^^^^^^^^^ | = help: items from traits can only be used if the trait is in scope help: trait `Read` which provides `read_to_string` is implemented but not in scope; perhaps you want to import it | 1 + use std::io::Read; | help: there is a method `read_to_end` with a similar name | 9 - f.read_to_string(&mut text)?; 9 + f.read_to_end(&mut text)?; ... (notes pointing into the standard library trimmed)
Go runs this and prints hello. Read is a method on the file itself, so there is nothing extra to import.
Kotlin runs this and prints hello, because reader() and readText() come from kotlin.io, which is imported by default. Kotlin has the Rust rule too: the same readText() on a java.nio.file.Path does not compile until you add import kotlin.io.path.readText.
Python prints hello. read is a method of the file object itself, so there is nothing extra to import before you can call it.
Prints hello. Node's file functions are plain functions in the fs module, so once it's imported every one of them is there.
In Java a method belongs to its class, so readAllBytes is right there on the stream with no extra import, and this prints hello.
ReadToEnd is a method of StreamReader itself, so it's there with no extra using, and this prints hello. Extension methods like LINQ's do need a using, but the project's implicit usings already bring in the common ones.
C has a cousin of this mistake: forget #include <string.h> and GCC 14 refuses strcspn and strlen, and even suggests the header, much like rustc suggests use std::io::Read.
C++ refuses this too: without #include <fstream>, std::ofstream and std::ifstream are only declared, so g++ calls them incomplete types. For std::ifstream it also names the header to add, <fstream>.
read_to_string isn't a method of File itself. It comes from the Read trait, and trait methods only exist where the trait is imported with use.
Many std types get most of their methods from traits: Read, Write, BufRead, Iterator, FromStr. Rust only looks at traits you've brought into scope, so two crates can add methods with the same name without clashing. The compiler error tells you exactly which use line to add. Copy it and move on.
The fix
Compiles and runs.
use std::fs::{self, File};use std::io::Read; // brings read_to_string (and read, bytes, ...) into scope
fn main() -> std::io::Result<()> { let path = std::env::temp_dir().join("rbc-hello.txt"); fs::write(&path, "hello")?;
let mut f = File::open(&path)?; let mut text = String::new(); f.read_to_string(&mut text)?; println!("{text}"); fs::remove_file(&path)?; Ok(())}hello
The toolkit
Outputs come from Linux on 64-bit ARM, Rust 1.99.1, debug build. Every program on this page that makes files puts them under std::env::temp_dir() and deletes them. The runner gives a program an empty standard input. A few programs read /proc or use a Unix-only API; they say so.
Whole files in one call
fs::write creates a file, or empties it first, and writes the bytes. fs::read_to_string returns a String, fs::read a Vec<u8>. Each takes impl AsRef<Path>, so a &str, a String or a PathBuf all work (Reading signatures explains that bound).
use std::fs;use std::io;
fn main() -> io::Result<()> { let path = std::env::temp_dir().join("lr-whole-file.txt");
fs::write(&path, "milk\neggs\n")?; let text = fs::read_to_string(&path)?; println!("read_to_string: {text:?}");
let bytes = fs::read(&path)?; println!("read: {} bytes, first four {:?}", bytes.len(), &bytes[..4]);
fs::write(&path, "tea\n")?; // creates the file, or empties it first println!("after a second write: {:?}", fs::read_to_string(&path)?);
fs::remove_file(&path)?; Ok(())}read_to_string: "milk\neggs\n" read: 10 bytes, first four [109, 105, 108, 107] after a second write: "tea\n"
Both read the whole file into memory: fine for a config file or short input you need whole. For a file you do not control (an upload, a log), read pieces with a BufReader (below), one line or buffer at a time.
Errors: io::Error and ErrorKind
Almost every call that touches the disk returns io::Result<T>, which is Result<T, io::Error> (Errors covers Result, ? and Box<dyn Error>). Path::exists, is_file and is_dir are the exceptions: they return bool and hide the error. The Display text comes from the operating system, kind() gives a portable ErrorKind, and raw_os_error() the OS number. ErrorKind is #[non_exhaustive], so a match needs a wildcard arm (docs).
use std::fs;use std::io::{self, ErrorKind};
fn load(path: &str) -> io::Result<String> { fs::read_to_string(path)}
fn main() { let err = load("/no/such/file.txt").unwrap_err(); println!("display: {err}"); println!("kind: {:?}", err.kind()); println!("raw_os_error: {:?}", err.raw_os_error());
// ErrorKind is #[non_exhaustive], so a match needs a catch-all arm for path in ["/no/such/file.txt", "/"] { let what = match load(path) { Ok(_) => "read it".to_string(), Err(e) if e.kind() == ErrorKind::NotFound => "does not exist".to_string(), Err(e) => format!("failed: {:?}", e.kind()), }; println!("{path}: {what}"); }
// the message does not say which path failed; add that where you know it let with_path = load("/no/such/file.txt").map_err(|e| format!("/no/such/file.txt: {e}")); println!("with context: {with_path:?}");}display: No such file or directory (os error 2)
kind: NotFound
raw_os_error: Some(2)
/no/such/file.txt: does not exist
/: failed: IsADirectory
with context: Err("/no/such/file.txt: No such file or directory (os error 2)")The message does not name the path; add it where you know it. The translating section runs the other languages: Go, Java, Kotlin, C#, Python, Node and C++’s <filesystem> put the path in the message, and C’s strerror does not.
read_to_string refuses bytes that are not UTF-8 with InvalidData; the docs use that exact case as the example. Read the bytes with fs::read and decode them yourself, or decide per line. On 1.99.1 lines() reports the bad line as an Err and then carries on, but the docs promise only the error.
use std::fs::{self, File};use std::io::{BufRead, BufReader};
fn main() -> std::io::Result<()> { let path = std::env::temp_dir().join("lr-invalid-utf8.txt"); fs::write(&path, b"good\nbad \xff here\nlast\n")?; // 0xff is never valid UTF-8
let err = fs::read_to_string(&path).unwrap_err(); println!("read_to_string: {err} (kind {:?})", err.kind());
let bytes = fs::read(&path)?; println!("read + lossy: {:?}", String::from_utf8_lossy(&bytes));
for line in BufReader::new(File::open(&path)?).lines() { match line { Ok(line) => println!("lines: Ok({line:?})"), Err(e) => println!("lines: Err({:?})", e.kind()), } }
fs::remove_file(&path)?; Ok(())}read_to_string: stream did not contain valid UTF-8 (kind InvalidData)
read + lossy: "good\nbad � here\nlast\n"
lines: Ok("good")
lines: Err(InvalidData)
lines: Ok("last")Opening a file: File, OpenOptions and closing
File::open opens for reading, File::create for writing (creating or emptying the file). Anything else goes through OpenOptions, a builder: append, create_new, read(true).write(true) for both. On Unix mode sets the permission bits of a new file (the umask can remove bits) and fs::set_permissions changes them later. The create_new docs call it “atomic”: the check and the creation are one step, so no other process can create the file in between (a TOCTOU race, time-of-check to time-of-use). Checking Path::exists and then creating is the weaker pattern.
use std::fs::{self, File, OpenOptions};use std::io::Write;use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
fn main() -> std::io::Result<()> { let path = std::env::temp_dir().join("lr-open-options.txt");
// create_new: create it, and fail if it already exists (one step, no check first) let mut f = OpenOptions::new() .write(true) .create_new(true) .open(&path)?; f.write_all(b"first\n")?; let again = OpenOptions::new().write(true).create_new(true).open(&path); println!( "create_new twice: {:?}", again.map(|_| ()).map_err(|e| e.kind()) );
// append: every write goes to the end let mut f = OpenOptions::new().append(true).open(&path)?; f.write_all(b"second\n")?; println!("after append: {:?}", fs::read_to_string(&path)?);
// append without create does not make a missing file let missing = OpenOptions::new() .append(true) .open(path.with_extension("none")); println!( "append to a missing file: {:?}", missing.map(|_| ()).map_err(|e| e.kind()) );
// File::create is write + create + truncate File::create(&path)?; println!("after File::create: {} bytes", fs::metadata(&path)?.len());
// File::open is read-only, so a write through it fails let err = File::open(&path)?.write_all(b"x").unwrap_err(); println!("write on a read-only handle: {err}");
fs::remove_file(&path)?;
// a mode argument, like os.WriteFile's 0o644 (Unix only; the umask can remove bits) let private = path.with_extension("private"); let f = OpenOptions::new() .write(true) .create_new(true) .mode(0o600) .open(&private)?; println!( "mode(0o600): {:o}", f.metadata()?.permissions().mode() & 0o777 ); fs::set_permissions(&private, fs::Permissions::from_mode(0o640))?; println!( "after set_permissions(0o640): {:o}", fs::metadata(&private)?.permissions().mode() & 0o777 ); fs::remove_file(&private)}create_new twice: Err(AlreadyExists) after append: "first\nsecond\n" append to a missing file: Err(NotFound) after File::create: 0 bytes write on a read-only handle: Bad file descriptor (os error 9) mode(0o600): 600 after set_permissions(0o640): 640
There is no close. The File docs say: “Files are automatically closed when they go out of scope.” A File owns an operating-system handle, and its Drop (Common traits) closes it. On Unix the operating system numbers each open file with a file descriptor. This program counts the process’s descriptors through /proc/self/fd (Linux):
use std::fs::{self, File};use std::io;
/// How many file descriptors this process has open (Linux: one entry in /proc/self/fd each).fn open_fds() -> usize { fs::read_dir("/proc/self/fd").unwrap().count()}
fn main() -> io::Result<()> { let path = std::env::temp_dir().join("lr-drop-closes.txt"); fs::write(&path, "x")?; let base = open_fds();
{ let _f = File::open(&path)?; println!("inside the block: {} more open", open_fds() - base); } println!("after the block: {} more open", open_fds() - base);
let f = File::open(&path)?; println!("opened again: {} more open", open_fds() - base); drop(f); println!("after drop(f): {} more open", open_fds() - base);
let f = File::open(&path)?; std::mem::forget(f); // never dropped, so never closed println!("after mem::forget: {} more open", open_fds() - base);
fs::remove_file(&path)}inside the block: 1 more open after the block: 0 more open opened again: 1 more open after drop(f): 0 more open after mem::forget: 1 more open
mem::forget takes a value and never runs its Drop, so the descriptor stays open: closing is Drop, nothing more. The docs warn: “Errors detected on closing are ignored by the implementation of Drop. Use the method sync_all if these errors must be manually handled.” The BufWriter section runs a failure that a drop hides.
Read, Write, BufRead and Seek
These traits are what std::io is for: code that does not care where bytes come from. Read takes bytes out, Write puts bytes in, BufRead adds lines and friends to a reader that keeps a buffer, and Seek moves the position. Each type implements the ones that fit it:
| Type | Read |
Write |
BufRead |
Seek |
|---|---|---|---|---|
File |
yes | yes | no | yes |
BufReader<File> |
yes | no | yes | yes |
&[u8] |
yes | no | yes | no |
Cursor<Vec<u8>> |
yes | yes | yes | yes |
Vec<u8> |
no | yes | no | no |
Stdin / StdinLock |
yes / yes | no | no / yes | no |
Stdout |
no | yes | no | no |
A bare File has no buffer, so it is not a BufRead: File::open(p)?.lines() does not compile; wrap it in a BufReader. A function written against the traits (Traits, Generics) serves every source, and a test needs no disk:
use std::fs::{self, File};use std::io::{self, BufRead, BufReader, Write};
/// Counts words in anything that can be read line by line.fn count_words<R: BufRead>(reader: R) -> io::Result<usize> { let mut n = 0; for line in reader.lines() { n += line?.split_whitespace().count(); } Ok(n)}
/// Writes a report to anything that can be written to.fn report<W: Write>(out: &mut W, name: &str, n: usize) -> io::Result<()> { writeln!(out, "{name}: {n} words")}
fn main() -> io::Result<()> { let path = std::env::temp_dir().join("lr-traits.txt"); let text = "one two\nthree\n"; fs::write(&path, text)?;
println!("from a byte slice: {}", count_words(text.as_bytes())?); println!( "from a file: {}", count_words(BufReader::new(File::open(&path)?))? );
let mut memory: Vec<u8> = Vec::new(); report(&mut memory, "vec", 3)?; println!("into a Vec<u8>: {:?}", String::from_utf8(memory).unwrap());
let mut file = File::create(&path)?; report(&mut file, "file", 3)?; println!("into a File: {:?}", fs::read_to_string(&path)?);
report(&mut io::stdout(), "stdout", 3)?;
fs::remove_file(&path)?; Ok(())}from a byte slice: 3 from a file: 3 into a Vec<u8>: "vec: 3 words\n" into a File: "file: 3 words\n" stdout: 3 words
Those methods exist only where the trait is imported (the gotcha above). Seek is random access, and a file needs read(true).write(true) to do both. Cursor is the same traits over memory, a stand-in for a file in tests.
use std::fs::{self, OpenOptions};use std::io::{self, Cursor, Read, Seek, SeekFrom, Write};
fn main() -> io::Result<()> { let path = std::env::temp_dir().join("lr-seek.txt"); let mut f = OpenOptions::new() .read(true) .write(true) .create(true) .truncate(true) .open(&path)?; f.write_all(b"0123456789")?;
f.seek(SeekFrom::Start(3))?; let mut four = [0u8; 4]; f.read_exact(&mut four)?; println!("seek to 3, read 4: {:?}", String::from_utf8_lossy(&four)); println!("position now: {}", f.stream_position()?);
f.seek(SeekFrom::End(-2))?; // two bytes before the end f.write_all(b"XY")?; // overwrites them f.rewind()?; let mut all = String::new(); f.read_to_string(&mut all)?; println!("after overwriting the last two bytes: {all:?}");
// a Cursor is the same three traits over memory: no disk, so a test can use it let mut mem = Cursor::new(Vec::new()); mem.write_all(b"hello world")?; mem.set_position(6); let mut rest = String::new(); mem.read_to_string(&mut rest)?; println!("cursor from byte 6: {rest:?}");
fs::remove_file(&path)}seek to 3, read 4: "3456" position now: 7 after overwriting the last two bytes: "01234567XY" cursor from byte 6: "world"
io::copy(&mut reader, &mut writer) moves all the bytes between any Read and Write and returns the count; fs::copy copies one file to another.
use std::fs::{self, File};use std::io;
fn main() -> io::Result<()> { let dir = std::env::temp_dir(); let (a, b) = (dir.join("lr-copy-a.txt"), dir.join("lr-copy-b.txt")); fs::write(&a, "some bytes\n")?;
let n = fs::copy(&a, &b)?; // file to file, returns the byte count println!( "fs::copy: {n} bytes, copy reads {:?}", fs::read_to_string(&b)? );
// io::copy moves bytes from any Read to any Write let mut sink = Vec::new(); let n = io::copy(&mut File::open(&a)?, &mut sink)?; println!("io::copy into a Vec<u8>: {n} bytes"); print!("io::copy to stdout: "); io::copy(&mut File::open(&a)?, &mut io::stdout().lock())?;
fs::remove_file(&a)?; fs::remove_file(&b)}fs::copy: 11 bytes, copy reads "some bytes\n" io::copy into a Vec<u8>: 11 bytes io::copy to stdout: some bytes
Lines and line endings
BufReader::new(file).lines() is the everyday way to read line by line. Its items are io::Result<String>, and the docs say each string has no \n or \r\n at the end. read_line appends to a String you own, keeps the ending, and returns Ok(0) at the end of the input. Nothing translates endings on Linux. Splitting on \n yourself leaves the \r on Windows lines, a last line without a newline is still a line, and a lone \r is not a break.
use std::fs;use std::io::{BufRead, BufReader};
fn main() -> std::io::Result<()> { let data: &[u8] = b"a\r\nb\nc\r\n\r\nd"; // mixed endings, no newline at the end let lines: Vec<String> = BufReader::new(data).lines().map(|l| l.unwrap()).collect(); println!("lines(): {lines:?}");
let mut reader = BufReader::new(data); let mut line = String::new(); let mut seen = Vec::new(); while reader.read_line(&mut line).unwrap() > 0 { seen.push(line.clone()); line.clear(); // read_line appends, so clear it between reads } println!("read_line(): {seen:?}");
let pieces: Vec<&[u8]> = data.split(|&b| b == b'\n').collect(); println!( "split on \\n: {} pieces, first is {:?}", pieces.len(), String::from_utf8_lossy(pieces[0]) );
let lone: Vec<String> = BufReader::new(&b"x\ry\n"[..]) .lines() .map(|l| l.unwrap()) .collect(); println!("a lone \\r is not a line break: {lone:?}");
// a file is no different: read_to_string does not translate \r\n let path = std::env::temp_dir().join("lr-line-endings.txt"); fs::write(&path, "a\r\nb\r\n")?; println!("read_to_string: {:?}", fs::read_to_string(&path)?); fs::remove_file(&path)}lines(): ["a", "b", "c", "", "d"] read_line(): ["a\r\n", "b\n", "c\r\n", "\r\n", "d"] split on \n: 5 pieces, first is "a\r" a lone \r is not a line break: ["x\ry"] read_to_string: "a\r\nb\r\n"
Paths
A Path is to a PathBuf what a &str is to a String: a borrowed view and its owned twin. The methods work on components, not text, so starts_with compares whole components:
use std::path::{Path, PathBuf};
fn main() { let p = Path::new("/home/ana/notes/todo.final.txt"); println!("file_name: {:?}", p.file_name()); println!("file_stem: {:?}", p.file_stem()); println!("extension: {:?}", p.extension()); println!("parent: {:?}", p.parent()); println!("with_extension: {:?}", p.with_extension("md"));
println!(".bashrc extension: {:?}", Path::new(".bashrc").extension()); println!( "a.tar.gz extension: {:?}", Path::new("a.tar.gz").extension() );
let mut b = PathBuf::from("/data"); b.push("logs"); b.push("app.log"); println!("built: {}", b.display()); println!( "join(absolute) replaces: {:?}", Path::new("/data").join("/etc/hosts") ); println!( "starts_with is by component: {}", Path::new("/data/logs").starts_with("/dat") ); println!( "components: {:?}", Path::new("/data/./logs/../app.log") .components() .collect::<Vec<_>>() );}file_name: Some("todo.final.txt")
file_stem: Some("todo.final")
extension: Some("txt")
parent: Some("/home/ana/notes")
with_extension: "/home/ana/notes/todo.final.md"
.bashrc extension: None
a.tar.gz extension: Some("gz")
built: /data/logs/app.log
join(absolute) replaces: "/etc/hosts"
starts_with is by component: false
components: [RootDir, Normal("data"), Normal("logs"), ParentDir, Normal("app.log")]Three things to remember. extension is only the last part (gz, not tar.gz), without the dot, and a name that only starts with a dot has none (docs). join with an absolute path replaces everything before it: the docs say “If path is absolute, it replaces the current path.” And components keeps ... A base directory plus user input is therefore not a sandbox.
Directories, metadata and symlinks
use std::fs;use std::io;use std::path::Path;
/// std has no recursive walk: `read_dir` is one level, so recurse yourself/// (the `walkdir` crate is the usual shortcut).fn walk(dir: &Path, prefix: &str, found: &mut Vec<String>) -> io::Result<()> { for entry in fs::read_dir(dir)? { let entry = entry?; let name = format!("{prefix}{}", entry.file_name().to_string_lossy()); if entry.file_type()?.is_dir() { walk(&entry.path(), &format!("{name}/"), found)?; // file_type does not follow symlinks } found.push(name); } Ok(())}
fn main() -> io::Result<()> { let root = std::env::temp_dir().join("lr-dirs"); fs::create_dir_all(root.join("a/b"))?; // every missing level; fine if it exists fs::write(root.join("top.txt"), "333")?; fs::write(root.join("a/one.txt"), "1")?;
// read_dir yields Result items, in an order the docs do not specify: sort let mut names = Vec::new(); for entry in fs::read_dir(&root)? { let entry = entry?; let kind = if entry.file_type()?.is_dir() { "dir" } else { "file" }; names.push(format!("{} ({kind})", entry.file_name().to_string_lossy())); } names.sort(); println!("entries: {names:?}");
let mut all = Vec::new(); walk(&root, "", &mut all)?; all.sort(); println!("walk: {all:?}");
let meta = fs::metadata(root.join("top.txt"))?; println!( "top.txt: len {}, is_file {}, is_dir {}", meta.len(), meta.is_file(), meta.is_dir() );
let err = fs::remove_dir(root.join("a")).unwrap_err(); println!("remove_dir on a non-empty directory: {:?}", err.kind());
fs::remove_dir_all(&root)?; println!("root exists afterwards: {:?}", root.try_exists()); Ok(())}entries: ["a (dir)", "top.txt (file)"] walk: ["a", "a/b", "a/one.txt", "top.txt"] top.txt: len 3, is_file true, is_dir false remove_dir on a non-empty directory: DirectoryNotEmpty root exists afterwards: Ok(false)
read_dir yields io::Result<DirEntry> items, and the docs say the order “can change between calls”, so sort. It lists one level; std has no recursive walk, so walk recurses by hand (the walkdir crate is the usual shortcut).
| Task | Call |
|---|---|
| make one directory / a whole path | fs::create_dir / fs::create_dir_all |
| delete a file / an empty directory | fs::remove_file / fs::remove_dir, which fails on a non-empty one |
| delete a directory and everything in it | fs::remove_dir_all |
| size, kind of file | fs::metadata(path)? then .len(), .is_file() |
| does it exist | try_exists(); exists() hides errors and, as the docs warn, invites TOCTOU bugs |
A symlink is a file that holds a path. metadata and exists follow it, symlink_metadata looks at the link itself, so a dangling link “does not exist” while the link is still there (std::os::unix::fs::symlink is Unix only):
use std::fs;use std::os::unix::fs::symlink;
fn main() -> std::io::Result<()> { let dir = std::env::temp_dir().join("lr-symlink"); fs::create_dir_all(&dir)?; fs::write(dir.join("real.txt"), "hello")?; symlink("real.txt", dir.join("link"))?;
let followed = fs::metadata(dir.join("link"))?; let itself = fs::symlink_metadata(dir.join("link"))?; println!( "metadata(link): is_file {}, len {}", followed.is_file(), followed.len() ); println!("symlink_metadata(link): is_symlink {}", itself.is_symlink()); println!("read_link: {:?}", fs::read_link(dir.join("link"))?); println!( "read_to_string(link): {:?}", fs::read_to_string(dir.join("link"))? );
fs::remove_file(dir.join("real.txt"))?; let link = dir.join("link"); println!( "dangling: exists {}, but symlink_metadata is_ok {}", link.exists(), fs::symlink_metadata(&link).is_ok() );
fs::remove_dir_all(&dir)}metadata(link): is_file true, len 5 symlink_metadata(link): is_symlink true read_link: "real.txt" read_to_string(link): "hello" dangling: exists false, but symlink_metadata is_ok true
Standard output, standard input and flush
print! and println! write to Stdout, which keeps a buffer. The docs say it is line-buffered “when connected to a terminal”, so print! without a newline may sit in the buffer and a prompt needs flush. This program runs itself twice with its output sent to a file (the runner’s own is a terminal) and counts write calls:
use std::fs::{self, File};use std::io::{self, BufWriter, Write};use std::process::Command;
/// How many write system calls this process has made so far (Linux: the `syscw` line).fn write_calls() -> u64 { let io = fs::read_to_string("/proc/self/io").unwrap(); let line = io.lines().find(|l| l.starts_with("syscw:")).unwrap(); line["syscw:".len()..].trim().parse().unwrap()}
fn main() -> io::Result<()> { let mode = std::env::args().nth(1); match mode.as_deref() { Some("plain") => { let before = write_calls(); for i in 0..1000 { println!("line {i}"); } eprintln!(" 1000 println!: {} write calls", write_calls() - before); } Some("buffered") => { let before = write_calls(); let mut out = BufWriter::new(io::stdout().lock()); for i in 0..1000 { writeln!(out, "line {i}")?; } out.flush()?; eprintln!( " 1000 writeln! into a BufWriter: {} write calls", write_calls() - before ); } _ => { // run this program again, twice, with standard output going to a file let path = std::env::temp_dir().join("lr-stdout-redirect.txt"); for mode in ["plain", "buffered"] { println!("child {mode}, stdout redirected to a file:"); Command::new(std::env::current_exe()?) .arg(mode) .stdout(File::create(&path)?) .status()?; } fs::remove_file(&path)?; } } Ok(())}child plain, stdout redirected to a file: 1000 println!: 1000 write calls child buffered, stdout redirected to a file: 1000 writeln! into a BufWriter: 2 write calls
On 1.99.1 every println! made its own write call even to a file. The docs promise only the terminal case, so do not depend on either behaviour. Many lines to a file or pipe want stdout().lock() in a BufWriter, flushed at the end.
io::stdin().read_line(&mut s) reads one line, newline included, and io::stdin().lines() iterates over the rest. The runner’s empty input makes read_line return Ok(0).
use std::io::{self, Write};
fn main() -> io::Result<()> { // print! has no newline, so the text may sit in stdout's buffer: flush before reading print!("your name: "); io::stdout().flush()?;
let mut line = String::new(); let n = io::stdin().read_line(&mut line)?; println!(); println!("read_line returned {n}, line is {line:?}"); // read_line keeps the newline, so trim before using it println!("trimmed: {:?}", line.trim_end());
// the rest of the input, line by line (Stdin has its own lines(), no BufRead import needed) let rest = io::stdin().lines().count(); println!("lines left: {rest}"); Ok(())}your name: read_line returned 0, line is "" trimmed: "" lines left: 0
Going deeper
What a BufWriter buys, counted
The File docs say it “does not buffer reads and writes”, so every write on it is a request to the operating system, a system call. This program counts the write system calls the process makes, read from /proc/self/io (Linux only), for 1,000 lines, first straight into a File and then through a BufWriter. File::flush has nothing to empty (the docs call it “currently a no-op”) and made none:
use std::fs::{self, File};use std::io::{self, BufWriter, Write};
/// How many write system calls this process has made so far (Linux: the `syscw` line).fn write_calls() -> u64 { let io = fs::read_to_string("/proc/self/io").unwrap(); let line = io.lines().find(|l| l.starts_with("syscw:")).unwrap(); line["syscw:".len()..].trim().parse().unwrap()}
fn main() -> io::Result<()> { let path = std::env::temp_dir().join("lr-syscalls.txt");
let mut file = File::create(&path)?; let before = write_calls(); for i in 0..1000 { writeln!(file, "line {i}")?; } println!("unbuffered File: {} write calls", write_calls() - before);
// a File has no buffer, so there is nothing for flush to do let before = write_calls(); for _ in 0..1000 { file.flush()?; } println!( "1000 File::flush calls: {} write calls", write_calls() - before );
let mut w = BufWriter::new(File::create(&path)?); let before = write_calls(); for i in 0..1000 { writeln!(w, "line {i}")?; } w.flush()?; println!("BufWriter<File>: {} write calls", write_calls() - before);
println!("bytes in the file: {}", fs::metadata(&path)?.len()); fs::remove_file(&path)}unbuffered File: 3000 write calls 1000 File::flush calls: 0 write calls BufWriter<File>: 2 write calls bytes in the file: 8890
BufWriter collects small writes and passes them on in large batches (default “currently 8 KiB, but may change”; docs). The 3,000 calls are three per writeln!; this one writes to a counter and shows what is handed over and what waits:
use std::io::{self, BufWriter, Write};
/// A destination that only counts how many times it is asked to write.struct Counting { calls: usize, bytes: usize,}
impl Write for Counting { fn write(&mut self, buf: &[u8]) -> io::Result<usize> { self.calls += 1; self.bytes += buf.len(); Ok(buf.len()) } fn flush(&mut self) -> io::Result<()> { Ok(()) }}
fn main() -> io::Result<()> { let mut raw = Counting { calls: 0, bytes: 0 }; for i in 0..1000 { writeln!(raw, "line {i}")?; } println!( "unbuffered: {} bytes in {} write calls", raw.bytes, raw.calls );
let mut w = BufWriter::new(Counting { calls: 0, bytes: 0 }); println!("capacity: {}", w.capacity()); for i in 0..1000 { writeln!(w, "line {i}")?; } println!( "before flush: {} calls, {} bytes handed over, {} bytes waiting", w.get_ref().calls, w.get_ref().bytes, w.buffer().len() ); w.flush()?; println!( "after flush: {} bytes in {} write calls", w.get_ref().bytes, w.get_ref().calls ); Ok(())}unbuffered: 8890 bytes in 3000 write calls capacity: 8192 before flush: 1 calls, 8188 bytes handed over, 702 bytes waiting after flush: 8890 bytes in 2 write calls
It flushes on drop, but that flush has nowhere to report an error: “It is critical to call flush before BufWriter<W> is dropped.” /dev/full, a Linux device that refuses every byte, stands in for a full disk. Without flush()? the first function reports success:
use std::fs::File;use std::io::{self, BufWriter, Write};
// /dev/full accepts open() and refuses every byte, like a full disk (Linux)fn forget_to_flush() -> io::Result<()> { let mut w = BufWriter::new(File::create("/dev/full")?); println!( "write_all: {:?}", w.write_all(b"data").map_err(|e| e.kind()) ); Ok(()) // w is dropped here; its flush fails and nobody is told}
fn flush_and_check() -> io::Result<()> { let mut w = BufWriter::new(File::create("/dev/full")?); w.write_all(b"data")?; w.flush()}
fn main() { println!( "without flush: {:?}", forget_to_flush().map_err(|e| e.kind()) ); println!( "with flush()?: {:?}", flush_and_check().map_err(|e| e.kind()) );}write_all: Ok(()) without flush: Ok(()) with flush()?: Err(StorageFull)
read and write can do less than you asked
Read::read fills at most the buffer and returns how many bytes it wrote; for a non-empty buffer, 0 means the end of the input. Write::write may accept fewer bytes than offered. read_to_string, read_to_end, read_exact and write_all loop until done, or fail; ignoring write’s count can lose data, so use write_all or writeln!.
use std::io::{Read, Write};
fn main() { // read fills at most the buffer and says how much it put there let mut src: &[u8] = b"0123456789"; let mut buf = [0u8; 4]; let mut sizes = Vec::new(); loop { let n = src.read(&mut buf).unwrap(); if n == 0 { break; // for a non-empty buffer, 0 means the end of the input } sizes.push(n); } println!("read() returned: {sizes:?}");
let mut src: &[u8] = b"abc"; println!("read into an empty buffer: {:?}", src.read(&mut []));
let err = src.read_exact(&mut [0u8; 4]).unwrap_err(); println!("read_exact on 3 bytes: {:?}", err.kind());
// write may take only part of what you offer; write_all keeps going or fails let mut room = [0u8; 3]; let mut dst: &mut [u8] = &mut room; println!("write 5 bytes into 3: {:?}", dst.write(b"hello"));
let mut room = [0u8; 3]; let mut dst: &mut [u8] = &mut room; let err = dst.write_all(b"hello").unwrap_err(); println!("write_all 5 bytes into 3: {:?}", err.kind());}read() returned: [4, 4, 2] read into an empty buffer: Ok(0) read_exact on 3 bytes: UnexpectedEof write 5 bytes into 3: Ok(3) write_all 5 bytes into 3: WriteZero
Why a path is not a String
On Linux a file name is any bytes except / and NUL, so it need not be UTF-8. Names are OsStr and OsString (Path and PathBuf wrap them). to_str returns an Option, to_string_lossy replaces bad bytes, and display() prints a path. The type tells you when a name is not text.
use std::ffi::OsStr;use std::fs;use std::os::unix::ffi::OsStrExt;use std::path::Path;
fn main() -> std::io::Result<()> { let dir = std::env::temp_dir().join("lr-osstr"); fs::create_dir_all(&dir)?;
// a Linux file name is any bytes except '/' and NUL; this one is not UTF-8 let name = OsStr::from_bytes(b"caf\xe9.txt"); fs::write(dir.join(name), "hi")?;
for entry in fs::read_dir(&dir)? { let name = entry?.file_name(); println!("to_str: {:?}", name.to_str()); println!("to_string_lossy: {}", name.to_string_lossy()); println!("as_bytes: {:?}", name.as_bytes()); println!("Debug: {:?}", name); println!("round trip works: {}", Path::new(&dir).join(&name).exists()); }
fs::remove_dir_all(&dir)}to_str: None to_string_lossy: caf�.txt as_bytes: [99, 97, 102, 233, 46, 116, 120, 116] Debug: "caf\xE9.txt" round trip works: true
Temporary files that clean up
std has env::temp_dir() and no unique names (the tempfile crate does that). By hand, the process id keeps two overlapping runs apart, and a Drop removes the directory when the guard goes out of scope, even when ? returns early from the function that owns it.
use std::fs;use std::io;use std::path::PathBuf;
/// A scratch directory that deletes itself when it goes out of scope.struct TempDir(PathBuf);
impl TempDir { fn new(tag: &str) -> io::Result<TempDir> { // the process id keeps two runs that overlap in time from sharing a directory let path = std::env::temp_dir().join(format!("lr-{tag}-{}", std::process::id())); fs::create_dir(&path)?; // fails with AlreadyExists if an old run left it behind Ok(TempDir(path)) }}
impl Drop for TempDir { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.0); // nothing useful to do if this fails }}
/// Returns early with `?`, and the guard `tmp` is still dropped.fn work(seen: &mut Option<PathBuf>) -> io::Result<()> { let tmp = TempDir::new("demo")?; *seen = Some(tmp.0.clone()); fs::write(tmp.0.join("a.txt"), "data")?; println!("inside work: exists {}", tmp.0.exists()); let _ = fs::read_to_string(tmp.0.join("missing.txt"))?; // fails: we leave early Ok(())}
fn main() { let mut seen = None; println!("work: {:?}", work(&mut seen).map_err(|e| e.kind())); println!("after work: exists {}", seen.unwrap().exists());}inside work: exists true work: Err(NotFound) after work: exists false
Replacing a file safely
fs::write empties the file first, so a crash mid-write leaves half a file. The usual idiom writes a temporary file in the same directory and renames it over the target. The rename docs say it replaces to and “will not work” on a different mount point, so the temporary file goes next to the target. sync_all asks the OS to flush the data first, and create_new keeps the code from overwriting a file that is not its own.
use std::fs::{self, OpenOptions};use std::io::{self, Write};use std::path::Path;
/// Replace `path` with `contents`: write a temporary file next to it, then rename it over.fn save_atomic(path: &Path, contents: &str) -> io::Result<()> { let mut name = path.file_name().expect("a file path").to_owned(); name.push(format!(".{}.tmp", std::process::id())); let tmp = path.with_file_name(name); // same directory, so the same file system
// create_new fails if the name is taken, so we never touch a file that is not ours let mut f = OpenOptions::new().write(true).create_new(true).open(&tmp)?;
// a closure that is called at once: `?` leaves the closure, not save_atomic, // so the clean-up below runs on any error let result = (|| { f.write_all(contents.as_bytes())?; f.sync_all()?; // ask the OS to put the bytes on the disk before the swap fs::rename(&tmp, path) })(); if result.is_err() { let _ = fs::remove_file(&tmp); // do not leave the temporary file behind } result}
fn names(dir: &Path) -> io::Result<Vec<String>> { let mut names = Vec::new(); for entry in fs::read_dir(dir)? { names.push(entry?.file_name().to_string_lossy().into_owned()); } names.sort(); Ok(names)}
fn main() -> io::Result<()> { let dir = std::env::temp_dir().join("lr-atomic"); fs::create_dir_all(&dir)?; let config = dir.join("config.txt");
fs::write(&config, "version 1\n")?; save_atomic(&config, "version 2\n")?; println!("config: {:?}", fs::read_to_string(&config)?); println!("directory after the save: {:?}", names(&dir)?);
// a save that fails: the target is a directory, so the rename is refused let blocked = dir.join("blocked"); fs::create_dir(&blocked)?; let err = save_atomic(&blocked, "x").unwrap_err(); println!("save over a directory: {:?}", err.kind()); println!("directory after the failed save: {:?}", names(&dir)?);
fs::remove_dir_all(&dir)}config: "version 2\n" directory after the save: ["config.txt"] save over a directory: IsADirectory directory after the failed save: ["blocked", "config.txt"]
This run shows the result and the clean-up on failure, not a power cut or a crash between the calls; the page claims nothing about those.
File locks
File::lock (stable since Rust 1.89) locks a whole file; on Unix it is flock. The docs say it “may be advisory or mandatory” (advisory: other programs can ignore it), so this page tests lock against lock, on handles open for writing, as the docs leave read-only ones unspecified.
use std::fs::{self, File, OpenOptions, TryLockError};
fn state(r: Result<(), TryLockError>) -> &'static str { match r { Ok(()) => "got the lock", Err(TryLockError::WouldBlock) => "WouldBlock", Err(TryLockError::Error(_)) => "I/O error", }}
fn main() -> std::io::Result<()> { let path = std::env::temp_dir().join("lr-lock.txt"); let first = File::create(&path)?; // another handle to the same file, open for writing too: the docs leave the lock // calls on a read-only handle unspecified let second = OpenOptions::new().read(true).write(true).open(&path)?;
first.lock()?; // waits until it can have the lock all to itself println!( "second.try_lock while first holds it: {}", state(second.try_lock()) ); println!( "second.try_lock_shared: {}", state(second.try_lock_shared()) );
first.unlock()?; println!("after first.unlock: {}", state(second.try_lock())); second.unlock()?;
first.lock_shared()?; println!("shared + shared: {}", state(second.try_lock_shared())); println!("shared + exclusive: {}", state(second.try_lock()));
drop(first); fs::remove_file(&path)}second.try_lock while first holds it: WouldBlock second.try_lock_shared: WouldBlock after first.unlock: got the lock shared + shared: got the lock shared + exclusive: WouldBlock
Translating from what you know
All of std::fs blocks the thread; async file I/O comes from a runtime (Async basics, Tokio).
Practice
Try it
Each exercise has checks in main: replace the todo!() and run until it prints all checks passed. The starters compile and stop at todo!().
Statistics from any BufRead, without reading it all
Write stats(reader, limit): count the lines, the blank lines and the longest line (in characters) of any BufRead, and stop at the first line longer than limit characters: count it and read no further. The checks use Windows endings, a last line with no newline, non-ASCII text, bad UTF-8 and a reader that fails (an Err, not a skipped line). One reader fails once its bytes are used up, so reading everything first (read_to_string, then str::lines()) fails.
use std::io::{self, BufRead, BufReader, Read};
#[derive(Debug, PartialEq)]struct Stats { /// Lines read. A last line without a newline still counts. lines: usize, /// Lines with no characters at all once the line ending is gone. blank: usize, /// The most characters (not bytes) in one line, not counting its ending. longest: usize, /// True if reading stopped at a line longer than the limit. stopped: bool,}
/// Read `reader` one line at a time. It may be a file, standard input or a byte slice./// An unreadable line (bad UTF-8, a failing disk) is an error for the caller./// Stop at the first line with more than `limit` characters: count that line, then/// read no further, so the rest of a huge file is never touched.fn stats<R: BufRead>(reader: R, limit: usize) -> io::Result<Stats> { todo!("loop over reader.lines(), keep the counters, use ? on each line, stop at the long one")}
/// A reader that hands out some bytes and then fails.struct Flaky(bool);
impl Read for Flaky { fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> { if self.0 { return Err(io::Error::other("the disk went away")); } self.0 = true; let data = b"one\ntwo\n"; buf[..data.len()].copy_from_slice(data); Ok(data.len()) }}
/// A reader that hands out its bytes and then fails. A program that reads everything before/// it looks at a line runs into the failure; one that stops at the long line never does.struct RunsDry<'a>(&'a [u8]);
impl Read for RunsDry<'_> { fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> { if self.0.is_empty() { return Err(io::Error::other("read past the long line")); } let n = buf.len().min(self.0.len()); buf[..n].copy_from_slice(&self.0[..n]); self.0 = &self.0[n..]; Ok(n) }}
fn check(input: &[u8], lines: usize, blank: usize, longest: usize) { let got = stats(input, usize::MAX).unwrap(); assert_eq!( got, Stats { lines, blank, longest, stopped: false }, "input {:?}", String::from_utf8_lossy(input) );}
fn main() { check(b"", 0, 0, 0); check(b"one\ntwo\n", 2, 0, 3); check(b"a\nbc", 2, 0, 2); // no newline at the end check("h\u{e9}llo\r\n\r\nx\r\n".as_bytes(), 3, 1, 5); // CRLF, and \u{e9} is 2 bytes but 1 char check(b" \n", 1, 0, 3); // spaces are characters check(b"\n\n", 2, 2, 0);
let err = stats(&b"good\nbad \xff\nmore\n"[..], usize::MAX) .err() .expect("bad UTF-8 must be reported"); assert_eq!( err.kind(), io::ErrorKind::InvalidData, "bad UTF-8 must be reported" );
// BufReader with a tiny buffer: a line is longer than the buffer let long = "x".repeat(1000) + "\nshort\n"; let small = BufReader::with_capacity(8, long.as_bytes()); assert_eq!( stats(small, usize::MAX).unwrap(), Stats { lines: 2, blank: 0, longest: 1000, stopped: false } );
let err = stats(BufReader::new(Flaky(false)), usize::MAX) .err() .expect("a failing read must be reported"); assert_eq!( err.to_string(), "the disk went away", "a failing read must not be swallowed" );
// the limit counts characters, not bytes, and a line of exactly `limit` is fine let text = "h\u{e9}llo\nsix\u{e9}\u{e9}\u{e9}\n"; let got = stats(text.as_bytes(), 5).unwrap(); assert_eq!( got, Stats { lines: 2, blank: 0, longest: 6, stopped: true }, "5 characters are not over a limit of 5; 6 characters are" );
// stop at the long line: the reader fails if it is asked for more bytes let data = b"ok\n\nthis line is too long\nnever looked at \xff\n"; let got = stats(BufReader::new(RunsDry(data)), 10) .expect("stop at the long line; do not read the rest of the input"); assert_eq!( got, Stats { lines: 3, blank: 1, longest: 21, stopped: true } );
println!("all checks passed");}Open in the Rust Playground ↗Run it first: it stops at todo!() with not yet implemented and a hint, and the compiler warns that the parameter is unused. Both are expected. Replace the todo!() with your code and run it again until it prints all checks passed.
Show a solution
use std::io::{self, BufRead, BufReader, Read};
#[derive(Debug, PartialEq)]struct Stats { /// Lines read. A last line without a newline still counts. lines: usize, /// Lines with no characters at all once the line ending is gone. blank: usize, /// The most characters (not bytes) in one line, not counting its ending. longest: usize, /// True if reading stopped at a line longer than the limit. stopped: bool,}
/// Read `reader` one line at a time. It may be a file, standard input or a byte slice./// An unreadable line (bad UTF-8, a failing disk) is an error for the caller./// Stop at the first line with more than `limit` characters: count that line, then/// read no further, so the rest of a huge file is never touched.fn stats<R: BufRead>(reader: R, limit: usize) -> io::Result<Stats> { let mut found = Stats { lines: 0, blank: 0, longest: 0, stopped: false, }; for line in reader.lines() { let line = line?; let chars = line.chars().count(); found.lines += 1; if line.is_empty() { found.blank += 1; } found.longest = found.longest.max(chars); if chars > limit { found.stopped = true; break; } } Ok(found)}
/// A reader that hands out some bytes and then fails.struct Flaky(bool);
impl Read for Flaky { fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> { if self.0 { return Err(io::Error::other("the disk went away")); } self.0 = true; let data = b"one\ntwo\n"; buf[..data.len()].copy_from_slice(data); Ok(data.len()) }}
/// A reader that hands out its bytes and then fails. A program that reads everything before/// it looks at a line runs into the failure; one that stops at the long line never does.struct RunsDry<'a>(&'a [u8]);
impl Read for RunsDry<'_> { fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> { if self.0.is_empty() { return Err(io::Error::other("read past the long line")); } let n = buf.len().min(self.0.len()); buf[..n].copy_from_slice(&self.0[..n]); self.0 = &self.0[n..]; Ok(n) }}
fn check(input: &[u8], lines: usize, blank: usize, longest: usize) { let got = stats(input, usize::MAX).unwrap(); assert_eq!( got, Stats { lines, blank, longest, stopped: false }, "input {:?}", String::from_utf8_lossy(input) );}
fn main() { check(b"", 0, 0, 0); check(b"one\ntwo\n", 2, 0, 3); check(b"a\nbc", 2, 0, 2); // no newline at the end check("h\u{e9}llo\r\n\r\nx\r\n".as_bytes(), 3, 1, 5); // CRLF, and \u{e9} is 2 bytes but 1 char check(b" \n", 1, 0, 3); // spaces are characters check(b"\n\n", 2, 2, 0);
let err = stats(&b"good\nbad \xff\nmore\n"[..], usize::MAX) .err() .expect("bad UTF-8 must be reported"); assert_eq!( err.kind(), io::ErrorKind::InvalidData, "bad UTF-8 must be reported" );
// BufReader with a tiny buffer: a line is longer than the buffer let long = "x".repeat(1000) + "\nshort\n"; let small = BufReader::with_capacity(8, long.as_bytes()); assert_eq!( stats(small, usize::MAX).unwrap(), Stats { lines: 2, blank: 0, longest: 1000, stopped: false } );
let err = stats(BufReader::new(Flaky(false)), usize::MAX) .err() .expect("a failing read must be reported"); assert_eq!( err.to_string(), "the disk went away", "a failing read must not be swallowed" );
// the limit counts characters, not bytes, and a line of exactly `limit` is fine let text = "h\u{e9}llo\nsix\u{e9}\u{e9}\u{e9}\n"; let got = stats(text.as_bytes(), 5).unwrap(); assert_eq!( got, Stats { lines: 2, blank: 0, longest: 6, stopped: true }, "5 characters are not over a limit of 5; 6 characters are" );
// stop at the long line: the reader fails if it is asked for more bytes let data = b"ok\n\nthis line is too long\nnever looked at \xff\n"; let got = stats(BufReader::new(RunsDry(data)), 10) .expect("stop at the long line; do not read the rest of the input"); assert_eq!( got, Stats { lines: 3, blank: 1, longest: 21, stopped: true } );
println!("all checks passed");}all checks passed
Replace a file without ever showing half of it
Write save_with(path, fill): create a temporary file next to path with create_new (another name if one is taken), let fill write into it, then rename it over path. The checks look at the directory while fill runs, keep a reader open from before the save, make fill fail half way, put a file that is not yours where settings.txt.tmp would go, and use a missing directory. A thread opens the target in a loop through 500 saves, so removing the file before the rename fails (by chance, not certainly; a correct solution never trips it). Writing into the target, a temporary file elsewhere, a leftover and a fixed temporary name also fail.
use std::fs::{self, File};use std::io::{self, Read, Write};use std::path::{Path, PathBuf};use std::sync::Arc;use std::sync::atomic::{AtomicBool, Ordering};use std::thread;
/// Replace the file at `path` with whatever `fill` writes, so that anyone reading `path`/// sees the old file until the new one is complete. If `fill` or anything else fails, the/// old file must be untouched and no temporary file of yours may be left behind./// The temporary file lives next to `path`. Create it with `create_new` and try another/// name if the first is taken: you must never truncate or delete a file that is not yours.fn save_with<F>(path: &Path, fill: F) -> io::Result<()>where F: FnOnce(&mut File) -> io::Result<()>,{ todo!( "create_new a temporary file next to path (another name if it exists), fill it, then rename it over path" )}
/// A fresh directory for one check, removed at the end.struct Scratch(PathBuf);
impl Scratch { fn new(name: &str) -> Scratch { let dir = std::env::temp_dir().join(format!("lr-save-with-{name}-{}", std::process::id())); let _ = fs::remove_dir_all(&dir); fs::create_dir_all(&dir).unwrap(); Scratch(dir) }
fn entries(&self) -> Vec<String> { let mut names: Vec<String> = fs::read_dir(&self.0) .unwrap() .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) .collect(); names.sort(); names }}
impl Drop for Scratch { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.0); }}
fn main() { // a new file let s = Scratch::new("new"); let target = s.0.join("settings.txt"); save_with(&target, |f| f.write_all(b"fresh")).unwrap(); assert_eq!(fs::read_to_string(&target).unwrap(), "fresh"); assert_eq!( s.entries(), ["settings.txt"], "no temporary file may be left" );
// replacing an old file: while `fill` runs, readers still see the old contents, // and the temporary file sits in the same directory (a rename cannot cross mount points) let s = Scratch::new("replace"); let target = s.0.join("settings.txt"); fs::write(&target, "old").unwrap(); let mut reader = File::open(&target).unwrap(); // a reader that opened the file before the save let mut calls = 0; save_with(&target, |f| { calls += 1; f.write_all(b"new")?; assert_eq!( fs::read_to_string(&target).unwrap(), "old", "the old file must stay readable" ); assert_eq!( s.entries().len(), 2, "the temporary file belongs next to the target" ); Ok(()) }) .unwrap(); assert_eq!(calls, 1); let mut seen = String::new(); reader.read_to_string(&mut seen).unwrap(); assert_eq!( seen, "old", "rename a new file over the path; do not write into the old one" ); assert_eq!(fs::read_to_string(&target).unwrap(), "new"); assert_eq!(s.entries(), ["settings.txt"]);
// a failure half way: the old file survives and nothing is left over let s = Scratch::new("failure"); let target = s.0.join("settings.txt"); fs::write(&target, "old").unwrap(); let err = save_with(&target, |f| { f.write_all(b"half of the ne")?; Err(io::Error::other("disk full")) }) .unwrap_err(); assert_eq!( err.to_string(), "disk full", "the error from fill must come back" ); assert_eq!( fs::read_to_string(&target).unwrap(), "old", "a failed save must not change the file" ); assert_eq!(s.entries(), ["settings.txt"], "a failed save must clean up");
// a file that is not yours sits where the obvious temporary name would go let s = Scratch::new("stranger"); let target = s.0.join("settings.txt"); let stranger = s.0.join("settings.txt.tmp"); fs::write(&stranger, "somebody else's").unwrap(); save_with(&target, |f| f.write_all(b"mine")).unwrap(); assert_eq!(fs::read_to_string(&target).unwrap(), "mine"); assert_eq!( fs::read_to_string(&stranger).expect("the other file must still be there"), "somebody else's", "never truncate or delete a file you did not create" ); save_with(&target, |_| Err(io::Error::other("again"))).unwrap_err(); assert_eq!( s.entries(), ["settings.txt", "settings.txt.tmp"], "a failed save must leave the other file alone too" );
// a directory that does not exist is an error, not a panic let s = Scratch::new("missing"); let err = save_with(&s.0.join("no/such/dir/x.txt"), |f| f.write_all(b"x")).unwrap_err(); assert_eq!(err.kind(), io::ErrorKind::NotFound); assert!(s.entries().is_empty());
// there is no instant at which the path does not exist: a thread opens it in a loop // while it is saved 500 times. Deleting first and renaming after leaves a gap; the thread // can only fall into it by chance, so this check is likely to catch a gap, not certain let s = Scratch::new("never-missing"); let target = s.0.join("settings.txt"); fs::write(&target, "0").unwrap(); let stop = Arc::new(AtomicBool::new(false)); let watcher = { let (target, stop) = (target.clone(), Arc::clone(&stop)); thread::spawn(move || { let mut missing = 0; while !stop.load(Ordering::Relaxed) { if File::open(&target).is_err() { missing += 1; } } missing }) }; for i in 1..=500 { save_with(&target, |f| f.write_all(i.to_string().as_bytes())).unwrap(); } stop.store(true, Ordering::Relaxed); assert_eq!( watcher.join().unwrap(), 0, "the path was missing while a save was running: rename over it, do not remove it first" ); assert_eq!(fs::read_to_string(&target).unwrap(), "500");
println!("all checks passed");}Open in the Rust Playground ↗Run it first: it stops at todo!() with not yet implemented and a hint, and the compiler warns that the parameter is unused. Both are expected. Replace the todo!() with your code and run it again until it prints all checks passed.
Show a solution
use std::fs::{self, File};use std::io::{self, Read, Write};use std::path::{Path, PathBuf};use std::sync::Arc;use std::sync::atomic::{AtomicBool, Ordering};use std::thread;
/// Replace the file at `path` with whatever `fill` writes, so that anyone reading `path`/// sees the old file until the new one is complete. If `fill` or anything else fails, the/// old file must be untouched and no temporary file of yours may be left behind./// The temporary file lives next to `path`. Create it with `create_new` and try another/// name if the first is taken: you must never truncate or delete a file that is not yours.fn save_with<F>(path: &Path, fill: F) -> io::Result<()>where F: FnOnce(&mut File) -> io::Result<()>,{ let name = path.file_name().expect("a file path").to_string_lossy(); let mut attempt = 0; let (mut f, tmp) = loop { let tmp = path.with_file_name(format!("{name}.{}.{attempt}.tmp", std::process::id())); match fs::OpenOptions::new() .write(true) .create_new(true) .open(&tmp) { Ok(f) => break (f, tmp), Err(e) if e.kind() == io::ErrorKind::AlreadyExists => attempt += 1, Err(e) => return Err(e), } };
let result = (|| { fill(&mut f)?; f.sync_all()?; fs::rename(&tmp, path) })(); if result.is_err() { let _ = fs::remove_file(&tmp); } result}
/// A fresh directory for one check, removed at the end.struct Scratch(PathBuf);
impl Scratch { fn new(name: &str) -> Scratch { let dir = std::env::temp_dir().join(format!("lr-save-with-{name}-{}", std::process::id())); let _ = fs::remove_dir_all(&dir); fs::create_dir_all(&dir).unwrap(); Scratch(dir) }
fn entries(&self) -> Vec<String> { let mut names: Vec<String> = fs::read_dir(&self.0) .unwrap() .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) .collect(); names.sort(); names }}
impl Drop for Scratch { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.0); }}
fn main() { // a new file let s = Scratch::new("new"); let target = s.0.join("settings.txt"); save_with(&target, |f| f.write_all(b"fresh")).unwrap(); assert_eq!(fs::read_to_string(&target).unwrap(), "fresh"); assert_eq!( s.entries(), ["settings.txt"], "no temporary file may be left" );
// replacing an old file: while `fill` runs, readers still see the old contents, // and the temporary file sits in the same directory (a rename cannot cross mount points) let s = Scratch::new("replace"); let target = s.0.join("settings.txt"); fs::write(&target, "old").unwrap(); let mut reader = File::open(&target).unwrap(); // a reader that opened the file before the save let mut calls = 0; save_with(&target, |f| { calls += 1; f.write_all(b"new")?; assert_eq!( fs::read_to_string(&target).unwrap(), "old", "the old file must stay readable" ); assert_eq!( s.entries().len(), 2, "the temporary file belongs next to the target" ); Ok(()) }) .unwrap(); assert_eq!(calls, 1); let mut seen = String::new(); reader.read_to_string(&mut seen).unwrap(); assert_eq!( seen, "old", "rename a new file over the path; do not write into the old one" ); assert_eq!(fs::read_to_string(&target).unwrap(), "new"); assert_eq!(s.entries(), ["settings.txt"]);
// a failure half way: the old file survives and nothing is left over let s = Scratch::new("failure"); let target = s.0.join("settings.txt"); fs::write(&target, "old").unwrap(); let err = save_with(&target, |f| { f.write_all(b"half of the ne")?; Err(io::Error::other("disk full")) }) .unwrap_err(); assert_eq!( err.to_string(), "disk full", "the error from fill must come back" ); assert_eq!( fs::read_to_string(&target).unwrap(), "old", "a failed save must not change the file" ); assert_eq!(s.entries(), ["settings.txt"], "a failed save must clean up");
// a file that is not yours sits where the obvious temporary name would go let s = Scratch::new("stranger"); let target = s.0.join("settings.txt"); let stranger = s.0.join("settings.txt.tmp"); fs::write(&stranger, "somebody else's").unwrap(); save_with(&target, |f| f.write_all(b"mine")).unwrap(); assert_eq!(fs::read_to_string(&target).unwrap(), "mine"); assert_eq!( fs::read_to_string(&stranger).expect("the other file must still be there"), "somebody else's", "never truncate or delete a file you did not create" ); save_with(&target, |_| Err(io::Error::other("again"))).unwrap_err(); assert_eq!( s.entries(), ["settings.txt", "settings.txt.tmp"], "a failed save must leave the other file alone too" );
// a directory that does not exist is an error, not a panic let s = Scratch::new("missing"); let err = save_with(&s.0.join("no/such/dir/x.txt"), |f| f.write_all(b"x")).unwrap_err(); assert_eq!(err.kind(), io::ErrorKind::NotFound); assert!(s.entries().is_empty());
// there is no instant at which the path does not exist: a thread opens it in a loop // while it is saved 500 times. Deleting first and renaming after leaves a gap; the thread // can only fall into it by chance, so this check is likely to catch a gap, not certain let s = Scratch::new("never-missing"); let target = s.0.join("settings.txt"); fs::write(&target, "0").unwrap(); let stop = Arc::new(AtomicBool::new(false)); let watcher = { let (target, stop) = (target.clone(), Arc::clone(&stop)); thread::spawn(move || { let mut missing = 0; while !stop.load(Ordering::Relaxed) { if File::open(&target).is_err() { missing += 1; } } missing }) }; for i in 1..=500 { save_with(&target, |f| f.write_all(i.to_string().as_bytes())).unwrap(); } stop.store(true, Ordering::Relaxed); assert_eq!( watcher.join().unwrap(), 0, "the path was missing while a save was running: rename over it, do not remove it first" ); assert_eq!(fs::read_to_string(&target).unwrap(), "500");
println!("all checks passed");}all checks passed
Group a directory by extension
Write group_by_extension: the regular files directly inside a directory (one level), grouped by lower-cased extension without the dot, names sorted. The directory holds .hidden, archive.tar.gz, a.TXT, a subdirectory with a file in it and a file whose extension is not UTF-8, so it needs Linux. Splitting at the last dot, forgetting directories or the case, keeping the dot and skipping or unwrapping the odd extension all fail. Forgetting the sort fails only where read_dir returns unsorted names, which depends on the file system.
use std::collections::BTreeMap;use std::fs;use std::io;use std::path::{Path, PathBuf};
/// Group the regular files directly inside `dir` by extension./// The key is the extension in lower case and without the dot (`Path::extension` already/// leaves it off, where Python's `suffix` and C++'s `extension()` keep it), or "" for a file/// without one; each list holds file names in sorted order. Directories and anything nested/// deeper are left out. A name or extension that is not valid UTF-8 goes through/// `to_string_lossy`. Linux only: macOS refuses to create a name that is not UTF-8.fn group_by_extension(dir: &Path) -> io::Result<BTreeMap<String, Vec<String>>> { todo!("read_dir, skip non-files, take Path::extension and file_name from each entry")}
struct Scratch(PathBuf);
impl Drop for Scratch { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.0); }}
fn main() { use std::ffi::OsStr; use std::os::unix::ffi::OsStrExt;
let dir = std::env::temp_dir().join(format!("lr-by-extension-{}", std::process::id())); let _ = fs::remove_dir_all(&dir); fs::create_dir_all(dir.join("sub.d")).unwrap(); let scratch = Scratch(dir.clone()); // created in no particular order: some file systems list newest first for name in [ "m.txt", "b.txt", "q.txt", "a.TXT", "Makefile", "t.txt", ".hidden", "archive.tar.gz", "f.txt", "notes.md", "z.txt", "d.Txt", "k.txt", ] { fs::write(dir.join(name), name).unwrap(); } fs::write(dir.join("sub.d/inner.txt"), "nested").unwrap(); // the extension is not UTF-8: Linux file names are bytes fs::write(dir.join(OsStr::from_bytes(b"cafe.\xe9")), "not utf-8").unwrap();
let got = group_by_extension(&dir).unwrap(); let show = |key: &str| got.get(key).cloned().unwrap_or_default(); // sorting is checked by the order read_dir happens to return, which depends on the // file system: a solution that forgets the sort may pass where that order is sorted assert_eq!( show("txt"), [ "a.TXT", "b.txt", "d.Txt", "f.txt", "k.txt", "m.txt", "q.txt", "t.txt", "z.txt" ], "txt: lower-cased key, sorted names, nothing nested" ); assert_eq!( show("\u{fffd}"), ["cafe.\u{fffd}"], "an extension that is not UTF-8 still gets a key" ); assert_eq!( show("gz"), ["archive.tar.gz"], "only the last extension counts" ); assert_eq!(show("md"), ["notes.md"]); assert_eq!( show(""), [".hidden", "Makefile"], "a leading dot is not an extension" ); assert_eq!(got.len(), 5, "keys: {:?}", got.keys().collect::<Vec<_>>());
let err = group_by_extension(&dir.join("nope")).unwrap_err(); assert_eq!(err.kind(), io::ErrorKind::NotFound);
drop(scratch); println!("all checks passed");}Open in the Rust Playground ↗Run it first: it stops at todo!() with not yet implemented and a hint, and the compiler warns that the parameter is unused. Both are expected. Replace the todo!() with your code and run it again until it prints all checks passed.
Show a solution
use std::collections::BTreeMap;use std::fs;use std::io;use std::path::{Path, PathBuf};
/// Group the regular files directly inside `dir` by extension./// The key is the extension in lower case and without the dot (`Path::extension` already/// leaves it off, where Python's `suffix` and C++'s `extension()` keep it), or "" for a file/// without one; each list holds file names in sorted order. Directories and anything nested/// deeper are left out. A name or extension that is not valid UTF-8 goes through/// `to_string_lossy`. Linux only: macOS refuses to create a name that is not UTF-8.fn group_by_extension(dir: &Path) -> io::Result<BTreeMap<String, Vec<String>>> { let mut groups: BTreeMap<String, Vec<String>> = BTreeMap::new(); for entry in fs::read_dir(dir)? { let entry = entry?; if !entry.file_type()?.is_file() { continue; } let path = entry.path(); let key = path .extension() .map(|e| e.to_string_lossy().to_lowercase()) .unwrap_or_default(); groups .entry(key) .or_default() .push(entry.file_name().to_string_lossy().into_owned()); } for names in groups.values_mut() { names.sort(); } Ok(groups)}
struct Scratch(PathBuf);
impl Drop for Scratch { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.0); }}
fn main() { use std::ffi::OsStr; use std::os::unix::ffi::OsStrExt;
let dir = std::env::temp_dir().join(format!("lr-by-extension-{}", std::process::id())); let _ = fs::remove_dir_all(&dir); fs::create_dir_all(dir.join("sub.d")).unwrap(); let scratch = Scratch(dir.clone()); // created in no particular order: some file systems list newest first for name in [ "m.txt", "b.txt", "q.txt", "a.TXT", "Makefile", "t.txt", ".hidden", "archive.tar.gz", "f.txt", "notes.md", "z.txt", "d.Txt", "k.txt", ] { fs::write(dir.join(name), name).unwrap(); } fs::write(dir.join("sub.d/inner.txt"), "nested").unwrap(); // the extension is not UTF-8: Linux file names are bytes fs::write(dir.join(OsStr::from_bytes(b"cafe.\xe9")), "not utf-8").unwrap();
let got = group_by_extension(&dir).unwrap(); let show = |key: &str| got.get(key).cloned().unwrap_or_default(); // sorting is checked by the order read_dir happens to return, which depends on the // file system: a solution that forgets the sort may pass where that order is sorted assert_eq!( show("txt"), [ "a.TXT", "b.txt", "d.Txt", "f.txt", "k.txt", "m.txt", "q.txt", "t.txt", "z.txt" ], "txt: lower-cased key, sorted names, nothing nested" ); assert_eq!( show("\u{fffd}"), ["cafe.\u{fffd}"], "an extension that is not UTF-8 still gets a key" ); assert_eq!( show("gz"), ["archive.tar.gz"], "only the last extension counts" ); assert_eq!(show("md"), ["notes.md"]); assert_eq!( show(""), [".hidden", "Makefile"], "a leading dot is not an extension" ); assert_eq!(got.len(), 5, "keys: {:?}", got.keys().collect::<Vec<_>>());
let err = group_by_extension(&dir.join("nope")).unwrap_err(); assert_eq!(err.kind(), io::ErrorKind::NotFound);
drop(scratch); println!("all checks passed");}all checks passed
Read more
- The Rust Book, chapter 12: An I/O project and Rust by Example: File I/O.
- The standard library:
std::io,File,OpenOptions,BufReader,BufWriter,Path,OsStr,ErrorKind. - The traits:
Read,Write,BufRead,Seek,Cursor,io::copy. fs::rename,fs::read_dir, and thetempfilecrate (docs.rs) for unique temporary files.- Errors: E0599 (the gotcha), or this site’s index.
Learn Rust is an independent project, not affiliated with the Rust Project or the Rust Foundation. Code and text are under the MIT licence.Source on GitHub.