Project: Log Analyser CLI
Every web server writes a log: one line per request, with the time, the path, the status code and how long it took. When something goes wrong, developers search these logs, and a small tool that summarises them saves a lot of time. In this project you will build logscan, a command-line log analyser for the booking service of a (made-up) railway app.
The project uses almost everything from this course: Cargo, modules, structs and enums, match, Result and ?, vectors, maps, iterators, traits, the clap, serde and anyhow crates, and tests. Build it step by step, running cargo check after each step.
Step 1: plan the tool
Each log line has seven fields separated by spaces:
2026-10-28 09:16:11 ERROR POST /api/book 500 2300ms
date time level method path status durationlogscan will have two subcommands:
logscan <FILE> summary [--top N] [--json]: count requests per level, list the busiest paths, and show the average and slowest request.--jsonprints the same summary as JSON for other programs.logscan <FILE> filter [--level LEVEL]: print only requests at or above a level (by defaultwarn).
Broken lines must not stop the tool: it prints a warning to the error output and carries on. Missing files and bad options end the program with a clear error message.
Create the project and add the crates:
cargo new logscan
cd logscan
cargo add clap --features derive
cargo add serde --features derive
cargo add serde_json
cargo add anyhowThe code goes into three files: src/entry.rs (one log line and its parser), src/report.rs (the summary calculation) and src/main.rs (the command-line interface).
Step 2: the Level enum
A level is one of three fixed values, so it is an enum. Deriving PartialOrd and Ord orders the variants as declared, so Info < Warn < Error, which the filter command will use. Create src/entry.rs:
use std::fmt;
use std::str::FromStr;
use serde::Serialize;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
#[serde(rename_all = "UPPERCASE")]
pub enum Level {
Info,
Warn,
Error,
}
impl FromStr for Level {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"INFO" => Ok(Level::Info),
"WARN" => Ok(Level::Warn),
"ERROR" => Ok(Level::Error),
other => Err(format!("unknown level `{other}`")),
}
}
}
impl fmt::Display for Level {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let text = match self {
Level::Info => "INFO",
Level::Warn => "WARN",
Level::Error => "ERROR",
};
f.pad(text)
}
}Implementing the standard FromStr trait means "WARN".parse::<Level>() works, just like parsing numbers. In Display, f.pad(text) instead of write! makes width options such as {:<5} work, so levels line up in columns. The serde attribute makes the JSON output use "INFO" rather than "Info".
Step 3: parsing a line
Add the LogEntry struct and the parser to the same file:
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct LogEntry {
pub time: String,
pub level: Level,
pub method: String,
pub path: String,
pub status: u16,
pub millis: u32,
}
pub fn parse_line(line: &str) -> Result<LogEntry, String> {
let parts: Vec<&str> = line.split_whitespace().collect();
let [date, time, level, method, path, status, duration] = parts[..] else {
return Err(format!("expected 7 fields, found {}", parts.len()));
};
let level: Level = level.parse()?;
let status: u16 = status.parse().map_err(|_| format!("bad status `{status}`"))?;
let millis: u32 = duration
.strip_suffix("ms")
.and_then(|n| n.parse().ok())
.ok_or_else(|| format!("bad duration `{duration}`"))?;
Ok(LogEntry {
time: format!("{date} {time}"),
level,
method: method.to_string(),
path: path.to_string(),
status,
millis,
})
}The slice pattern [date, time, ..., duration] with let ... else checks that there are exactly seven fields and names them all in one line. Each conversion returns a Result, and ? passes the first error up. For the duration, strip_suffix("ms") returns an Option, and_then parses the number (turning a parse error into None), and ok_or_else converts the final None into an error message. The error type is a simple String here, because the caller only prints it.
Now protect the parser with unit tests at the bottom of entry.rs:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_a_valid_line() {
let e = parse_line("2026-10-28 09:15:02 INFO GET /api/trains 200 120ms").unwrap();
assert_eq!(e.level, Level::Info);
assert_eq!(e.path, "/api/trains");
assert_eq!(e.millis, 120);
}
#[test]
fn rejects_missing_fields() {
assert!(parse_line("2026-10-28 09:15:02 INFO").is_err());
}
#[test]
fn rejects_bad_duration() {
let err = parse_line("2026-10-28 09:15:02 INFO GET / 200 fast").unwrap_err();
assert_eq!(err, "bad duration `fast`");
}
}Step 4: the summary
The report needs counts per level and per path. A BTreeMap keeps keys sorted, so the output is the same on every run (a HashMap would print in random order). Create src/report.rs:
use std::collections::BTreeMap;
use serde::Serialize;
use crate::entry::{Level, LogEntry};
#[derive(Serialize)]
pub struct Summary {
pub total: usize,
pub by_level: BTreeMap<Level, usize>,
pub top_paths: Vec<(String, usize)>,
pub average_ms: u32,
pub slowest: Option<LogEntry>,
}
pub fn summarise(entries: &[LogEntry], top: usize) -> Summary {
let mut by_level = BTreeMap::new();
let mut path_counts: BTreeMap<&str, usize> = BTreeMap::new();
for e in entries {
*by_level.entry(e.level).or_insert(0) += 1;
*path_counts.entry(e.path.as_str()).or_insert(0) += 1;
}
let mut top_paths: Vec<(String, usize)> = path_counts
.into_iter()
.map(|(path, n)| (path.to_string(), n))
.collect();
top_paths.sort_by(|a, b| b.1.cmp(&a.1)); // stable: ties stay alphabetical
top_paths.truncate(top);
let total_ms: u64 = entries.iter().map(|e| u64::from(e.millis)).sum();
let average_ms = if entries.is_empty() {
0
} else {
(total_ms / entries.len() as u64) as u32
};
let slowest = entries.iter().max_by_key(|e| e.millis).cloned();
Summary { total: entries.len(), by_level, top_paths, average_ms, slowest }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::entry::parse_line;
#[test]
fn counts_levels_and_paths() {
let entries: Vec<LogEntry> = [
"2026-10-28 10:00:00 INFO GET /a 200 10ms",
"2026-10-28 10:00:01 ERROR GET /b 500 30ms",
"2026-10-28 10:00:02 INFO GET /a 200 20ms",
]
.iter()
.map(|line| parse_line(line).unwrap())
.collect();
let s = summarise(&entries, 5);
assert_eq!(s.total, 3);
assert_eq!(s.by_level[&Level::Info], 2);
assert_eq!(s.top_paths[0], (String::from("/a"), 2));
assert_eq!(s.average_ms, 20);
assert_eq!(s.slowest.unwrap().millis, 30);
}
}Points to notice:
path_countsborrows the paths as&strwhile counting, so no strings are copied for every line; only the final top list ownsStringvalues.sort_byis a stable sort, so paths with equal counts keep their alphabetical order from theBTreeMap. The result is fully predictable.- The total is summed as
u64, because adding manyu32durations could overflow.u64::fromconverts without any risk of losing data. max_by_keyreturns anOption<&LogEntry>, and.cloned()turns it into an ownedOption<LogEntry>for the summary.
Step 5: the command-line interface
Now src/main.rs ties everything together. clap's derive API describes a positional FILE argument followed by a subcommand:
mod entry;
mod report;
use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use entry::{Level, LogEntry};
#[derive(Parser)]
#[command(version, about = "Summarise and filter web server logs")]
struct Cli {
/// Path to the log file
file: PathBuf,
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
/// Count requests, list busy paths and find the slowest request
Summary {
/// How many paths to list
#[arg(long, default_value_t = 3)]
top: usize,
/// Print the summary as JSON
#[arg(long)]
json: bool,
},
/// Print requests at or above a level (info, warn or error)
Filter {
#[arg(long, default_value = "warn")]
level: String,
},
}
fn load(path: &Path) -> Result<(Vec<LogEntry>, usize)> {
let text = fs::read_to_string(path)
.with_context(|| format!("could not read `{}`", path.display()))?;
let mut entries = Vec::new();
let mut skipped = 0;
for (n, line) in text.lines().enumerate() {
if line.trim().is_empty() {
continue;
}
match entry::parse_line(line) {
Ok(e) => entries.push(e),
Err(reason) => {
eprintln!("warning: line {}: {reason}", n + 1);
skipped += 1;
}
}
}
Ok((entries, skipped))
}
fn print_summary(s: &report::Summary, skipped: usize) {
println!("Requests: {} (skipped lines: {skipped})", s.total);
for (level, count) in &s.by_level {
println!(" {level:<5} {count}");
}
println!("Busiest paths:");
for (path, count) in &s.top_paths {
println!(" {path:<14} {count}");
}
println!("Average time: {} ms", s.average_ms);
if let Some(e) = &s.slowest {
println!("Slowest: {} {} took {} ms at {}", e.method, e.path, e.millis, e.time);
}
}
fn main() -> Result<()> {
let cli = Cli::parse();
let (entries, skipped) = load(&cli.file)?;
match cli.command {
Command::Summary { top, json } => {
let summary = report::summarise(&entries, top);
if json {
println!("{}", serde_json::to_string_pretty(&summary)?);
} else {
print_summary(&summary, skipped);
}
}
Command::Filter { level } => {
let min: Level = level.to_uppercase().parse().map_err(anyhow::Error::msg)?;
for e in entries.iter().filter(|e| e.level >= min) {
println!(
"{} {:<5} {} {} {} {}ms",
e.time, e.level, e.method, e.path, e.status, e.millis
);
}
}
}
Ok(())
}eprintln!writes warnings to standard error, so they do not mix with real output when someone redirects the results to a file.with_contextadds the file name to any read error, andanyhow::Error::msgturns ourStringerror fromparseinto ananyhowerror so?can return it.- The user may type
warnorWARN;to_uppercase()accepts both. - Doc comments on the subcommands and fields become the text of
logscan --helpandlogscan sample.log summary --help.
Step 6: run it
Save this as sample.log in the project folder. Line 8 is broken on purpose:
2026-10-28 09:15:02 INFO GET /api/trains 200 120ms
2026-10-28 09:15:07 INFO GET /api/seats 200 340ms
2026-10-28 09:15:31 WARN GET /api/seats 200 950ms
2026-10-28 09:16:11 ERROR POST /api/book 500 2300ms
2026-10-28 09:16:45 INFO GET /api/trains 200 95ms
2026-10-28 09:17:02 INFO POST /api/book 201 610ms
2026-10-28 09:17:30 WARN GET /api/trains 429 15ms
this line is broken
2026-10-28 09:18:04 INFO GET /api/seats 200 280ms
2026-10-28 09:18:40 ERROR GET /api/pnr 503 1800ms
2026-10-28 09:19:12 INFO GET /api/trains 200 110mscargo run -- sample.log summarywarning: line 8: expected 7 fields, found 4 Requests: 10 (skipped lines: 1) INFO 6 WARN 2 ERROR 2 Busiest paths: /api/trains 4 /api/seats 3 /api/book 2 Average time: 662 ms Slowest: POST /api/book took 2300 ms at 2026-10-28 09:16:11
(Cargo's own Compiling and Running lines are left out.) Check the numbers by hand: the ten durations add up to 6,620 ms, so the average is 662 ms. Next, the filter:
cargo run -- sample.log filter --level warnwarning: line 8: expected 7 fields, found 4 2026-10-28 09:15:31 WARN GET /api/seats 200 950ms 2026-10-28 09:16:11 ERROR POST /api/book 500 2300ms 2026-10-28 09:17:30 WARN GET /api/trains 429 15ms 2026-10-28 09:18:40 ERROR GET /api/pnr 503 1800ms
And the JSON summary with only the busiest path:
cargo run -- sample.log summary --top 1 --jsonwarning: line 8: expected 7 fields, found 4
{
"total": 10,
"by_level": {
"INFO": 6,
"WARN": 2,
"ERROR": 2
},
"top_paths": [
[
"/api/trains",
4
]
],
"average_ms": 662,
"slowest": {
"time": "2026-10-28 09:16:11",
"level": "ERROR",
"method": "POST",
"path": "/api/book",
"status": 500,
"millis": 2300
}
}Tuples become JSON arrays, Option becomes an object or null, and the map keys follow the enum order. Errors are reported cleanly too. Running cargo run -- missing.log summary on Linux prints the context message first and the operating system's reason below it (Windows words the reason differently):
Error: could not read `missing.log`
Caused by:
No such file or directory (os error 2)An unknown level, as in filter --level debug, ends the program with Error: followed by the parse message, unknown level DEBUG.
Step 7: test, build and install
cargo test
cargo clippy
cargo build --release
cargo install --path .cargo test runs the four unit tests from entry.rs and report.rs. cargo clippy checks for common mistakes. The release build is in target/release/logscan, and cargo install --path . copies it into ~/.cargo/bin, so you can now type logscan sample.log summary from any folder.
Ideas to extend the project
- Read very large files line by line with
std::io::BufReaderand itslines()method, instead of loading the whole file withread_to_string. - Add a
--since 09:16option to the filter command, comparing the time strings. - Count status codes (200, 429, 500...) in another
BTreeMapand add them to the summary. - Replace the
Stringerrors inentry.rswith an error enum built withthiserror. - Process several log files at once with threads, or with the
rayoncrate.
Key points
- Split a CLI into modules: parsing (
entry.rs), logic (report.rs) and the interface (main.rs). - Enums with derived
Ordand aFromStrimplementation make values like levels easy to parse, compare and print. let ... elsewith a slice pattern and?make a line parser short and safe; bad lines become warnings, not crashes.BTreeMapand a stable sort keep reports predictable;serdeturns the same summary into JSON for free.- clap subcommands,
anyhowcontext and unit tests turn a small program into a tool other people can rely on.
Exercise
Extend logscan with a third subcommand, status, that prints how many requests ended with each HTTP status code, in ascending order of code, and marks codes of 500 or above with (server error). Add a function status_counts(entries: &[LogEntry]) -> BTreeMap<u16, usize> to report.rs, write a unit test for it, and show the output for sample.log.
Show solution
// in src/report.rs
pub fn status_counts(entries: &[LogEntry]) -> BTreeMap<u16, usize> {
let mut counts = BTreeMap::new();
for e in entries {
*counts.entry(e.status).or_insert(0) += 1;
}
counts
}
// inside `mod tests` in src/report.rs
#[test]
fn counts_status_codes() {
let entries: Vec<LogEntry> = [
"2026-10-28 10:00:00 INFO GET /a 200 10ms",
"2026-10-28 10:00:01 ERROR GET /b 500 30ms",
"2026-10-28 10:00:02 INFO GET /a 200 20ms",
]
.iter()
.map(|line| parse_line(line).unwrap())
.collect();
let counts = status_counts(&entries);
assert_eq!(counts[&200], 2);
assert_eq!(counts[&500], 1);
}
// in src/main.rs: a new variant in `enum Command`
/// Count requests per HTTP status code
Status,
// and a new arm in the `match` in main
Command::Status => {
for (code, count) in report::status_counts(&entries) {
let note = if code >= 500 { " (server error)" } else { "" };
println!("{code}: {count}{note}");
}
}warning: line 8: expected 7 fields, found 4 200: 6 201: 1 429: 1 500: 1 (server error) 503: 1 (server error)
The BTreeMap<u16, usize> keeps the codes in ascending order, and note is an empty string for codes below 500. The unit variant Status needs no fields, so logscan sample.log status is all the user types.
Quick Quiz
1. Why does logscan use BTreeMap instead of HashMap for counting?
HashMap iteration order is random; BTreeMap gives predictable, sorted output.
2. What does f.pad(text) in the Display implementation for Level make possible?
pad respects the formatter's width, fill and alignment settings, unlike a plain write!.
3. Why are warnings printed with eprintln! instead of println!?
Separating diagnostics (stderr) from results (stdout) is standard practice for command-line tools.
4. What does the slice pattern in let [date, time, level, method, path, status, duration] = parts[..] else { ... }; check?
A fixed-length slice pattern matches only slices of that length; otherwise the else branch runs.
Learn Rust with a mentor
Free lessons get you started. Our Programming & Software Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
