1use std::collections::BTreeMap;
24
25use serde_json::Value;
26
27use logfence_proto::syslog::{Facility, Priority, Severity, SyslogMessage};
28
29use crate::{
30 error::{BuildError, ClientError},
31 transport::Transport,
32};
33
34pub const CEE_COOKIE: &str = "@cee:";
38
39#[derive(Debug)]
51pub struct MessageBuilder {
52 facility: Facility,
53 severity: Severity,
54 timestamp: Option<String>,
55 hostname: Option<String>,
56 app_name: Option<String>,
57 proc_id: Option<String>,
58 msg_id: Option<String>,
59 fields: BTreeMap<String, Value>,
60 cee_cookie: bool,
61}
62
63impl MessageBuilder {
64 #[must_use]
69 pub fn new(facility: Facility, severity: Severity) -> Self {
70 Self {
71 facility,
72 severity,
73 timestamp: None,
74 hostname: None,
75 app_name: None,
76 proc_id: Some(std::process::id().to_string()),
77 msg_id: None,
78 fields: BTreeMap::new(),
79 cee_cookie: false,
80 }
81 }
82
83 #[must_use]
91 pub fn cee_cookie(mut self, enabled: bool) -> Self {
92 self.cee_cookie = enabled;
93 self
94 }
95
96 #[must_use]
101 pub fn timestamp(mut self, ts: impl Into<String>) -> Self {
102 self.timestamp = Some(ts.into());
103 self
104 }
105
106 #[must_use]
108 pub fn hostname(mut self, h: impl Into<String>) -> Self {
109 self.hostname = Some(h.into());
110 self
111 }
112
113 #[must_use]
115 pub fn app_name(mut self, name: impl Into<String>) -> Self {
116 self.app_name = Some(name.into());
117 self
118 }
119
120 #[must_use]
122 pub fn proc_id(mut self, pid: impl Into<String>) -> Self {
123 self.proc_id = Some(pid.into());
124 self
125 }
126
127 #[must_use]
129 pub fn msgid(mut self, id: impl Into<String>) -> Self {
130 self.msg_id = Some(id.into());
131 self
132 }
133
134 pub fn kv(
147 mut self,
148 key: impl Into<String>,
149 val: impl serde::Serialize,
150 ) -> Result<Self, BuildError> {
151 let key = key.into();
152 if key.is_empty() {
153 return Err(BuildError::EmptyKey);
154 }
155 let value = serde_json::to_value(val)?;
156 self.fields.insert(key, value);
157 Ok(self)
158 }
159
160 pub fn build(self) -> Result<SyslogMessage, BuildError> {
169 let json = serde_json::to_string(&self.fields)?;
170 let msg = if self.cee_cookie {
171 format!("{CEE_COOKIE}{json}")
172 } else {
173 json
174 };
175 Ok(SyslogMessage {
176 priority: Priority {
177 facility: self.facility,
178 severity: self.severity,
179 },
180 timestamp: self.timestamp,
181 hostname: self.hostname,
182 app_name: self.app_name,
183 proc_id: self.proc_id,
184 msg_id: self.msg_id,
185 structured_data: "-".to_owned(),
186 msg,
187 })
188 }
189
190 pub async fn send<T: Transport>(self, transport: &T) -> Result<(), ClientError> {
197 let msg = self.build()?;
198 transport.send(&msg).await
199 }
200}
201
202#[must_use]
210pub fn now_rfc3339() -> String {
211 use std::time::{SystemTime, UNIX_EPOCH};
212 let secs = SystemTime::now()
213 .duration_since(UNIX_EPOCH)
214 .unwrap_or_default()
215 .as_secs();
216 epoch_secs_to_rfc3339(secs)
217}
218
219fn epoch_secs_to_rfc3339(secs: u64) -> String {
220 let days = secs / 86_400;
221 let rem = secs % 86_400;
222 let h = rem / 3_600;
223 let m = (rem % 3_600) / 60;
224 let s = rem % 60;
225 let (year, month, day) = civil_from_days(days);
226 format!("{year:04}-{month:02}-{day:02}T{h:02}:{m:02}:{s:02}Z")
227}
228
229#[allow(
236 clippy::cast_sign_loss,
237 clippy::cast_possible_truncation,
238 clippy::cast_possible_wrap,
239 reason = "intermediate values are mathematically bounded by the Unix epoch range; \
240 results fit in i32/u32 for any date within ±millions of years"
241)]
242fn civil_from_days(days: u64) -> (i32, u32, u32) {
243 let z: i64 = days as i64 + 719_468;
244 let era: i64 = if z >= 0 { z } else { z - 146_096 } / 146_097;
245 let doe: u64 = (z - era * 146_097) as u64;
246 let yoe: u64 = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
247 let y: i64 = yoe as i64 + era * 400;
248 let doy: u64 = doe - (365 * yoe + yoe / 4 - yoe / 100);
249 let mp: u64 = (5 * doy + 2) / 153;
250 let d: u64 = doy - (153 * mp + 2) / 5 + 1;
251 let m: u64 = if mp < 10 { mp + 3 } else { mp - 9 };
252 let y = if m <= 2 { y + 1 } else { y };
253 (y as i32, m as u32, d as u32)
254}
255
256#[cfg(test)]
259#[allow(
260 clippy::unwrap_used,
261 reason = "unwrap is appropriate in test assertions"
262)]
263mod tests {
264 use logfence_proto::syslog::{Facility, Severity};
265
266 use super::*;
267
268 #[test]
271 fn build_defaults() {
272 let msg = MessageBuilder::new(Facility::Local0, Severity::Info)
273 .build()
274 .unwrap();
275
276 assert_eq!(msg.priority.as_integer(), 134); assert!(msg.timestamp.is_none());
278 assert!(msg.hostname.is_none());
279 assert!(msg.app_name.is_none());
280 assert!(msg.proc_id.is_some());
282 assert!(msg.msg_id.is_none());
283 assert_eq!(msg.structured_data, "-");
284 assert_eq!(msg.msg, "{}"); }
286
287 #[test]
288 fn build_with_all_header_fields() {
289 let msg = MessageBuilder::new(Facility::Daemon, Severity::Warning)
290 .timestamp("2026-05-14T12:00:00Z")
291 .hostname("myhost")
292 .app_name("myapp")
293 .proc_id("9999")
294 .msgid("AUDIT")
295 .build()
296 .unwrap();
297
298 assert_eq!(msg.timestamp.as_deref(), Some("2026-05-14T12:00:00Z"));
299 assert_eq!(msg.hostname.as_deref(), Some("myhost"));
300 assert_eq!(msg.app_name.as_deref(), Some("myapp"));
301 assert_eq!(msg.proc_id.as_deref(), Some("9999"));
302 assert_eq!(msg.msg_id.as_deref(), Some("AUDIT"));
303 }
304
305 #[test]
306 fn build_kv_string() {
307 let msg = MessageBuilder::new(Facility::Local0, Severity::Info)
308 .kv("action", "login")
309 .unwrap()
310 .build()
311 .unwrap();
312
313 let v: serde_json::Value = serde_json::from_str(&msg.msg).unwrap();
314 assert_eq!(v["action"], "login");
315 }
316
317 #[test]
318 fn build_kv_integer() {
319 let msg = MessageBuilder::new(Facility::Local0, Severity::Info)
320 .kv("user_id", 42_u32)
321 .unwrap()
322 .build()
323 .unwrap();
324
325 let v: serde_json::Value = serde_json::from_str(&msg.msg).unwrap();
326 assert_eq!(v["user_id"], 42);
327 }
328
329 #[test]
330 fn build_kv_bool() {
331 let msg = MessageBuilder::new(Facility::Local0, Severity::Info)
332 .kv("success", true)
333 .unwrap()
334 .build()
335 .unwrap();
336
337 let v: serde_json::Value = serde_json::from_str(&msg.msg).unwrap();
338 assert_eq!(v["success"], true);
339 }
340
341 #[test]
342 fn build_kv_nested_object() {
343 let nested = serde_json::json!({"code": 200, "reason": "OK"});
344 let msg = MessageBuilder::new(Facility::Local0, Severity::Info)
345 .kv("response", nested)
346 .unwrap()
347 .build()
348 .unwrap();
349
350 let v: serde_json::Value = serde_json::from_str(&msg.msg).unwrap();
351 assert_eq!(v["response"]["code"], 200);
352 }
353
354 #[test]
355 fn build_kv_multiple_fields_sorted() {
356 let msg = MessageBuilder::new(Facility::Local0, Severity::Info)
357 .kv("z_last", 3_u32)
358 .unwrap()
359 .kv("a_first", 1_u32)
360 .unwrap()
361 .kv("m_middle", 2_u32)
362 .unwrap()
363 .build()
364 .unwrap();
365
366 assert_eq!(msg.msg, r#"{"a_first":1,"m_middle":2,"z_last":3}"#);
368 }
369
370 #[test]
371 fn build_kv_duplicate_key_last_wins() {
372 let msg = MessageBuilder::new(Facility::Local0, Severity::Info)
373 .kv("key", "first")
374 .unwrap()
375 .kv("key", "second")
376 .unwrap()
377 .build()
378 .unwrap();
379
380 let v: serde_json::Value = serde_json::from_str(&msg.msg).unwrap();
381 assert_eq!(v["key"], "second");
382 }
383
384 #[test]
385 fn build_kv_empty_key_rejected() {
386 let err = MessageBuilder::new(Facility::Local0, Severity::Info)
387 .kv("", "value")
388 .unwrap_err();
389 assert!(matches!(err, BuildError::EmptyKey));
390 }
391
392 #[test]
393 fn build_produces_valid_syslog_message() {
394 let msg = MessageBuilder::new(Facility::Local7, Severity::Debug)
396 .app_name("roundtrip")
397 .msgid("TEST")
398 .kv("hello", "world")
399 .unwrap()
400 .build()
401 .unwrap();
402
403 let wire = msg.to_string();
404 let parsed = SyslogMessage::parse(&wire).unwrap();
405 assert_eq!(parsed, msg);
406 }
407
408 #[test]
411 fn now_rfc3339_format() {
412 let ts = now_rfc3339();
413 assert_eq!(ts.len(), 20);
415 assert_eq!(&ts[4..5], "-");
416 assert_eq!(&ts[7..8], "-");
417 assert_eq!(&ts[10..11], "T");
418 assert_eq!(&ts[13..14], ":");
419 assert_eq!(&ts[16..17], ":");
420 assert_eq!(&ts[19..20], "Z");
421 }
422
423 #[test]
426 fn build_cee_cookie_prefixes_json() {
427 let msg = MessageBuilder::new(Facility::Local0, Severity::Info)
428 .cee_cookie(true)
429 .kv("event", "login")
430 .unwrap()
431 .build()
432 .unwrap();
433
434 assert!(
435 msg.msg.starts_with("@cee:"),
436 "expected @cee: prefix, got: {}",
437 msg.msg
438 );
439 let json_part = &msg.msg["@cee:".len()..];
440 let v: serde_json::Value = serde_json::from_str(json_part).unwrap();
441 assert_eq!(v["event"], "login");
442 }
443
444 #[test]
445 fn build_without_cee_cookie_has_no_prefix() {
446 let msg = MessageBuilder::new(Facility::Local0, Severity::Info)
447 .kv("event", "login")
448 .unwrap()
449 .build()
450 .unwrap();
451
452 assert!(
453 !msg.msg.starts_with("@cee:"),
454 "expected no @cee: prefix, got: {}",
455 msg.msg
456 );
457 }
458
459 #[test]
460 fn build_cee_cookie_empty_fields() {
461 let msg = MessageBuilder::new(Facility::Local0, Severity::Info)
462 .cee_cookie(true)
463 .build()
464 .unwrap();
465
466 assert_eq!(msg.msg, "@cee:{}");
467 }
468
469 #[test]
470 fn build_cee_cookie_multiple_fields_sorted() {
471 let msg = MessageBuilder::new(Facility::Local0, Severity::Info)
472 .cee_cookie(true)
473 .kv("z", 3_u32)
474 .unwrap()
475 .kv("a", 1_u32)
476 .unwrap()
477 .build()
478 .unwrap();
479
480 assert_eq!(msg.msg, r#"@cee:{"a":1,"z":3}"#);
481 }
482
483 #[test]
484 fn epoch_secs_known_dates() {
485 assert_eq!(epoch_secs_to_rfc3339(0), "1970-01-01T00:00:00Z");
487 assert_eq!(epoch_secs_to_rfc3339(946_684_800), "2000-01-01T00:00:00Z");
489 assert_eq!(epoch_secs_to_rfc3339(951_782_400), "2000-02-29T00:00:00Z");
491 assert_eq!(epoch_secs_to_rfc3339(1_778_716_800), "2026-05-14T00:00:00Z");
493 assert_eq!(
495 epoch_secs_to_rfc3339(1_778_716_800 + 13 * 3_600 + 45 * 60 + 30),
496 "2026-05-14T13:45:30Z"
497 );
498 }
499}