EthicsComputerCourses
Join training +91 90532 10052

Project: Log Analyser CLI

Rust · Lesson 37 of 43 Advanced 9 min read Updated

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:

Text
2026-10-28 09:16:11 ERROR POST /api/book 500 2300ms
date       time     level method path    status duration

logscan 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. --json prints the same summary as JSON for other programs.
  • logscan <FILE> filter [--level LEVEL]: print only requests at or above a level (by default warn).

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:

Terminal
cargo new logscan
cd logscan
cargo add clap --features derive
cargo add serde --features derive
cargo add serde_json
cargo add anyhow

The 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:

Rust
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:

Rust
#[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:

Rust
#[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:

Rust
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_counts borrows the paths as &str while counting, so no strings are copied for every line; only the final top list owns String values.
  • sort_by is a stable sort, so paths with equal counts keep their alphabetical order from the BTreeMap. The result is fully predictable.
  • The total is summed as u64, because adding many u32 durations could overflow. u64::from converts without any risk of losing data.
  • max_by_key returns an Option<&LogEntry>, and .cloned() turns it into an owned Option<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:

Rust
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_context adds the file name to any read error, and anyhow::Error::msg turns our String error from parse into an anyhow error so ? can return it.
  • The user may type warn or WARN; to_uppercase() accepts both.
  • Doc comments on the subcommands and fields become the text of logscan --help and logscan sample.log summary --help.

Step 6: run it

Save this as sample.log in the project folder. Line 8 is broken on purpose:

Text
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 110ms
Terminal
cargo run -- sample.log summary
Output
warning: 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:

Terminal
cargo run -- sample.log filter --level warn
Output
warning: 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:

Terminal
cargo run -- sample.log summary --top 1 --json
Output
warning: 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):

Output
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

Terminal
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::BufReader and its lines() method, instead of loading the whole file with read_to_string.
  • Add a --since 09:16 option to the filter command, comparing the time strings.
  • Count status codes (200, 429, 500...) in another BTreeMap and add them to the summary.
  • Replace the String errors in entry.rs with an error enum built with thiserror.
  • Process several log files at once with threads, or with the rayon crate.

Key points

  • Split a CLI into modules: parsing (entry.rs), logic (report.rs) and the interface (main.rs).
  • Enums with derived Ord and a FromStr implementation make values like levels easy to parse, compare and print.
  • let ... else with a slice pattern and ? make a line parser short and safe; bad lines become warnings, not crashes.
  • BTreeMap and a stable sort keep reports predictable; serde turns the same summary into JSON for free.
  • clap subcommands, anyhow context 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
Rust
// 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}");
    }
}
Output
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?

2. What does f.pad(text) in the Display implementation for Level make possible?

3. Why are warnings printed with eprintln! instead of println!?

4. What does the slice pattern in let [date, time, level, method, path, status, duration] = parts[..] else { ... }; check?

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