rload 0.1.0

A Rust HTTP/1.1 load generator with Nginx access-log and JSONL replay
Documentation
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::Path;

use crate::{Method, RunError};

#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct ReplayRequest {
    pub(crate) method: Method,
    pub(crate) path: String,
    pub(crate) headers: Vec<(String, String)>,
    pub(crate) body: Vec<u8>,
    pub(crate) body_present: bool,
}

pub(crate) fn read(path: &Path) -> Result<Vec<ReplayRequest>, RunError> {
    let file = File::open(path)?;
    let mut requests = Vec::new();
    for (index, line) in BufReader::new(file).lines().enumerate() {
        let line = line?;
        if line.trim().is_empty() {
            continue;
        }
        requests.push(parse_line(&line, index + 1)?);
    }
    if requests.is_empty() {
        return Err(RunError::InvalidAccessLog(
            "access log contains no requests".into(),
        ));
    }
    Ok(requests)
}

fn parse_line(line: &str, line_number: usize) -> Result<ReplayRequest, RunError> {
    let invalid =
        |message: &str| RunError::InvalidAccessLog(format!("line {line_number}: {message}"));
    let request = line
        .split_once('"')
        .and_then(|(_, remainder)| remainder.split_once('"').map(|(request, _)| request))
        .ok_or_else(|| invalid("quoted request field is missing"))?;
    let mut fields = request.split_whitespace();
    let method = match fields.next() {
        Some("GET") => Method::Get,
        Some("HEAD") => Method::Head,
        Some(method) => {
            return Err(invalid(&format!(
                "method {method} requires a request body or is unsupported"
            )));
        }
        None => return Err(invalid("request field is empty")),
    };
    let path = fields
        .next()
        .filter(|path| path.starts_with('/') && !path.contains(['\r', '\n']))
        .ok_or_else(|| invalid("request URI must use origin form"))?;
    fields
        .next()
        .filter(|version| matches!(*version, "HTTP/1.0" | "HTTP/1.1"))
        .ok_or_else(|| invalid("HTTP version must be HTTP/1.0 or HTTP/1.1"))?;
    if fields.next().is_some() {
        return Err(invalid("request field has unexpected fields"));
    }
    Ok(ReplayRequest {
        method,
        path: path.to_owned(),
        headers: Vec::new(),
        body: Vec::new(),
        body_present: false,
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn reports_line_number_for_unsupported_method() {
        let error =
            parse_line("127.0.0.1 - - [date] \"POST /items HTTP/1.1\" 200 0", 7).unwrap_err();

        assert!(error.to_string().contains("line 7"));
        assert!(error.to_string().contains("method POST"));
    }
}