File I/O with std::fs
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 repository’s pinned verification toolchain uses the 2024 edition; cargo new selects that edition 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— reliable 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.