Logging #26

Open
leon wants to merge 33 commits from Logging into main
8 changed files with 741 additions and 3 deletions

View File

@ -1,3 +1,5 @@
workspace = { members = ["config", "logging"] }
[package] [package]
name = "wanessa" name = "wanessa"
version = "0.1.0" version = "0.1.0"
@ -9,12 +11,13 @@ edition = "2021"
[lints.rust] [lints.rust]
unsafe_code = "forbid" unsafe_code = "forbid"
missing_docs = "warn"
[lints.clippy] [lints.clippy]
enum_glob_use = "deny" # https://rust-lang.github.io/rust-clippy/master/index.html#/enum_glob_use enum_glob_use = "deny" # https://rust-lang.github.io/rust-clippy/master/index.html#/enum_glob_use
pedantic = "deny" # https://rust-lang.github.io/rust-clippy/master/index.html#/?groups=pedantic pedantic = "deny" # https://rust-lang.github.io/rust-clippy/master/index.html#/?groups=pedantic
nursery = "deny" # wip lints: https://rust-lang.github.io/rust-clippy/master/index.html#/?groups=nursery nursery = "deny" # wip lints: https://rust-lang.github.io/rust-clippy/master/index.html#/?groups=nursery
unwrap_used = "deny" # https://rust-lang.github.io/rust-clippy/master/index.html#/unwrap_used unwrap_used = "forbid" # https://rust-lang.github.io/rust-clippy/master/index.html#/unwrap_used
missing_const_for_fn = "warn" # https://rust-lang.github.io/rust-clippy/master/index.html#/missing_const_for_fn missing_const_for_fn = "warn" # https://rust-lang.github.io/rust-clippy/master/index.html#/missing_const_for_fn
missing_assert_message = "deny" # https://rust-lang.github.io/rust-clippy/master/index.html#/missing_assert_message missing_assert_message = "warn" # https://rust-lang.github.io/rust-clippy/master/index.html#/missing_assert_message
missing_errors_doc = "deny" # https://rust-lang.github.io/rust-clippy/master/index.html#/missing_assert_message missing_errors_doc = "warn" # https://rust-lang.github.io/rust-clippy/master/index.html#/missing_assert_message

23
config/Cargo.toml Normal file
View File

@ -0,0 +1,23 @@
[package]
name = "config"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
getset = { version = "0.1.2", default-features = false }
once_cell = { version = "1.19.0", default-features = false, features = ["std"] }
[lints.rust]
unsafe_code = "forbid"
missing_docs = "warn"
[lints.clippy]
enum_glob_use = "deny" # https://rust-lang.github.io/rust-clippy/master/index.html#/enum_glob_use
pedantic = "deny" # https://rust-lang.github.io/rust-clippy/master/index.html#/?groups=pedantic
nursery = "deny" # wip lints: https://rust-lang.github.io/rust-clippy/master/index.html#/?groups=nursery
unwrap_used = "forbid" # https://rust-lang.github.io/rust-clippy/master/index.html#/unwrap_used
missing_const_for_fn = "warn" # https://rust-lang.github.io/rust-clippy/master/index.html#/missing_const_for_fn
missing_assert_message = "warn" # https://rust-lang.github.io/rust-clippy/master/index.html#/missing_assert_message
missing_errors_doc = "warn" # https://rust-lang.github.io/rust-clippy/master/index.html#/missing_assert_message

57
config/src/db.rs Normal file
View File

@ -0,0 +1,57 @@
/*!
* This module contains everything related to [`DbConfig`].
*/
#![allow(clippy::module_name_repetitions)]
use getset::Getters;
/**
* A immutable record of all the information needed to connect to the SQL database.
*/
#[allow(clippy::module_name_repetitions)]
#[derive(Clone, PartialEq, Eq, Getters, Debug)]
#[getset(get = "pub")]
pub struct DbConfig {
/// Database connection address.
/// Is an option to allow constructing a default config during compile time.<br>
addr: String,
/// Database connection port.<br>
port: u16,
}
impl Default for DbConfig {
fn default() -> Self {
Self {
addr: String::from("localhost"),
port: 6969,
}
}
}
/**
* Builder for [`DbConfig`].
*/
#[derive(Default, Debug)]
pub struct DbConfigBuilder(DbConfig);
impl DbConfigBuilder {
/// Get a new [`DbConfigBuilder`]
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Set the address to the location of the database.
#[must_use]
pub fn set_addr(mut self, addr: impl Into<String>) -> Self {
self.0.addr = addr.into();
self
}
/// Set the port to the port the database uses.
#[must_use]
pub fn set_port(mut self, port: impl Into<u16>) -> Self {
self.0.port = port.into();
self
}
}

