use std::collections::BTreeMap;
use std::time::SystemTime;
use serde::Serialize;
use crate::model::metric::Labels;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[cfg_attr(feature = "config", derive(serde::Deserialize))]
#[serde(rename_all = "lowercase")]
pub enum Severity {
Trace,
Debug,
Info,
Warn,
Error,
Fatal,
}
impl Severity {
const fn rank(self) -> u8 {
match self {
Severity::Trace => 0,
Severity::Debug => 1,
Severity::Info => 2,
Severity::Warn => 3,
Severity::Error => 4,
Severity::Fatal => 5,
}
}
}
impl Ord for Severity {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.rank().cmp(&other.rank())
}
}
impl PartialOrd for Severity {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
#[derive(Debug, Clone)]
pub struct LogEvent {
pub timestamp: SystemTime,
pub severity: Severity,
pub message: String,
pub labels: Labels,
pub fields: BTreeMap<String, String>,
}
impl LogEvent {
pub fn new(
severity: Severity,
message: String,
labels: Labels,
fields: BTreeMap<String, String>,
) -> Self {
Self {
timestamp: SystemTime::now(),
severity,
message,
labels,
fields,
}
}
pub fn with_timestamp(
timestamp: SystemTime,
severity: Severity,
message: String,
labels: Labels,
fields: BTreeMap<String, String>,
) -> Self {
Self {
timestamp,
severity,
message,
labels,
fields,
}
}
}
#[cfg(test)]
mod tests {
use std::time::{Duration, UNIX_EPOCH};
use super::*;
#[test]
fn new_uses_current_timestamp() {
let before = SystemTime::now();
let event = LogEvent::new(
Severity::Info,
"hello".to_string(),
Labels::default(),
BTreeMap::new(),
);
let after = SystemTime::now();
assert!(
event.timestamp >= before,
"timestamp should not precede the call"
);
assert!(
event.timestamp <= after,
"timestamp should not exceed the call"
);
}
#[test]
fn new_stores_severity_message_and_fields() {
let mut fields = BTreeMap::new();
fields.insert("host".to_string(), "web-01".to_string());
let event = LogEvent::new(
Severity::Error,
"connection failed".to_string(),
Labels::default(),
fields,
);
assert_eq!(event.severity, Severity::Error);
assert_eq!(event.message, "connection failed");
assert_eq!(event.fields.get("host").map(String::as_str), Some("web-01"));
}
#[test]
fn new_with_empty_fields_succeeds() {
let event = LogEvent::new(
Severity::Debug,
"empty".to_string(),
Labels::default(),
BTreeMap::new(),
);
assert!(event.fields.is_empty());
}
#[test]
fn with_timestamp_uses_exact_provided_timestamp() {
let ts = UNIX_EPOCH + Duration::from_secs(1_700_000_000);
let event = LogEvent::with_timestamp(
ts,
Severity::Warn,
"test message".to_string(),
Labels::default(),
BTreeMap::new(),
);
assert_eq!(
event.timestamp, ts,
"timestamp must be exactly the one provided"
);
}
#[test]
fn with_timestamp_stores_all_fields_correctly() {
let ts = UNIX_EPOCH + Duration::from_secs(42);
let mut fields = BTreeMap::new();
fields.insert("service".to_string(), "api".to_string());
fields.insert("region".to_string(), "us-east-1".to_string());
let event = LogEvent::with_timestamp(
ts,
Severity::Fatal,
"system crash".to_string(),
Labels::default(),
fields,
);
assert_eq!(event.timestamp, ts);
assert_eq!(event.severity, Severity::Fatal);
assert_eq!(event.message, "system crash");
assert_eq!(event.fields.get("service").map(String::as_str), Some("api"));
assert_eq!(
event.fields.get("region").map(String::as_str),
Some("us-east-1")
);
}
#[test]
fn with_timestamp_at_unix_epoch_is_valid() {
let event = LogEvent::with_timestamp(
UNIX_EPOCH,
Severity::Trace,
"epoch".to_string(),
Labels::default(),
BTreeMap::new(),
);
assert_eq!(event.timestamp, UNIX_EPOCH);
}
#[test]
fn fields_are_sorted_by_key() {
let mut fields = BTreeMap::new();
fields.insert("zebra".to_string(), "z".to_string());
fields.insert("alpha".to_string(), "a".to_string());
fields.insert("mango".to_string(), "m".to_string());
let event = LogEvent::new(
Severity::Info,
"sorted".to_string(),
Labels::default(),
fields,
);
let keys: Vec<&str> = event.fields.keys().map(String::as_str).collect();
assert_eq!(keys, vec!["alpha", "mango", "zebra"]);
}
#[test]
fn severity_trace_serializes_to_lowercase_json() {
let s = serde_json::to_string(&Severity::Trace).unwrap();
assert_eq!(s, r#""trace""#);
}
#[test]
fn severity_debug_serializes_to_lowercase_json() {
let s = serde_json::to_string(&Severity::Debug).unwrap();
assert_eq!(s, r#""debug""#);
}
#[test]
fn severity_info_serializes_to_lowercase_json() {
let s = serde_json::to_string(&Severity::Info).unwrap();
assert_eq!(s, r#""info""#);
}
#[test]
fn severity_warn_serializes_to_lowercase_json() {
let s = serde_json::to_string(&Severity::Warn).unwrap();
assert_eq!(s, r#""warn""#);
}
#[test]
fn severity_error_serializes_to_lowercase_json() {
let s = serde_json::to_string(&Severity::Error).unwrap();
assert_eq!(s, r#""error""#);
}
#[test]
fn severity_fatal_serializes_to_lowercase_json() {
let s = serde_json::to_string(&Severity::Fatal).unwrap();
assert_eq!(s, r#""fatal""#);
}
#[cfg(feature = "config")]
#[test]
fn severity_deserializes_from_lowercase_trace() {
let s: Severity = serde_json::from_str(r#""trace""#).unwrap();
assert_eq!(s, Severity::Trace);
}
#[cfg(feature = "config")]
#[test]
fn severity_deserializes_from_lowercase_debug() {
let s: Severity = serde_json::from_str(r#""debug""#).unwrap();
assert_eq!(s, Severity::Debug);
}
#[cfg(feature = "config")]
#[test]
fn severity_deserializes_from_lowercase_info() {
let s: Severity = serde_json::from_str(r#""info""#).unwrap();
assert_eq!(s, Severity::Info);
}
#[cfg(feature = "config")]
#[test]
fn severity_deserializes_from_lowercase_warn() {
let s: Severity = serde_json::from_str(r#""warn""#).unwrap();
assert_eq!(s, Severity::Warn);
}
#[cfg(feature = "config")]
#[test]
fn severity_deserializes_from_lowercase_error() {
let s: Severity = serde_json::from_str(r#""error""#).unwrap();
assert_eq!(s, Severity::Error);
}
#[cfg(feature = "config")]
#[test]
fn severity_deserializes_from_lowercase_fatal() {
let s: Severity = serde_json::from_str(r#""fatal""#).unwrap();
assert_eq!(s, Severity::Fatal);
}
#[cfg(feature = "config")]
#[test]
fn severity_rejects_uppercase_deserialization() {
let result: Result<Severity, _> = serde_json::from_str(r#""INFO""#);
assert!(
result.is_err(),
"uppercase severity string must be rejected"
);
}
#[cfg(feature = "config")]
#[test]
fn severity_rejects_unknown_variant() {
let result: Result<Severity, _> = serde_json::from_str(r#""critical""#);
assert!(result.is_err(), "unknown severity variant must be rejected");
}
#[cfg(feature = "config")]
#[test]
fn severity_info_serializes_to_lowercase_yaml() {
let s = serde_yaml_ng::to_string(&Severity::Info).unwrap();
assert!(s.trim() == "info", "expected 'info', got: {s}");
}
#[cfg(feature = "config")]
#[test]
fn severity_error_serializes_to_lowercase_yaml() {
let s = serde_yaml_ng::to_string(&Severity::Error).unwrap();
assert!(s.trim() == "error", "expected 'error', got: {s}");
}
#[test]
fn severity_is_send_and_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<Severity>();
}
#[test]
fn severity_ordering_follows_severity_ladder() {
assert!(Severity::Trace < Severity::Debug);
assert!(Severity::Debug < Severity::Info);
assert!(Severity::Info < Severity::Warn);
assert!(Severity::Warn < Severity::Error);
assert!(Severity::Error < Severity::Fatal);
}
#[test]
fn severity_equal_variants_compare_as_equal() {
assert_eq!(
Severity::Info.cmp(&Severity::Info),
std::cmp::Ordering::Equal
);
assert_eq!(
Severity::Fatal.cmp(&Severity::Fatal),
std::cmp::Ordering::Equal
);
}
#[test]
fn severity_partial_ord_consistent_with_ord() {
assert_eq!(
Severity::Trace.partial_cmp(&Severity::Fatal),
Some(std::cmp::Ordering::Less)
);
assert_eq!(
Severity::Fatal.partial_cmp(&Severity::Trace),
Some(std::cmp::Ordering::Greater)
);
}
#[test]
fn severity_sort_produces_ascending_order() {
let mut levels = vec![
Severity::Fatal,
Severity::Trace,
Severity::Warn,
Severity::Debug,
Severity::Error,
Severity::Info,
];
levels.sort();
assert_eq!(
levels,
vec![
Severity::Trace,
Severity::Debug,
Severity::Info,
Severity::Warn,
Severity::Error,
Severity::Fatal,
]
);
}
#[test]
fn log_event_is_send_and_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<LogEvent>();
}
#[test]
fn log_event_clone_is_independent() {
let ts = UNIX_EPOCH + Duration::from_secs(1000);
let mut fields = BTreeMap::new();
fields.insert("k".to_string(), "v".to_string());
let original = LogEvent::with_timestamp(
ts,
Severity::Info,
"msg".to_string(),
Labels::default(),
fields,
);
let mut cloned = original.clone();
cloned.message = "different".to_string();
cloned.fields.insert("k".to_string(), "changed".to_string());
assert_eq!(original.message, "msg");
assert_eq!(original.fields.get("k").map(String::as_str), Some("v"));
}
#[test]
fn new_stores_labels_correctly() {
let labels = Labels::from_pairs(&[("device", "wlan0"), ("hostname", "router-01")]).unwrap();
let event = LogEvent::new(Severity::Info, "test".to_string(), labels, BTreeMap::new());
assert_eq!(event.labels.len(), 2);
let label_pairs: Vec<(&str, &str)> = event.labels.iter().collect();
assert_eq!(label_pairs[0].0, "device");
assert_eq!(label_pairs[0].1, "wlan0");
assert_eq!(label_pairs[1].0, "hostname");
assert_eq!(label_pairs[1].1, "router-01");
}
#[test]
fn with_timestamp_stores_labels_correctly() {
let ts = UNIX_EPOCH + Duration::from_secs(1_700_000_000);
let labels = Labels::from_pairs(&[("env", "staging"), ("region", "us_west")]).unwrap();
let event = LogEvent::with_timestamp(
ts,
Severity::Warn,
"warning event".to_string(),
labels,
BTreeMap::new(),
);
assert_eq!(event.labels.len(), 2);
let label_pairs: Vec<(&str, &str)> = event.labels.iter().collect();
assert_eq!(label_pairs[0].0, "env");
assert_eq!(label_pairs[0].1, "staging");
assert_eq!(label_pairs[1].0, "region");
assert_eq!(label_pairs[1].1, "us_west");
}
#[test]
fn log_event_clone_preserves_labels() {
let ts = UNIX_EPOCH + Duration::from_secs(1000);
let labels = Labels::from_pairs(&[("service", "api"), ("zone", "eu1")]).unwrap();
let original = LogEvent::with_timestamp(
ts,
Severity::Error,
"cloned".to_string(),
labels,
BTreeMap::new(),
);
let cloned = original.clone();
assert_eq!(cloned.labels.len(), 2);
let original_pairs: Vec<(&str, &str)> = original.labels.iter().collect();
let cloned_pairs: Vec<(&str, &str)> = cloned.labels.iter().collect();
assert_eq!(original_pairs, cloned_pairs);
}
#[test]
fn new_with_empty_labels_has_no_labels() {
let event = LogEvent::new(
Severity::Info,
"no labels".to_string(),
Labels::default(),
BTreeMap::new(),
);
assert!(event.labels.is_empty());
assert_eq!(event.labels.len(), 0);
}
}