Build a complete command-line application named linefind. It finds lines containing a literal query and prints their original text with line numbers. The project combines ownership, borrowing, structs, iterators, error propagation, modules, traits and testing in one tool you can run and extend.
The implementation uses safe Rust and the standard library. A small synchronous CLI does not need async, shared ownership or unsafe code merely because you have learned them. Choosing the simplest suitable tool is part of practical Rust.
The Rust Book's command-line project is a useful companion. This capstone adds streaming input, non-UTF-8 path handling, output-error propagation and process-level checks under an explicit contract.
Define the contract
Usage:
linefind [--ignore-case] QUERY PATH
linefind --help
QUERYis a non-empty UTF-8 literal substring, not a regular expression.PATHis a file path;-reads standard input. Paths stay in the operating system's native string representation.- The optional
--ignore-caseflag goes before the query. Other positions are not parsed as options. - Each matching line prints as
line_number:original text, with numbering starting at one. - A final line without a newline is included. Input must be valid UTF-8 text.
- Successful searches with matches exit
0; searches without matches exit1; argument or I/O errors exit2. - Help exits
0. Diagnostics go to standard error, while matches go to standard output.
Case-insensitive mode compares the lowercase form of the query and each line. It handles Unicode lowercase conversion but does not promise full Unicode case folding, normalization, or locale-sensitive matching. For example, do not assume that every spelling involving ß and SS compares equally.
Project layout
cargo new linefind --edition 2024
cd linefind
mkdir -p tests/fixtures
You will create these files. Each code block below is the entire named file, not a fragment to merge with Cargo's starter code.
linefind/
Cargo.toml
src/
lib.rs
main.rs
tests/
cli.rs
fixtures/
notes.txt
Keep the generated Cargo.toml; it has no dependencies. The library owns configuration parsing and the search algorithm. The binary chooses real I/O and an exit status. This lets tests exercise the algorithm without launching a process and also check the actual command-line behavior.
Library: configuration and search
Replace src/lib.rs with:
use std::ffi::OsString;
use std::io::{self, BufRead, Write};
use std::path::PathBuf;
pub const USAGE: &str = "Usage: linefind [--ignore-case] QUERY PATH\n linefind --help\nUse - as PATH to read stdin.";
#[derive(Debug, PartialEq, Eq)]
pub struct Config {
pub query: String,
pub path: PathBuf,
pub ignore_case: bool,
}
impl Config {
/// Parse arguments excluding the executable name.
pub fn parse(args: impl IntoIterator<Item = OsString>) -> Result<Self, &'static str> {
let mut args = args.into_iter();
let first = args.next().ok_or("missing query and path")?;
let (ignore_case, query) = if first == "--ignore-case" {
(true, args.next().ok_or("missing query")?)
} else {
(false, first)
};
let query = query
.into_string()
.map_err(|_| "query must be valid UTF-8")?;
if query.is_empty() {
return Err("query must not be empty");
}
let path = PathBuf::from(args.next().ok_or("missing path")?);
if args.next().is_some() {
return Err("too many arguments");
}
Ok(Self {
query,
path,
ignore_case,
})
}
}
/// Write matching UTF-8 lines with one-based line numbers.
/// Return the number of matching lines, or the first input/output error.
pub fn search<R: BufRead, W: Write>(
reader: R,
mut output: W,
query: &str,
ignore_case: bool,
) -> io::Result<usize> {
let needle = if ignore_case {
query.to_lowercase()
} else {
query.to_owned()
};
let mut matches = 0;
for (index, line) in reader.lines().enumerate() {
let line = line?;
let found = if ignore_case {
line.to_lowercase().contains(&needle)
} else {
line.contains(&needle)
};
if found {
writeln!(output, "{}:{line}", index + 1)?;
matches += 1;
}
}
Ok(matches)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Cursor;
#[test]
fn parses_a_flag_query_and_path() {
let args = ["--ignore-case", "rust", "notes.txt"].map(OsString::from);
assert_eq!(
Config::parse(args),
Ok(Config {
query: String::from("rust"),
path: PathBuf::from("notes.txt"),
ignore_case: true,
})
);
}
#[test]
fn rejects_invalid_argument_shapes() {
for args in [
vec![],
vec!["rust"],
vec!["--ignore-case"],
vec!["", "notes.txt"],
vec!["rust", "notes.txt", "extra"],
] {
assert!(Config::parse(args.into_iter().map(OsString::from)).is_err());
}
}
#[test]
fn keeps_original_text_and_line_numbers() {
let input = Cursor::new(b"Rust is practical\nother\nrust is safe");
let mut output = Vec::new();
let count = search(input, &mut output, "rust", true).unwrap();
assert_eq!(count, 2);
assert_eq!(output, b"1:Rust is practical\n3:rust is safe\n");
}
#[test]
fn default_search_is_case_sensitive() {
let mut output = Vec::new();
let count = search(Cursor::new(b"Rust\nrust\n"), &mut output, "rust", false).unwrap();
assert_eq!(count, 1);
assert_eq!(output, b"2:rust\n");
}
#[test]
fn empty_input_has_no_matches() {
let mut output = Vec::new();
assert_eq!(
search(Cursor::new(b""), &mut output, "rust", false).unwrap(),
0
);
assert!(output.is_empty());
}
#[test]
fn invalid_utf8_is_reported() {
let mut output = Vec::new();
let error = search(Cursor::new([0xff, b'\n']), &mut output, "x", false)
.expect_err("invalid text must fail");
assert_eq!(error.kind(), io::ErrorKind::InvalidData);
}
}
Config owns its query and path, so they remain valid throughout the command. args_os will preserve an OS path even when it cannot become a UTF-8 String. Only the query requires UTF-8 conversion. &'static str is sufficient for this parser's fixed error messages; larger reusable libraries often benefit from a typed error enum.
search borrows its query and takes generic I/O implementations. BufRead::lines provides a Result<String> per line and removes its line ending, including CRLF. We use ? to propagate errors rather than skip unreadable lines. The output is re-emitted with \n line endings.
The algorithm holds roughly one line plus its lowercase copy in memory instead of loading the whole file. This still permits a very large individual line. If bounded input size matters, enforce a byte limit with lower-level buffered reads. A late read or write failure can also occur after earlier matching lines were printed; this is streaming output, not an all-or-nothing transaction.
See BufRead and Write for their error and buffering contracts.
Binary: connect operating-system I/O
Replace src/main.rs with:
use std::env;
use std::error::Error;
use std::ffi::OsString;
use std::fs::File;
use std::io::{self, BufReader, BufWriter, Write};
use std::path::Path;
use std::process::ExitCode;
use linefind::{Config, USAGE, search};
fn run() -> Result<ExitCode, Box<dyn Error>> {
let args: Vec<OsString> = env::args_os().skip(1).collect();
if args.len() == 1 && args[0] == "--help" {
writeln!(io::stdout().lock(), "{USAGE}")?;
return Ok(ExitCode::SUCCESS);
}
let config = Config::parse(args).map_err(|message| format!("{message}\n{USAGE}"))?;
let stdout = io::stdout();
let mut output = BufWriter::new(stdout.lock());
let matches = if config.path == Path::new("-") {
let stdin = io::stdin();
search(stdin.lock(), &mut output, &config.query, config.ignore_case)?
} else {
let file = File::open(&config.path)
.map_err(|error| format!("{}: {error}", config.path.display()))?;
search(
BufReader::new(file),
&mut output,
&config.query,
config.ignore_case,
)?
};
output.flush()?;
Ok(if matches == 0 {
ExitCode::from(1)
} else {
ExitCode::SUCCESS
})
}
fn main() -> ExitCode {
match run() {
Ok(status) => status,
Err(error) => {
eprintln!("linefind: {error}");
ExitCode::from(2)
}
}
}
The binary is an adapter: it reads arguments, opens the input, chooses output buffering and translates the result into process status. Returning ExitCode allows normal stack cleanup; calling process::exit deep inside a helper would skip destructors.
The explicit flush() matters. A buffered writer can encounter its first error only while flushing, and errors during its destructor cannot be returned to us. This tool treats output failures, including a broken pipe, as errors with status 2; a different CLI may deliberately choose another broken-pipe policy.
The parser is intentionally small. It reserves a leading --ignore-case, and a sole --help is handled by the binary. It does not support arbitrary flag order, -- option termination, multiple input files or regex syntax. A dedicated argument-parsing crate becomes useful when those requirements grow. See args_os and ExitCode.
Run it on a real file and a pipe
Save this fixture as tests/fixtures/notes.txt:
Rust makes ownership explicit.
Go uses a garbage collector.
rust tools use Cargo.
From the project root:
cargo run -- --ignore-case rust tests/fixtures/notes.txt
Expected standard output:
1:Rust makes ownership explicit.
3:rust tools use Cargo.
The first -- belongs to Cargo: it separates Cargo options from the arguments sent to linefind. In a POSIX shell, try stdin and inspect the no-match status:
printf 'Rust\nother\nrust\n' | cargo run --quiet -- --ignore-case rust -
cargo run --quiet -- zebra tests/fixtures/notes.txt
echo $?
The pipeline prints 1:Rust and 3:rust. The search for zebra prints no matches, and echo $? reports 1. Run that check in a shell session that does not stop automatically on every nonzero status; a no-match status is intentional here. cargo run -- --help prints usage and exits successfully.
Process tests: verify the actual command
Unit tests exercise the library. A process test verifies that the binary really reads its arguments, connects stdin/stdout and returns the documented status.
Save this entire file as tests/cli.rs:
use std::io::Write;
use std::process::{Command, Output, Stdio};
fn with_stdin(query: &str, input: &[u8]) -> Output {
let mut child = Command::new(env!("CARGO_BIN_EXE_linefind"))
.args(["--ignore-case", query, "-"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("start linefind");
{
let mut stdin = child.stdin.take().expect("piped stdin");
stdin.write_all(input).expect("write small test input");
}
child.wait_with_output().expect("collect linefind output")
}
#[test]
fn searches_stdin_and_preserves_original_text() {
let output = with_stdin("rust", b"Rust\nother\nrust");
assert_eq!(output.status.code(), Some(0));
assert_eq!(output.stdout, b"1:Rust\n3:rust\n");
assert!(output.stderr.is_empty());
}
#[test]
fn no_match_has_its_own_exit_status() {
let output = with_stdin("missing", b"Rust\n");
assert_eq!(output.status.code(), Some(1));
assert!(output.stdout.is_empty());
assert!(output.stderr.is_empty());
}
#[test]
fn invalid_text_is_an_io_error() {
let output = with_stdin("rust", &[0xff, b'\n']);
assert_eq!(output.status.code(), Some(2));
assert!(output.stdout.is_empty());
assert!(!output.stderr.is_empty());
}
#[test]
fn bad_arguments_use_stderr() {
let output = Command::new(env!("CARGO_BIN_EXE_linefind"))
.output()
.expect("run without arguments");
assert_eq!(output.status.code(), Some(2));
assert!(output.stdout.is_empty());
assert!(String::from_utf8_lossy(&output.stderr).contains("Usage:"));
}
#[test]
fn help_is_successful() {
let output = Command::new(env!("CARGO_BIN_EXE_linefind"))
.arg("--help")
.output()
.expect("run help");
assert_eq!(output.status.code(), Some(0));
assert!(String::from_utf8_lossy(&output.stdout).contains("Usage:"));
assert!(output.stderr.is_empty());
}
#[test]
fn reads_a_real_file() {
let fixture = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/notes.txt");
let output = Command::new(env!("CARGO_BIN_EXE_linefind"))
.args(["--ignore-case", "rust", fixture])
.output()
.expect("search fixture");
assert_eq!(output.status.code(), Some(0));
assert_eq!(
output.stdout,
b"1:Rust makes ownership explicit.\n3:rust tools use Cargo.\n"
);
assert!(output.stderr.is_empty());
}
Cargo makes CARGO_BIN_EXE_linefind available to integration tests, so they run the binary built for this test session rather than guessing a path under target/. Command::args supplies separate arguments directly without shell quoting or interpolation.
The helper drops the child's stdin handle after writing. That closes the pipe so the child sees end of input; keeping it open could leave lines() waiting forever. These tests send tiny inputs. A general harness streaming large input and output simultaneously needs concurrent pipe handling to avoid filling a pipe while the other process waits.
Run:
cargo fmt
cargo clippy --all-targets -- -D warnings
cargo test
cargo build --release
The base project has six unit tests and six process tests. Cargo may also show empty test suites for targets without tests. The release executable is under target/release/linefind on this native build. Use it directly to avoid invoking Cargo for each search. See Command and Cargo's integration-test binary variables.
Idioms and pitfalls
- Keep search logic outside
main, so callers and tests can supply I/O implementations. - Do not collect
lines()withfilter_map(Result::ok). It would silently turn an input error into an incomplete successful search. - Do not use
env::argsfor paths that may not be UTF-8; it can panic. KeepOsStringandPathBufuntil text conversion is actually required. - Do not lowercase the text you print. Match a normalized copy while preserving the original line.
- Do not infer success from a nonempty output alone. A streaming error can happen after some output; inspect the exit status.
- New requirements need explicit contracts. Binary files, regexes, recursive directories and gigabyte-long lines are not covered by this text-search implementation.
Exercises
1. Test a writer failure
Write a Write implementation that rejects writes with PermissionDenied. Pass it to search and prove that a matching line propagates the failure. Also prove that a search with no matching lines writes nothing.
2. Preserve native file paths
On Unix, construct a path argument containing a non-UTF-8 byte and verify that Config::parse preserves it. In a separate test, prove that an invalid UTF-8 query is rejected. Keep both tests conditional on Unix.
3. Plan a count-only mode
Design an extension that prints only the match count. Where would parsing, output policy and counting live? Should it keep exit status 1 when the count is zero? State that policy before implementing it.
Solutions
1. Inject a failing output
Save this complete additional integration file as tests/output_errors.rs:
use linefind::search;
use std::io::{self, Cursor, Write};
struct Denied;
impl Write for Denied {
fn write(&mut self, _buffer: &[u8]) -> io::Result<usize> {
Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"test writer denies writes",
))
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
#[test]
fn propagates_output_failure() {
let error = search(Cursor::new(b"Rust\n"), Denied, "Rust", false)
.expect_err("matching output must fail");
assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
}
#[test]
fn no_match_never_attempts_a_write() {
assert_eq!(
search(Cursor::new(b"Rust\n"), Denied, "Go", false).unwrap(),
0
);
}
The writer deliberately fails; no real filesystem permissions or shared files are needed. Flushing belongs to the owner of any buffering layer, which is why the binary explicitly flushes its BufWriter.
2. Test native bytes without global state
Save this complete file as tests/native_paths.rs:
#![cfg(unix)]
use linefind::Config;
use std::ffi::OsString;
use std::os::unix::ffi::OsStringExt;
use std::path::PathBuf;
#[test]
fn preserves_a_non_utf8_path() {
let path = OsString::from_vec(vec![b'n', 0xff]);
let config = Config::parse([OsString::from("rust"), path.clone()]).unwrap();
assert_eq!(config.path, PathBuf::from(path));
}
#[test]
fn rejects_a_non_utf8_query() {
let query = OsString::from_vec(vec![0xff]);
assert_eq!(
Config::parse([query, OsString::from("notes.txt")]),
Err("query must be valid UTF-8"),
);
}
This checks parsing without requiring an actual oddly named file. With both solution files on Unix, the project has sixteen tests.
3. Separate matching from rendering
Add a parsed mode to Config, then decide whether search should take a rendering policy or whether matching should become a separate operation used by two output paths. Keep a single definition of case comparison and line counting. A reasonable CLI policy preserves 0 for a positive count and 1 for zero, while printing a count in either case; document it and add process tests for both. A write failure must still exit 2.
Do not implement count-only mode by printing all matches and then trying to hide that output. Nor should you suppress read errors to produce a seemingly complete count. An extension is finished only when its parsing, help, stream behavior, exit statuses and tests agree.
Finish the course with a review
You now have a runnable tool, a library boundary and tests for success, no matches, invalid inputs and output failures. Before calling a change complete, format it, run lints and tests, exercise the command on a real file, and read the actual stdout, stderr and exit status.
The course's progression is deliberate: values and control flow; ownership and borrowing; data modeling and errors; modules, generics, traits and lifetimes; closures and iteration; pointer choices; threads and shared state; async contracts; tests and foreign boundaries; then this application. Revisit the relevant lesson when a new requirement exposes a gap rather than adding a wrapper or dependency automatically.
Possible follow-up projects include a directory summary tool, a validated CSV importer, or a concurrent file analyzer. For each, first define input limits, ownership, errors and observable behavior. Choose threads or async only when the workload calls for them, and reach for unsafe only at a boundary that safe APIs cannot express.