31
config/src/lib.rs Normal file
View File

@ -0,0 +1,31 @@
/*!
* Containing all singleton and thread-safe structs related to configuring `WANessa`.
*
* This crate differentiates between `Configs` and `Settings`:
* Configs:
* - Configs are immutable after they have been initialized. Example [`DbConfig`].
* - Changing the config requires a restart of `WANessa`.
*
* Settings:
* - Settings are mutable after they have been initialized. Example [`LogSettings`].
* - `WANessa` always uses the current setting and does not need a restart to apply new settings.
*/
pub mod db;
pub mod log;
use crate::db::DbConfig;
use crate::log::LogSettings;
use std::sync::{Arc, RwLock};
use once_cell::sync::Lazy;
// TODO: replace default with parsed settings from files+flags
/// Singelton [`DbConfig`].
pub static DB_CONFIG: Lazy<Arc<DbConfig>> = Lazy::new(|| Arc::new(DbConfig::default()));
/// Singelton [`LogSettings`].
pub static LOG_SETTINGS: Lazy<RwLock<LogSettings>> =
Lazy::new(|| RwLock::new(LogSettings::default()));

98
config/src/log.rs Normal file
View File

@ -0,0 +1,98 @@
/*!
* This module contains everything related to [`LogSettings`].
*/
#![allow(clippy::module_name_repetitions)]
use std::cmp::Ordering;
use std::path::Path;
use crate::log::LogVerbosity::Warning;
use std::path::PathBuf;
use getset::Getters;
use getset::Setters;
/**
* All settings relating to how the project logs information.
*/
#[allow(clippy::struct_excessive_bools)]
#[derive(Clone, PartialEq, Eq, Getters, Setters, Debug)]
#[getset(get = "pub", set = "pub")]
pub struct LogSettings {
/// See [`LogVerbosity`].<br>
verbosity: LogVerbosity,
/// Logs UTC time and date of message, if true.<br>
time: bool,
/// Time and date format.<br>
/// See [chrono](https://docs.rs/chrono/latest/chrono/format/strftime/index.html).<br>
time_format: String,
/// Logs location in code, where the message was logged, if true.<br>
location: bool,
/// If `Some(path)` tries to also write the log to `path` in addition to stderr/stderr.<br>
#[getset(skip)]
path: Option<PathBuf>,
/// Logs to standard out, if true.<br>
stdout: bool,
/// Logs to standard err, if true.<br>
stderr: bool,
}
impl LogSettings {
/// Setter for log path, including syntactic sugar for the [Option] enum.
pub fn set_path(&mut self, path: impl Into<Option<PathBuf>>) {
self.path = path.into();
}
/// Getter for the log path.
#[must_use]
pub fn path(&self) -> Option<&Path> {
self.path.as_deref()
}
}
impl Default for LogSettings {
fn default() -> Self {
Self {
verbosity: Warning,
time: false,
time_format: String::from("%F-%T:%f"),
location: false,
stdout: true,
stderr: true,
path: None,
}
}
}
/**
* Each level includes the previous ones.
*/
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum LogVerbosity {
/// Critical Errors, may lead to crashes or deactivating certain features.
Error = 10,
/// Very minor and recovered errors, such as invalid configs.
Warning = 20,
/// Very verbose and detailed. Basically gives a step-by-step instruction on what is currently done.
Information = 30,
/// Very technical and even more verbose.
/// May contain secrets and private information.
/// **Do not use in production environments!**
Debugging = 40,
}
impl PartialOrd for LogVerbosity {
/// Some operator overloading of comparison symbols (==, <,>=, etc.) as syntactic sugar.
/// See [`PartialOrd`].
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for LogVerbosity {
/// Some operator overloading of comparison symbols (==, <,>=, etc.) as syntactic sugar.
/// See [`Ord`].
fn cmp(&self, other: &Self) -> Ordering {
(*self as usize).cmp(&(*other as usize))
}
}

28
logging/Cargo.toml Normal file
View File

@ -0,0 +1,28 @@
[package]
name = "logging"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
chrono = { version = "0.4.34", default-features = false, features = ["now"] }
config = { path = "../config"}
[dev-dependencies]
uuid = { version = "1.7.0", default-features = false, features = ["v4", "fast-rng"] }
once_cell = { version = "1.19.0", default-features = false, features = ["std"] }
futures = { version = "0.3.30", default-features = false, features = ["alloc", "executor"] }
[lints.rust]
unsafe_code = "forbid"
missing_docs = "warn"
[lints.clippy]
enum_glob_use = "deny" # https://rust-lang.github.io/rust-clippy/master/index.html#/enum_glob_use
pedantic = "deny" # https://rust-lang.github.io/rust-clippy/master/index.html#/?groups=pedantic
nursery = "deny" # wip lints: https://rust-lang.github.io/rust-clippy/master/index.html#/?groups=nursery
unwrap_used = "forbid" # https://rust-lang.github.io/rust-clippy/master/index.html#/unwrap_used
missing_const_for_fn = "warn" # https://rust-lang.github.io/rust-clippy/master/index.html#/missing_const_for_fn
missing_assert_message = "warn" # https://rust-lang.github.io/rust-clippy/master/index.html#/missing_assert_message
missing_errors_doc = "warn" # https://rust-lang.github.io/rust-clippy/master/index.html#/missing_assert_message

195
logging/src/lib.rs Normal file
View File

@ -0,0 +1,195 @@
/*!
* This module handles logging messages asynchronously and is thread-safe.
* It should mostly be called statically using the [`log!`] macro.
*/
mod test;
use std::fmt::Display;
use std::fs::OpenOptions;
use std::io;
use std::io::Write;
use config::log::LogSettings;
use config::log::LogVerbosity;
use chrono::Utc;
use LogVerbosity::Information;
use LogVerbosity::Warning;
use LogMessageType::GenericDebug;
use LogMessageType::GenericErr;
use LogMessageType::GenericInfo;
use LogMessageType::GenericWarn;
/**
* Logs the given message.
*/
pub fn log_message(
msg: &LogMessage,
conf: &LogSettings,
file: &str,
line: u32,
column: u32,
) -> Result<(), io::Error> {
// Check if message may be logged according to config.
let Some(log_line) = log_to_str(msg, conf, file, line, column) else {
return Ok(());
};
// Log to file
match conf.path().as_ref() {
None => { /* Do not log to file */ }
Some(p) => {
let file = OpenOptions::new()
.write(true)
.append(true)
.create(true)
.open(p);
let mut file = match file {
Ok(f) => f,
Err(e) => return Err(e),
};
match writeln!(file, "{log_line}") {
Ok(_) => {}
Err(e) => return Err(e),
}
}
};
if msg.1 <= Warning && *conf.stderr() {
let mut stdout = io::stdout().lock();
return writeln!(stdout, "{log_line}");
} else if msg.1 >= Information && *conf.stdout() {
let mut stderr = io::stderr().lock();
return writeln!(stderr, "{log_line}");
}
return Ok(());
}
/**
* Return log line, if message may be logged according to [`config::log::LogSettings`].
*/
#[must_use]
pub fn log_to_str(
msg: &LogMessage,
conf: &LogSettings,
file: &str,
line: u32,
column: u32,
) -> Option<String> {
if conf.verbosity() < &msg.1 {
return None;
}
let mut log_line = String::new();
// add time substring
if *conf.time() {
log_line += &format!("{} ", Utc::now().format(conf.time_format()));
}
// add code location substring
if *conf.location() {
log_line += &format!("{file}:{line},{column} ");
}
Some(log_line + &msg.to_string())
}
/**
* Shorthand version for [`log_message`], which does not require information about where in the code
* the command was called from.
* Tries to aqcuire a read lock, if a reference to an instance of [`config::log::LogSettings`] is
* not given.
*/
#[macro_export]
macro_rules! log {
($msg:expr) => {
let conf = config::LOG_SETTINGS
.read()
.unwrap_or_else(|_| panic!("Failed aqcuire read lock on config!"));
let res = log_message($msg, &*conf, file!(), line!(), column!());
drop(conf);
res
};
($msg:expr, $config:expr) => {
log_message($msg, $config, file!(), line!(), column!())
};
}
/**
* A named typle assigning a log [`LogVerbosity`] to a [`LogMessageType`].
*/
#[derive(PartialEq, Eq, Debug)]
pub struct LogMessage(LogMessageType, LogVerbosity);
impl Display for LogMessage {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::result::Result<(), std::fmt::Error> {
self.0.fmt(f) // just display LogMessageType
}
}
// TODO: Evaluate replacing the following with a trait, to decouple this component from the rest.
/**
* Every possible Message, which may be logged. Grouped the following:
*
* These groups correspond to the four levels of logging verbosity. See [`LogVerbosity`].
*/
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum LogMessageType {
/// Errors
/// An error of any type; please avoid using this.
GenericErr(String),
/// Warnings
/// A warning of any type; please avoid using this.
GenericWarn(String),
/// Info
/// Some information of any type; please avoid using this.
GenericInfo(String),
/// Debug
/// a debug message of any type; please avoid using this.
GenericDebug(String),
}
/*
* Please don't put anything other except new match arms below this comment, since it is expected that
* the following code will have a lot of lines, due to exhausting all possible enum values
* respectively and (hopefully) detailed messages.
*/
impl LogMessageType {
/// Returns a new [`LogMessage`] based on the default verbosity of the given [`LogMessageType`].
#[must_use]
pub fn log_message(&self) -> LogMessage {
let verbosity = match self {
GenericErr(_) => LogVerbosity::Error,
GenericWarn(_) => LogVerbosity::Warning,
GenericInfo(_) => LogVerbosity::Information,
GenericDebug(_) => LogVerbosity::Debugging,
};
LogMessage(self.clone(), verbosity)
}
}
impl Display for LogMessageType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::result::Result<(), std::fmt::Error> {
match self {
GenericErr(err) => {
write!(f, "Generic Error: {err}")
}
GenericWarn(warn) => {
write!(f, "Generic Warning: {warn}")
}
GenericInfo(info) => {
write!(f, "Generic Information: {info}")
}
GenericDebug(debug) => {
write!(f, "Generic Debug Message: {debug}")
}
}
}
}

