File I/O with std::fs
20 min read
Quick Overview
Section titled “Quick Overview”Almost every command-line tool reads or writes files: a linter slurps source, a log processor streams gigabytes, a config tool writes back settings. In Node you reach for fs.readFileSync, fs.promises.readFile, or streams; in Rust the equivalent lives in the standard library’s std::fs and std::io modules — no crate required. The two big differences for a TypeScript/JavaScript developer: every fallible operation returns a Result you must handle (no silent ENOENT), and Rust draws a sharp line between cheap whole-file helpers and explicit buffered readers/writers for streaming.
This page covers the everyday operations: reading and writing whole files, buffering with BufReader/BufWriter, and reading a file line by line. Turning a string path into a real, cross-platform Path is covered in Path handling; reading configuration from the environment is in Environment variables.
The current stable toolchain is Rust 1.96.0 on the latest stable edition (2024); cargo new selects it automatically. Everything here is in the standard library, so there are no dependencies to add.
TypeScript/JavaScript Example
Section titled “TypeScript/JavaScript Example”Here is a small log-filter tool in Node. It reads a file, keeps the lines containing a search term, writes them to a second file, and appends a one-line summary to an audit log.
// filter.ts — run with: npx tsx filter.ts access.log errors.log 500// Uses only Node's built-in `node:fs` — no npm install needed.import { readFileSync, writeFileSync, appendFileSync } from "node:fs";
const [input, output, needle] = process.argv.slice(2);if (!input || !output || !needle) { console.error("usage: filter <input> <output> <needle>"); process.exit(2);}
// Read the whole file into a string (UTF-8 by default).const text = readFileSync(input, "utf8");
// Filter the lines.const matches = text.split("\n").filter((line) => line.includes(needle));
// Write the result and append a summary.writeFileSync(output, matches.join("\n") + "\n");appendFileSync("audit.log", `filtered ${matches.length} lines from ${input}\n`);
console.log(`wrote ${matches.length} matching line(s) to ${output}`);$ npx tsx filter.ts access.log errors.log 500wrote 2 matching line(s) to errors.logThis is idiomatic Node, but it has two quiet hazards that Rust forces you to confront. First, readFileSync loads the entire file into memory — fine for a 4 KB config, a problem for a 4 GB log. Second, if input does not exist, readFileSync throws and the error propagates as an uncaught exception; nothing in the type system reminded you to handle it.
Rust Equivalent
Section titled “Rust Equivalent”The direct translation uses the whole-file helpers fs::read_to_string, fs::write, and OpenOptions for appending. Notice the ? after every file call and the -> io::Result<()> on main.
use std::env;use std::fs::{self, OpenOptions};use std::io::{self, Write};use std::process::ExitCode;
fn run() -> io::Result<()> { let args: Vec<String> = env::args().collect(); let (input, output, needle) = match args.as_slice() { [_, i, o, n] => (i, o, n), _ => { eprintln!("usage: filter <input> <output> <needle>"); std::process::exit(2); } };
// Read the whole file as a UTF-8 String. `?` returns early on any I/O error. let text = fs::read_to_string(input)?;
// Filter the lines. `lines()` is an iterator; `collect` into a Vec<&str>. let matches: Vec<&str> = text.lines().filter(|line| line.contains(needle)).collect();
// Write the result in one call. Joining with '\n' rebuilds the file body. fs::write(output, matches.join("\n") + "\n")?;
// Append a summary line, creating the audit log if needed. let mut audit = OpenOptions::new().create(true).append(true).open("audit.log")?; writeln!(audit, "filtered {} lines from {input}", matches.len())?;
println!("wrote {} matching line(s) to {output}", matches.len()); Ok(())}
fn main() -> ExitCode { match run() { Ok(()) => ExitCode::SUCCESS, Err(e) => { eprintln!("error: {e}"); ExitCode::FAILURE } }}$ cargo run --quiet -- access.log errors.log 500wrote 2 matching line(s) to errors.logThis is correct and concise, and it mirrors the Node version closely. But like the Node version, it reads the whole file into RAM. The Real-World Example below rewrites it to stream the file with BufReader, so memory stays flat no matter how large the input is.
Detailed Explanation
Section titled “Detailed Explanation”Whole-file helpers: read_to_string, read, write
Section titled “Whole-file helpers: read_to_string, read, write”std::fs gives you three one-call helpers that open, do the work, and close the file for you. They are the equivalent of Node’s readFileSync / writeFileSync:
use std::fs;use std::io;
fn main() -> io::Result<()> { fs::write("notes.txt", "first line\nsecond line\nthird line\n")?;
// Read the file as a UTF-8 String. Errors if the bytes are not valid UTF-8. let contents: String = fs::read_to_string("notes.txt")?; println!("--- read_to_string ---"); print!("{contents}");
// Read the file as raw bytes. Never fails on encoding — bytes are bytes. let bytes: Vec<u8> = fs::read("notes.txt")?; println!("--- read (bytes) ---"); println!("{} bytes", bytes.len());
// `lines()` on a String/str splits on '\n' (and trims a trailing '\r'). let count = contents.lines().count(); println!("line count: {count}");
fs::remove_file("notes.txt")?; Ok(())}--- read_to_string ---first linesecond linethird line--- read (bytes) ---34 bytesline count: 3fs::write(path, data)accepts anything that isAsRef<[u8]>— a&str, aString, a&[u8], or aVec<u8>. It truncates and overwrites, exactly likewriteFileSyncwith no flag. It creates the file if it does not exist.fs::read_to_string(path)returnsio::Result<String>. It fails withErrorKind::InvalidDataif the file is not valid UTF-8 — Rust will not hand you a half-broken string. Usefs::readfor arbitrary bytes.fs::read(path)returnsio::Result<Vec<u8>>and is the binary-safe counterpart, likereadFileSync(path)with no encoding argument (which returns aBufferin Node).
Note:
String::lines()is the precise analogue of JavaScript’stext.split("\n"), with two refinements: it does not yield a trailing empty string when the file ends in a newline, and it strips a trailing\rso Windows\r\nfiles just work. We lean on that for cross-platform line handling — see Cross-platform considerations.
The ? operator and io::Result
Section titled “The ? operator and io::Result”Every fallible call returns Result<T, std::io::Error>, aliased as io::Result<T>. The ? operator unwraps the Ok value or returns the Err from the current function. Because main here is declared -> io::Result<()>, an unhandled error is printed via its Debug representation and the process exits non-zero. The mechanics of ? are covered in depth in The ? operator; for an error type that adds context, see anyhow and thiserror.
Buffered I/O: BufReader and BufWriter
Section titled “Buffered I/O: BufReader and BufWriter”A raw File performs one system call per read or write. Writing 100,000 lines directly to a File means 100,000 write(2) syscalls — slow. BufWriter batches them into a memory buffer (8 KB by default) and flushes in big chunks; BufReader does the symmetric thing for reads. This is the explicit version of what Node’s stream layer does for you under the hood.
use std::fs::File;use std::io::{self, BufRead, BufReader, BufWriter, Write};
fn main() -> io::Result<()> { // --- buffered writing --- let file = File::create("log.txt")?; let mut writer = BufWriter::new(file); for i in 1..=5 { // `writeln!` writes into the buffer, not straight to disk. writeln!(writer, "event {i}")?; } writer.flush()?; // push the buffer to disk before we read it back
// --- buffered, line-by-line reading --- let file = File::open("log.txt")?; let reader = BufReader::new(file);
let mut total = 0usize; for line in reader.lines() { let line = line?; // each item is io::Result<String> if line.contains('3') { println!("matched: {line}"); } total += 1; } println!("read {total} lines");
std::fs::remove_file("log.txt")?; Ok(())}matched: event 3read 5 linesKey points:
File::createopens for writing, truncating any existing file;File::openopens read-only and errors if the file is missing.writeln!/write!are macros that work on anyWritetarget (aBufWriter, aFile, evenVec<u8>). They are the file-writing cousins ofprintln!/print!. You must bring theWritetrait into scope withuse std::io::Writeto call them — see the pitfall below.reader.lines()requires theBufReadtrait (use std::io::BufRead). It yieldsio::Result<String>items — each line is a freshly allocated, ownedStringwith the line terminator stripped.
Reading lines: three approaches, three trade-offs
Section titled “Reading lines: three approaches, three trade-offs”There is more than one way to iterate lines, and the right choice depends on file size and whether you need owned strings.
use std::fs::{self, File};use std::io::{self, BufRead, BufReader, Write};
fn main() -> io::Result<()> { fs::write("audit.log", "boot\n")?;
// Append mode — like fs.appendFile / { flags: "a" } in Node. let mut f = fs::OpenOptions::new().append(true).open("audit.log")?; writeln!(f, "user logged in")?; writeln!(f, "user logged out")?; drop(f);
// Reuse one String buffer across reads to avoid a per-line allocation. let file = File::open("audit.log")?; let mut reader = BufReader::new(file); let mut buf = String::new(); let mut n = 0; while reader.read_line(&mut buf)? != 0 { print!("{n}: {buf}"); buf.clear(); // crucial: read_line APPENDS, it does not overwrite n += 1; }
fs::remove_file("audit.log")?; Ok(())}0: boot1: user logged in2: user logged outThe three approaches, ranked from simplest to fastest:
fs::read_to_string(path)?.lines()— read it all, then iterate&strslices. Zero per-line allocation, but the whole file is in memory. Best for small-to-medium files.BufReader::new(file).lines()— streams the file, allocating a newStringper line. The most readable for big files; the allocation is usually negligible.reader.read_line(&mut buf)in a loop — streams the file and reuses one buffer, the lowest-allocation option. Noteread_lineappends tobuf(it does not clear it) and keeps the trailing\n, so you callbuf.clear()each iteration. This is the hot-loop choice for multi-gigabyte inputs.
OpenOptions: append, and everything File::create/open cannot express
Section titled “OpenOptions: append, and everything File::create/open cannot express”File::open and File::create are shorthands. For anything else — append mode, create-if-missing-but-don’t-truncate, create-only-if-new — use OpenOptions, the builder equivalent of Node’s fs.open(path, flags):
Node flags | OpenOptions builder |
|---|---|
"r" | OpenOptions::new().read(true) (or just File::open) |
"w" | OpenOptions::new().write(true).create(true).truncate(true) (or File::create) |
"a" | OpenOptions::new().append(true).create(true) |
"wx" (fail if exists) | OpenOptions::new().write(true).create_new(true) |
.append(true) implies write and seeks to the end before every write — concurrent appends from multiple processes do not clobber each other on most platforms.
Key Differences
Section titled “Key Differences”| Concern | Node.js (node:fs) | Rust (std::fs / std::io) |
|---|---|---|
| Missing-file handling | Throws (sync) / rejects (async); easy to forget | Returns Result; the compiler warns if you ignore it |
| Default read result | Buffer, or string with "utf8" | Vec<u8> (fs::read) or String (fs::read_to_string) |
| Invalid UTF-8 | Silently replaced with � in "utf8" mode | read_to_string errors with ErrorKind::InvalidData |
| Buffering | Automatic in the streams layer | Explicit: wrap in BufReader / BufWriter |
| Flushing | Handled by stream end()/GC | You call .flush() (or rely on Drop, which can hide errors) |
| Line splitting | text.split("\n") (keeps trailing empty, keeps \r) | str::lines() (drops trailing empty, strips \r) |
| Sync vs async | readFileSync vs fs.promises / streams | std::fs is blocking; async needs tokio::fs |
std::fs is blocking — and that is fine for a CLI
Section titled “std::fs is blocking — and that is fine for a CLI”Every std::fs call blocks the current thread until the OS finishes. For a typical CLI tool that does its work and exits, blocking is exactly what you want — it is simpler and faster than an async runtime. You only need tokio::fs (covered in Section 11: Async) when file I/O happens inside an async server that must keep serving other requests. Do not reach for async file I/O in a command-line tool by reflex; in JavaScript the async API is the default, in Rust the blocking API is the default for CLIs.
Common Pitfalls
Section titled “Common Pitfalls”Ignoring the Result from a write
Section titled “Ignoring the Result from a write”In JavaScript, writeFileSync either works or throws; you can fire and forget. In Rust, a Result you do not use triggers a warning, because the write may have silently failed (disk full, permission denied).
use std::fs;
fn main() { // compiles, but with a warning (unused `Result` that must be used) fs::write("out.txt", "data"); println!("done");}warning: unused `Result` that must be used --> src/main.rs:5:5 |5 | fs::write("out.txt", "data"); | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ | = note: this `Result` may be an `Err` variant, which should be handled = note: `#[warn(unused_must_use)]` on by defaulthelp: use `let _ = ...` to ignore the resulting value |5 | let _ = fs::write("out.txt", "data"); | +++++++Handle it with ? (and an io::Result return type) or .expect("..."). Only use let _ = when you have genuinely decided the failure is irrelevant.
Forgetting use std::io::Write (or BufRead)
Section titled “Forgetting use std::io::Write (or BufRead)”writeln! on a writer needs the Write trait in scope; .lines() / .read_line() need BufRead. Without the import, the methods appear not to exist:
use std::fs::File;use std::io::BufWriter; // missing: use std::io::Write;
fn main() -> std::io::Result<()> { let mut w = BufWriter::new(File::create("x.txt")?); // does not compile (error[E0599]: cannot write into `BufWriter<File>`) writeln!(w, "hello")?; Ok(())}The real error names the trait you forgot (the ::: line points into your local toolchain’s copy of the standard library, so its path will differ on your machine):
error[E0599]: cannot write into `BufWriter<File>` --> src/main.rs:6:14 | 6 | writeln!(w, "hello")?; | ^ | ::: /home/you/.rustup/toolchains/stable/lib/rustlib/src/rust/library/std/src/io/mod.rs:1950:8 |1950 | fn write_fmt(&mut self, args: fmt::Arguments<'_>) -> Result<()> { | --------- the method is available for `BufWriter<File>` here |note: must implement `io::Write`, `fmt::Write`, or have a `write_fmt` method --> src/main.rs:6:14 | 6 | writeln!(w, "hello")?; | ^ = help: items from traits can only be used if the trait is in scopehelp: trait `Write` which provides `write_fmt` is implemented but not in scope; perhaps you want to import it | 1 + use std::io::Write; |The fix is exactly what the compiler suggests: add use std::io::Write;.
Dropping a BufWriter without flushing
Section titled “Dropping a BufWriter without flushing”A BufWriter flushes its buffer when it is dropped — but the flush at drop time cannot return an error, so a failure (disk full, broken pipe) is silently swallowed. Always call .flush()? explicitly when you care whether the bytes actually landed:
use std::fs::File;use std::io::{self, BufWriter, Write};
fn save(path: &str, data: &[&str]) -> io::Result<()> { let mut w = BufWriter::new(File::create(path)?); for line in data { writeln!(w, "{line}")?; } w.flush()?; // surfaces any error HERE, instead of losing it at drop Ok(())}
fn main() -> io::Result<()> { save("ok.txt", &["one", "two"])?; std::fs::remove_file("ok.txt")?; Ok(())}Warning: This is a real correctness bug, not a style nit. Without the explicit
flush(), a program can print “Saved!” and exit 0 while the last buffered chunk never reached disk.
Expecting read_to_string to tolerate non-UTF-8 bytes
Section titled “Expecting read_to_string to tolerate non-UTF-8 bytes”Node’s "utf8" mode quietly substitutes � for invalid bytes; fs::read_to_string refuses and returns an error. Read raw bytes and convert lossily if you want the Node behavior:
use std::fs;use std::io;
fn main() -> io::Result<()> { fs::write("bin.dat", [0x68, 0x69, 0xFF])?; // 0xFF is never valid UTF-8
match fs::read_to_string("bin.dat") { Ok(s) => println!("text: {s}"), Err(e) => println!("read_to_string failed: kind={:?}", e.kind()), }
let raw = fs::read("bin.dat")?; // Best-effort, like Buffer.toString("utf8") with replacement chars. println!("lossy: {}", String::from_utf8_lossy(&raw));
fs::remove_file("bin.dat")?; Ok(())}read_to_string failed: kind=InvalidDatalossy: hi�Forgetting that read_line keeps the newline and appends
Section titled “Forgetting that read_line keeps the newline and appends”A surprising number of bugs come from read_line not behaving like a “give me the next line, trimmed” function. It appends to the buffer (so you must buf.clear() each loop) and it retains the trailing \n (use line.trim_end() if you need it gone). The .lines() iterator, by contrast, strips the terminator for you.
Best Practices
Section titled “Best Practices”- Match the tool to the file size. Reach for
fs::read_to_string/fs::writefor small files (configs, single source files). Switch toBufReader/BufWriterthe moment a file could be large or unbounded, like a log stream. - Always wrap a
FileinBufReader/BufWriterwhen you read or write in a loop. Unbuffered per-iteration syscalls are the most common accidental performance cliff. - Call
.flush()?explicitly on anyBufWriterwhose success you report to the user; do not rely on the silent drop-time flush. - Return
io::Result<T>and propagate with?rather than.unwrap()in real tools. Reserve.unwrap()/.expect()for tests and quick prototypes — seeunwrapandexpect. - Match on
error.kind()to recover from expected conditions (a missing optional config file) while still failing loudly on unexpected ones:
use std::fs;use std::io::ErrorKind;
fn load_config() -> String { match fs::read_to_string("config.toml") { Ok(text) => text, Err(e) if e.kind() == ErrorKind::NotFound => { // Missing config is fine — fall back to defaults. String::from("default = true") } Err(e) => { eprintln!("failed to read config: {e}"); std::process::exit(1); } }}
fn main() { println!("config = {:?}", load_config());}config = "default = true"Tip:
ErrorKind::NotFoundis the moral equivalent of checkingerr.code === "ENOENT"in Node, but it is a typed enum variant the compiler knows about — no stringly-typed comparison.
- Use
fs::exists(path)?(stabilized in recent Rust) rather than the olderPath::exists()when you want to distinguish “does not exist” from “exists but I lack permission to check” —fs::existsreturnsio::Result<bool>and surfaces the permission error instead of swallowing it.
Real-World Example
Section titled “Real-World Example”Here is the log-filter from the top, rewritten to stream through BufReader/BufWriter. It processes one line at a time, so a 50 GB log uses the same memory as a 50-byte one. It exits with a meaningful status code, so it composes in shell pipelines — exit codes are covered in Cross-platform considerations.
use std::env;use std::fs::File;use std::io::{self, BufRead, BufReader, BufWriter, Write};use std::process::ExitCode;
/// Stream `input`, writing every line that contains `needle` into `output`./// Memory stays flat regardless of file size — we never hold the whole file,/// only one line at a time.fn filter_file(input: &str, output: &str, needle: &str) -> io::Result<usize> { let reader = BufReader::new(File::open(input)?); let mut writer = BufWriter::new(File::create(output)?);
let mut matches = 0; for line in reader.lines() { let line = line?; if line.contains(needle) { writeln!(writer, "{line}")?; matches += 1; } } writer.flush()?; // make errors surface here, not silently on drop Ok(matches)}
fn main() -> ExitCode { let args: Vec<String> = env::args().collect(); if args.len() != 4 { eprintln!("usage: {} <input> <output> <needle>", args[0]); return ExitCode::from(2); } match filter_file(&args[1], &args[2], &args[3]) { Ok(n) => { println!("wrote {n} matching line(s) to {}", args[2]); ExitCode::SUCCESS } Err(e) => { eprintln!("error: {e}"); ExitCode::FAILURE } }}Given an access.log of:
GET /index 200POST /login 500GET /style.css 200GET /api 500it runs as:
$ cargo run --quiet -- access.log errors.log 500wrote 2 matching line(s) to errors.log
$ cat errors.logPOST /login 500GET /api 500
$ cargo run --quiet -- access.logusage: target/debug/probe <input> <output> <needle>$ echo $?2A missing input file no longer crashes with a stack trace; it is caught by ?, formatted by the Err arm, and turned into exit code 1:
$ cargo run --quiet -- nope.log out.log 500error: No such file or directory (os error 2)$ echo $?1In a real tool you would parse these three positional arguments with clap instead of indexing args by hand — see clap derive API — and you might wrap errors with anyhow to attach the offending path to the message.
Further Reading
Section titled “Further Reading”Official documentation
Section titled “Official documentation”std::fsmodule —read,read_to_string,write,copy,metadata, directory operationsstd::iomodule — theRead,Write, andBufReadtraitsBufReaderandBufWriterOpenOptions— the flag builder for opening filesstd::io::ErrorKind— the typed error categories (NotFound,PermissionDenied, …)- Rust Book, Ch. 12 — building a
grepclone
Related sections of this guide
Section titled “Related sections of this guide”- Path handling — building cross-platform
Path/PathBufvalues to feed these functions - Environment variables — reading config from the environment instead of files
- Cross-platform considerations — line endings,
\r\n, and exit codes - clap derive API — parse the file paths these tools take as arguments
- The
?operator andanyhow/thiserror— robust error propagation - Strings and string slices — the
String/&strdistinction thatread_to_stringandlines()rely on - Section 11: Async — when (and when not) to use
tokio::fsfor non-blocking file I/O
Exercises
Section titled “Exercises”Exercise 1: Line numbering
Section titled “Exercise 1: Line numbering”Difficulty: Beginner
Objective: Practice buffered reading and writing with BufReader/BufWriter.
Instructions: Write a function number_lines(input: &str, output: &str) -> io::Result<()> that copies input to output, prefixing each line with its 1-based number right-aligned in a 4-character field followed by two spaces (so line 1 becomes 1 <text>). Use buffered I/O and propagate errors with ?.
Solution
use std::fs::File;use std::io::{self, BufRead, BufReader, BufWriter, Write};
fn number_lines(input: &str, output: &str) -> io::Result<()> { let reader = BufReader::new(File::open(input)?); let mut writer = BufWriter::new(File::create(output)?); for (i, line) in reader.lines().enumerate() { let line = line?; writeln!(writer, "{:>4} {line}", i + 1)?; } writer.flush()}
fn main() -> io::Result<()> { std::fs::write("in.txt", "alpha\nbeta\ngamma\n")?; number_lines("in.txt", "out.txt")?; print!("{}", std::fs::read_to_string("out.txt")?); std::fs::remove_file("in.txt")?; std::fs::remove_file("out.txt")?; Ok(())} 1 alpha 2 beta 3 gammaenumerate() pairs each line with its index; {:>4} right-aligns the number in 4 columns. The final writer.flush() (whose io::Result becomes the function’s return value) guarantees everything reaches disk before number_lines returns.
Exercise 2: A tiny wc
Section titled “Exercise 2: A tiny wc”Difficulty: Intermediate
Objective: Combine whole-file reading with graceful error handling on ErrorKind.
Instructions: Write count(path: &str) -> io::Result<(usize, usize, usize)> returning (lines, words, bytes) for a file. Then, in main, count a list of paths and, for any file that does not exist, print <path>: no such file to stderr and continue with the rest instead of aborting. (Hint: str::split_whitespace counts words; str::len counts bytes.)
Solution
use std::fs;use std::io::{self, ErrorKind};
fn count(path: &str) -> io::Result<(usize, usize, usize)> { let text = fs::read_to_string(path)?; let lines = text.lines().count(); let words = text.split_whitespace().count(); let bytes = text.len(); Ok((lines, words, bytes))}
fn main() { fs::write("sample.txt", "the quick brown fox\njumps over\n").unwrap(); for path in ["sample.txt", "missing.txt"] { match count(path) { Ok((l, w, b)) => println!("{l:>3} {w:>3} {b:>3} {path}"), Err(e) if e.kind() == ErrorKind::NotFound => { eprintln!("{path}: no such file"); } Err(e) => eprintln!("{path}: {e}"), } } fs::remove_file("sample.txt").unwrap();} 2 6 31 sample.txtmissing.txt: no such fileThe Err(e) if e.kind() == ErrorKind::NotFound guard handles the expected “file missing” case, while a final Err(e) arm still reports anything unexpected (like a permission error). Because each path is handled independently inside the loop, one missing file does not stop the others.
Exercise 3: Streaming uniq
Section titled “Exercise 3: Streaming uniq”Difficulty: Advanced
Objective: Process an arbitrarily large file in constant memory using read_line with a reused buffer.
Instructions: Write uniq(input: &str, output: &str) -> io::Result<usize> that copies input to output, collapsing consecutive identical lines into one (like the Unix uniq command). Return the number of lines written. Constraint: you must not load the whole file into memory — read one line at a time, comparing only against the previous line.
Solution
use std::fs::File;use std::io::{self, BufRead, BufReader, BufWriter, Write};
/// Like `uniq`: drop a line if it is identical to the line just written./// Uses two reused buffers so memory does not grow with the file.fn uniq(input: &str, output: &str) -> io::Result<usize> { let mut reader = BufReader::new(File::open(input)?); let mut writer = BufWriter::new(File::create(output)?);
let mut current = String::new(); let mut previous = String::new(); let mut written = 0; let mut first = true;
while reader.read_line(&mut current)? != 0 { if first || current != previous { write!(writer, "{current}")?; // read_line keeps the '\n' written += 1; first = false; } std::mem::swap(&mut previous, &mut current); current.clear(); } writer.flush()?; Ok(written)}
fn main() -> io::Result<()> { std::fs::write("dup.txt", "a\na\nb\nb\nb\na\n")?; let n = uniq("dup.txt", "uniq.txt")?; println!("kept {n} lines:"); print!("{}", std::fs::read_to_string("uniq.txt")?); std::fs::remove_file("dup.txt")?; std::fs::remove_file("uniq.txt")?; Ok(())}kept 3 lines:abaThe trick is two String buffers swapped with std::mem::swap: after writing current, it becomes the new previous (no allocation, just a pointer swap), and current.clear() readies it for the next read_line. Because read_line retains the trailing \n, we use write! (not writeln!) so we do not double the newlines. Memory is bounded by the longest single line, not the file size.