Beginner Projects
Basic Command-Line Utilities
Basic command-line utilities follow the Unix philosophy: composable tools that accept flag options (-i, -v, -n), read from either named files or standard input (stdin), write structured output to standard output (stdout), write diagnostics to standard error (stderr), and return meaningful exit codes (ExitCode).
Standard Unix CLI Pipeline Architecture in Rust:
Command-Line Args (`-i`, `-v`, `-n`, `pattern`, `[files...]`)
|
v `CliConfig::parse(args)`
Input Stream (`File::open(path)` or `io::stdin().lock()`)
|
v `run_filter(reader, writer, &config)`
Exit Status:
- `ExitCode::SUCCESS` (0): At least 1 line matched
- `ExitCode::from(1)`: 0 lines matched (standard `grep` convention!)
- `ExitCode::from(2)`: Usage or I/O error
Standard Exit Code Conventions for Search / Filter Utilities
Following POSIX grep and BSD sysexits conventions makes a Rust CLI utility composable inside shell if conditions and && pipelines:
| Exit Code | Constant / Value | Meaning in a Search Utility |
|---|---|---|
0 |
ExitCode::SUCCESS |
One or more matching lines were found. |
1 |
ExitCode::from(1) |
Execution succeeded with no I/O errors, but zero lines matched. |
2 |
ExitCode::from(2) |
Invalid command-line flags or file I/O failure. |
Complete Runnable Implementation: minigrep (-i, -v, -n)
use std::io::{self, BufRead, Write};
use std::process::ExitCode;
#[derive(Debug, PartialEq, Eq)]
pub struct GrepConfig {
pub pattern: String,
pub ignore_case: bool,
pub invert_match: bool,
pub line_numbers: bool,
}
impl GrepConfig {
pub fn parse<I>(args: I) -> Result<Self, String>
where
I: IntoIterator<Item = String>,
{
let mut ignore_case = false;
let mut invert_match = false;
let mut line_numbers = false;
let mut pattern: Option<String> = None;
for arg in args {
match arg.as_str() {
"-i" | "--ignore-case" => ignore_case = true,
"-v" | "--invert-match" => invert_match = true,
"-n" | "--line-number" => line_numbers = true,
s if s.starts_with('-') => {
return Err(format!("unknown option: {s}"));
}
pos => {
if pattern.is_none() {
pattern = Some(pos.to_string());
}
}
}
}
let pattern = pattern.ok_or_else(|| "missing search pattern".to_string())?;
Ok(Self {
pattern,
ignore_case,
invert_match,
line_numbers,
})
}
}
/// Filters lines from `reader` into `writer`, returning the number of matched lines.
pub fn grep_stream<R: BufRead, W: Write>(
mut reader: R,
mut writer: W,
cfg: &GrepConfig,
) -> io::Result<usize> {
let needle = if cfg.ignore_case {
cfg.pattern.to_lowercase()
} else {
cfg.pattern.clone()
};
let mut line_buf = String::new();
let mut line_no = 0usize;
let mut matched_count = 0usize;
loop {
line_buf.clear();
if reader.read_line(&mut line_buf)? == 0 {
break;
}
line_no += 1;
let clean = line_buf.trim_end_matches(['\r', '\n']);
let contains = if cfg.ignore_case {
clean.to_lowercase().contains(&needle)
} else {
clean.contains(&needle)
};
let is_match = if cfg.invert_match {
!contains
} else {
contains
};
if is_match {
matched_count += 1;
if cfg.line_numbers {
writeln!(writer, "{line_no}:{clean}")?;
} else {
writeln!(writer, "{clean}")?;
}
}
}
Ok(matched_count)
}
fn main() -> ExitCode {
let simulated_args = vec![
"-i".to_string(),
"-n".to_string(),
"error".to_string(),
];
let cfg = match GrepConfig::parse(simulated_args) {
Ok(c) => c,
Err(msg) => {
eprintln!("usage error: {msg}");
return ExitCode::from(2);
}
};
let sample_log = b"\
2026-10-27 INFO Server booted
2026-10-27 ERROR Connection timed out
2026-10-27 WARN Retrying in 100ms
2026-10-27 error Disk quota exceeded
";
let mut output = Vec::new();
match grep_stream(&sample_log[..], &mut output, &cfg) {
Ok(count) => {
print!("{}", String::from_utf8_lossy(&output));
if count > 0 {
ExitCode::SUCCESS
} else {
ExitCode::from(1)
}
}
Err(e) => {
eprintln!("I/O error: {e}");
ExitCode::from(2)
}
}
}
2:2026-10-27 ERROR Connection timed out
4:2026-10-27 error Disk quota exceeded