use std::collections::BTreeMap;
use serde_json::Value;
use logfence_proto::syslog::{Facility, Priority, Severity, SyslogMessage};
use crate::{
error::{BuildError, ClientError},
transport::Transport,
};
pub const CEE_COOKIE: &str = "@cee:";
#[derive(Debug)]
pub struct MessageBuilder {
facility: Facility,
severity: Severity,
timestamp: Option<String>,
hostname: Option<String>,
app_name: Option<String>,
proc_id: Option<String>,
msg_id: Option<String>,
fields: BTreeMap<String, Value>,
cee_cookie: bool,
}
impl MessageBuilder {
#[must_use]
pub fn new(facility: Facility, severity: Severity) -> Self {
Self {
facility,
severity,
timestamp: None,
hostname: None,
app_name: None,
proc_id: Some(std::process::id().to_string()),
msg_id: None,
fields: BTreeMap::new(),
cee_cookie: false,
}
}
#[must_use]
pub fn cee_cookie(mut self, enabled: bool) -> Self {
self.cee_cookie = enabled;
self
}
#[must_use]
pub fn timestamp(mut self, ts: impl Into<String>) -> Self {
self.timestamp = Some(ts.into());
self
}
#[must_use]
pub fn hostname(mut self, h: impl Into<String>) -> Self {
self.hostname = Some(h.into());
self
}
#[must_use]
pub fn app_name(mut self, name: impl Into<String>) -> Self {
self.app_name = Some(name.into());
self
}
#[must_use]
pub fn proc_id(mut self, pid: impl Into<String>) -> Self {
self.proc_id = Some(pid.into());
self
}
#[must_use]
pub fn msgid(mut self, id: impl Into<String>) -> Self {
self.msg_id = Some(id.into());
self
}
pub fn kv(
mut self,
key: impl Into<String>,
val: impl serde::Serialize,
) -> Result<Self, BuildError> {
let key = key.into();
if key.is_empty() {
return Err(BuildError::EmptyKey);
}
let value = serde_json::to_value(val)?;
self.fields.insert(key, value);
Ok(self)
}
pub fn build(self) -> Result<SyslogMessage, BuildError> {
let json = serde_json::to_string(&self.fields)?;
let msg = if self.cee_cookie {
format!("{CEE_COOKIE}{json}")
} else {
json
};
Ok(SyslogMessage {
priority: Priority {
facility: self.facility,
severity: self.severity,
},
timestamp: self.timestamp,
hostname: self.hostname,
app_name: self.app_name,
proc_id: self.proc_id,
msg_id: self.msg_id,
structured_data: "-".to_owned(),
msg,
})
}
pub async fn send<T: Transport>(self, transport: &T) -> Result<(), ClientError> {
let msg = self.build()?;
transport.send(&msg).await
}
}
#[must_use]
pub fn now_rfc3339() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
epoch_secs_to_rfc3339(secs)
}
fn epoch_secs_to_rfc3339(secs: u64) -> String {
let days = secs / 86_400;
let rem = secs % 86_400;
let h = rem / 3_600;
let m = (rem % 3_600) / 60;
let s = rem % 60;
let (year, month, day) = civil_from_days(days);
format!("{year:04}-{month:02}-{day:02}T{h:02}:{m:02}:{s:02}Z")
}
#[allow(
clippy::cast_sign_loss,
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
reason = "intermediate values are mathematically bounded by the Unix epoch range; \
results fit in i32/u32 for any date within ±millions of years"
)]
fn civil_from_days(days: u64) -> (i32, u32, u32) {
let z: i64 = days as i64 + 719_468;
let era: i64 = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe: u64 = (z - era * 146_097) as u64;
let yoe: u64 = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
let y: i64 = yoe as i64 + era * 400;
let doy: u64 = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp: u64 = (5 * doy + 2) / 153;
let d: u64 = doy - (153 * mp + 2) / 5 + 1;
let m: u64 = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
(y as i32, m as u32, d as u32)
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
reason = "unwrap is appropriate in test assertions"
)]
mod tests {
use logfence_proto::syslog::{Facility, Severity};
use super::*;
#[test]
fn build_defaults() {
let msg = MessageBuilder::new(Facility::Local0, Severity::Info)
.build()
.unwrap();
assert_eq!(msg.priority.as_integer(), 134); assert!(msg.timestamp.is_none());
assert!(msg.hostname.is_none());
assert!(msg.app_name.is_none());
assert!(msg.proc_id.is_some());
assert!(msg.msg_id.is_none());
assert_eq!(msg.structured_data, "-");
assert_eq!(msg.msg, "{}"); }
#[test]
fn build_with_all_header_fields() {
let msg = MessageBuilder::new(Facility::Daemon, Severity::Warning)
.timestamp("2026-05-14T12:00:00Z")
.hostname("myhost")
.app_name("myapp")
.proc_id("9999")
.msgid("AUDIT")
.build()
.unwrap();
assert_eq!(msg.timestamp.as_deref(), Some("2026-05-14T12:00:00Z"));
assert_eq!(msg.hostname.as_deref(), Some("myhost"));
assert_eq!(msg.app_name.as_deref(), Some("myapp"));
assert_eq!(msg.proc_id.as_deref(), Some("9999"));
assert_eq!(msg.msg_id.as_deref(), Some("AUDIT"));
}
#[test]
fn build_kv_string() {
let msg = MessageBuilder::new(Facility::Local0, Severity::Info)
.kv("action", "login")
.unwrap()
.build()
.unwrap();
let v: serde_json::Value = serde_json::from_str(&msg.msg).unwrap();
assert_eq!(v["action"], "login");
}
#[test]
fn build_kv_integer() {
let msg = MessageBuilder::new(Facility::Local0, Severity::Info)
.kv("user_id", 42_u32)
.unwrap()
.build()
.unwrap();
let v: serde_json::Value = serde_json::from_str(&msg.msg).unwrap();
assert_eq!(v["user_id"], 42);
}
#[test]
fn build_kv_bool() {
let msg = MessageBuilder::new(Facility::Local0, Severity::Info)
.kv("success", true)
.unwrap()
.build()
.unwrap();
let v: serde_json::Value = serde_json::from_str(&msg.msg).unwrap();
assert_eq!(v["success"], true);
}
#[test]
fn build_kv_nested_object() {
let nested = serde_json::json!({"code": 200, "reason": "OK"});
let msg = MessageBuilder::new(Facility::Local0, Severity::Info)
.kv("response", nested)
.unwrap()
.build()
.unwrap();
let v: serde_json::Value = serde_json::from_str(&msg.msg).unwrap();
assert_eq!(v["response"]["code"], 200);
}
#[test]
fn build_kv_multiple_fields_sorted() {
let msg = MessageBuilder::new(Facility::Local0, Severity::Info)
.kv("z_last", 3_u32)
.unwrap()
.kv("a_first", 1_u32)
.unwrap()
.kv("m_middle", 2_u32)
.unwrap()
.build()
.unwrap();
assert_eq!(msg.msg, r#"{"a_first":1,"m_middle":2,"z_last":3}"#);
}
#[test]
fn build_kv_duplicate_key_last_wins() {
let msg = MessageBuilder::new(Facility::Local0, Severity::Info)
.kv("key", "first")
.unwrap()
.kv("key", "second")
.unwrap()
.build()
.unwrap();
let v: serde_json::Value = serde_json::from_str(&msg.msg).unwrap();
assert_eq!(v["key"], "second");
}
#[test]
fn build_kv_empty_key_rejected() {
let err = MessageBuilder::new(Facility::Local0, Severity::Info)
.kv("", "value")
.unwrap_err();
assert!(matches!(err, BuildError::EmptyKey));
}
#[test]
fn build_produces_valid_syslog_message() {
let msg = MessageBuilder::new(Facility::Local7, Severity::Debug)
.app_name("roundtrip")
.msgid("TEST")
.kv("hello", "world")
.unwrap()
.build()
.unwrap();
let wire = msg.to_string();
let parsed = SyslogMessage::parse(&wire).unwrap();
assert_eq!(parsed, msg);
}
#[test]
fn now_rfc3339_format() {
let ts = now_rfc3339();
assert_eq!(ts.len(), 20);
assert_eq!(&ts[4..5], "-");
assert_eq!(&ts[7..8], "-");
assert_eq!(&ts[10..11], "T");
assert_eq!(&ts[13..14], ":");
assert_eq!(&ts[16..17], ":");
assert_eq!(&ts[19..20], "Z");
}
#[test]
fn build_cee_cookie_prefixes_json() {
let msg = MessageBuilder::new(Facility::Local0, Severity::Info)
.cee_cookie(true)
.kv("event", "login")
.unwrap()
.build()
.unwrap();
assert!(
msg.msg.starts_with("@cee:"),
"expected @cee: prefix, got: {}",
msg.msg
);
let json_part = &msg.msg["@cee:".len()..];
let v: serde_json::Value = serde_json::from_str(json_part).unwrap();
assert_eq!(v["event"], "login");
}
#[test]
fn build_without_cee_cookie_has_no_prefix() {
let msg = MessageBuilder::new(Facility::Local0, Severity::Info)
.kv("event", "login")
.unwrap()
.build()
.unwrap();
assert!(
!msg.msg.starts_with("@cee:"),
"expected no @cee: prefix, got: {}",
msg.msg
);
}
#[test]
fn build_cee_cookie_empty_fields() {
let msg = MessageBuilder::new(Facility::Local0, Severity::Info)
.cee_cookie(true)
.build()
.unwrap();
assert_eq!(msg.msg, "@cee:{}");
}
#[test]
fn build_cee_cookie_multiple_fields_sorted() {
let msg = MessageBuilder::new(Facility::Local0, Severity::Info)
.cee_cookie(true)
.kv("z", 3_u32)
.unwrap()
.kv("a", 1_u32)
.unwrap()
.build()
.unwrap();
assert_eq!(msg.msg, r#"@cee:{"a":1,"z":3}"#);
}
#[test]
fn epoch_secs_known_dates() {
assert_eq!(epoch_secs_to_rfc3339(0), "1970-01-01T00:00:00Z");
assert_eq!(epoch_secs_to_rfc3339(946_684_800), "2000-01-01T00:00:00Z");
assert_eq!(epoch_secs_to_rfc3339(951_782_400), "2000-02-29T00:00:00Z");
assert_eq!(epoch_secs_to_rfc3339(1_778_716_800), "2026-05-14T00:00:00Z");
assert_eq!(
epoch_secs_to_rfc3339(1_778_716_800 + 13 * 3_600 + 45 * 60 + 30),
"2026-05-14T13:45:30Z"
);
}
}