303
logging/src/test.rs Normal file
View File

@ -0,0 +1,303 @@
#![cfg(test)]
/*!
* This test suite uses uuid to easily avoid race conditions when writing to the same log file.
*/
use std::{
collections::HashSet,
env,
fs::{create_dir_all, read_to_string},
mem::take,
path::PathBuf,
};
use futures::{executor::block_on, future::join_all};
use once_cell::sync::Lazy;
use uuid::Uuid;
use super::*;
const CONCURRENT_MESSAGE_COUNT: usize = 99999;
static LOG_DIR: Lazy<String> = Lazy::new(|| {
if cfg!(unix) {
String::from("/tmp/WANessa/unit-tests/logging")
} else if cfg!(windows) {
let tmp_path = env::var("TMP").unwrap_or_else(|_| {
env::var("TEMP").expect(
"Windows should have both TMP and TEMP, but you have neither, what did you do?",
)
});
format!("{tmp_path}/WANessa/unit-tests/logging")
} else {
String::new()
}
});
/// Tests if the macro logs properly with a given config.
#[test]
pub fn log_macro_given_config() {
let log_path = &format!("{}/{}", *LOG_DIR, Uuid::new_v4());
println!("Log Path: {log_path}");
let message = LogMessageType::GenericWarn(String::from("Test Log")).log_message();
let mut config = config::log::LogSettings::default();
config.set_path(PathBuf::from(log_path));
create_dir_all(PathBuf::from(LOG_DIR.as_str()))
.unwrap_or_else(|_| panic!("Could not create directory: {}", *LOG_DIR));
log!(&message, &config);
let log_file = read_to_string(PathBuf::from(log_path))
.unwrap_or_else(|_| panic!("Could not read file: {log_path}"));
assert_eq!(message.to_string() + "\n", log_file);
}
/// Tests if [`log_message`] to file correctly.
#[test]
pub fn log_msg_file() {
let log_path = &format!("{}/{}", *LOG_DIR, Uuid::new_v4());
println!("Log Path: {log_path}");
let message = LogMessageType::GenericWarn(String::from("Test Log")).log_message();
let mut config = config::log::LogSettings::default();
config.set_path(PathBuf::from(log_path));
create_dir_all(PathBuf::from(LOG_DIR.as_str()))
.unwrap_or_else(|_| panic!("Could not create directory: {}", *LOG_DIR));
log!(&message, &config);
let log_file = read_to_string(PathBuf::from(log_path))
.unwrap_or_else(|_| panic!("Could not read file: {log_path}"));
assert_eq!(message.to_string() + "\n", log_file);
}
/// Tests if [`log_message`] does not modify output from [`log_to_str`], when logging to file.
#[test]
pub fn log_str() {
let log_path = &format!("{}/{}", *LOG_DIR, Uuid::new_v4());
println!("Log Path: {log_path}");
let message = LogMessageType::GenericWarn(String::from("Test Log")).log_message();
let mut config = config::log::LogSettings::default();
config.set_path(PathBuf::from(log_path));
create_dir_all(PathBuf::from(LOG_DIR.as_str()))
.unwrap_or_else(|_| panic!("Could not create directory: {}", *LOG_DIR));
let log_line = log_to_str(&message, &config, file!(), line!(), column!())
.expect("There should be a log line.")
+ "\n";
log!(&message, &config);
let log_file = read_to_string(PathBuf::from(log_path))
.unwrap_or_else(|_| panic!("Could not read file: {log_path}"));
assert_eq!(log_line, log_file);
}
/// Tests if no messages are unintentionally filtered due to their verboisity.
#[test]
pub fn verbosity_no_filter() {
let log_path = &format!("{}/{}", *LOG_DIR, Uuid::new_v4());
println!("Log Path: {log_path}");
let messages = vec![
LogMessageType::GenericErr(String::from("Test Err")).log_message(),
LogMessageType::GenericWarn(String::from("Test Warn")).log_message(),
LogMessageType::GenericInfo(String::from("Test Info")).log_message(),
LogMessageType::GenericDebug(String::from("Test Debug")).log_message(),
];
let mut config = config::log::LogSettings::default();
config.set_path(PathBuf::from(log_path));
config.set_verbosity(LogVerbosity::Error);
create_dir_all(PathBuf::from(LOG_DIR.as_str()))
.unwrap_or_else(|_| panic!("Could not create directory: {}", *LOG_DIR));
let log_line = log_to_str(&messages[0], &config, file!(), line!(), column!())
.expect("There should be a log line.")
+ "\n";
for msg in messages {
log!(&msg, &config);
}
let log_file = read_to_string(PathBuf::from(log_path))
.unwrap_or_else(|_| panic!("Could not read file: {log_path}"));
assert_eq!(log_file, log_line);
}
/// Tests if messages are properly filtered according to their verbosity.
#[test]
pub fn verbosity_filter() {
let log_path = &format!("{}/{}", *LOG_DIR, Uuid::new_v4());
println!("Log Path: {log_path}");
let messages = vec![
LogMessageType::GenericErr(String::from("Test Err")).log_message(),
LogMessageType::GenericWarn(String::from("Test Warn")).log_message(),
LogMessageType::GenericInfo(String::from("Test Info")).log_message(),
LogMessageType::GenericDebug(String::from("Test Debug")).log_message(),
];
let mut config = config::log::LogSettings::default();
config.set_path(PathBuf::from(log_path));
create_dir_all(PathBuf::from(LOG_DIR.as_str()))
.unwrap_or_else(|_| panic!("Could not create directory: {}", *LOG_DIR));
let mut log_line = log_to_str(&messages[0], &config, file!(), line!(), column!())
.expect("There should be a log line.")
+ "\n";
log_line += &(log_to_str(&messages[1], &config, file!(), line!(), column!())
.expect("There should be a log line.")
+ "\n");
for msg in messages {
log!(&msg, &config);
}
let log_file = read_to_string(PathBuf::from(log_path))
.unwrap_or_else(|_| panic!("Could not read file: {log_path}"));
assert_eq!(log_file, log_line);
}
/**
* All testing concurrency in a controlled manner.
*
* When a test modifies the config according to it's requirements, another test might panic, because
* it might read the result from the changed config.
*/
#[test]
pub fn concurrency_tests() {
log_macro_shared_config();
log_concurrently_any_order();
log_concurrently_correct_order();
}
/**
* Tests if log macro logs to file correctly.
* [`config::CONFIG`]
*/
fn log_macro_shared_config() {
let log_path = &format!("{}/{}", *LOG_DIR, Uuid::new_v4());
println!("Log Path: {log_path}");
let message = LogMessageType::GenericWarn(String::from("Test Log")).log_message();
let mut config = config::LOG_SETTINGS
.write()
.expect("Could not acquire write lock on config!");
take(&mut *config);
config.set_path(PathBuf::from(log_path));
config.set_stdout(false);
config.set_stderr(false);
drop(config);
create_dir_all(PathBuf::from(LOG_DIR.as_str()))
.unwrap_or_else(|_| panic!("Could not create directory: {}", *LOG_DIR));
log!(&message);
let log_file = read_to_string(PathBuf::from(log_path))
.unwrap_or_else(|_| panic!("Could not read file: {log_path}"));
assert_eq!(message.to_string() + "\n", log_file);
}
/**
* Tests concurrent logging. Log lines may be in any order.
*/
fn log_concurrently_any_order() {
let log_path = &format!("{}/{}", *LOG_DIR, Uuid::new_v4());
println!("Log Path: {log_path}");
let mut config = config::LOG_SETTINGS
.write()
.expect("Could not acquire write lock on config!");
take(&mut *config);
let mut messages = Vec::with_capacity(CONCURRENT_MESSAGE_COUNT);
config.set_path(PathBuf::from(log_path));
config.set_stdout(false);
config.set_stderr(false);
drop(config);
for i in 0..CONCURRENT_MESSAGE_COUNT {
let msg = i.to_string();
messages.push(async {
log!(&LogMessageType::GenericWarn(msg).log_message());
});
}
block_on(join_all(messages));
let mut num_set = HashSet::with_capacity(CONCURRENT_MESSAGE_COUNT);
for i in 0..CONCURRENT_MESSAGE_COUNT {
num_set.insert(i);
}
for line in read_to_string(PathBuf::from(log_path))
.unwrap_or_else(|_| panic!("Could not read file: {log_path}"))
.lines()
{
let num_str = line
.split_whitespace()
.last()
.unwrap_or_else(|| panic!("Could not get message number from line: {line}"));
let num = num_str
.parse()
.unwrap_or_else(|_| panic!("Could not parse number: {num_str}"));
assert!(num_set.remove(&num));
}
assert_eq!(num_set.len(), 0);
}
/**
* Tests concurrent logging. Log lines must be in order.
*/
fn log_concurrently_correct_order() {
let log_path = &format!("{}/{}", *LOG_DIR, Uuid::new_v4());
println!("Log Path: {log_path}");
let mut config = config::LOG_SETTINGS
.write()
.expect("Could not acquire write lock on config!");
take(&mut *config);
let mut messages = Vec::with_capacity(CONCURRENT_MESSAGE_COUNT);
config.set_path(PathBuf::from(log_path));
config.set_stdout(false);
config.set_stderr(false);
drop(config);
for i in 0..CONCURRENT_MESSAGE_COUNT {
let msg = i.to_string();
messages.push(async {
log!(&LogMessageType::GenericWarn(msg).log_message());
});
}
block_on(join_all(messages));
let mut num_set = HashSet::with_capacity(CONCURRENT_MESSAGE_COUNT);
for i in 0..CONCURRENT_MESSAGE_COUNT {
num_set.insert(i);
}
for (i, line) in read_to_string(PathBuf::from(log_path))
.unwrap_or_else(|_| panic!("Could not read file: {log_path}"))
.lines()
.enumerate()
{
let num_str = line
.split_whitespace()
.last()
.unwrap_or_else(|| panic!("Could not get message number from line: {line}"));
let num = num_str
.parse()
.unwrap_or_else(|_| panic!("Could not parse number: {num_str}"));
assert!(num_set.remove(&num));
assert_eq!(i, num);
}
assert_eq!(num_set.len(), 0);
}