1use std::collections::HashMap;
11use std::io::{BufRead, BufReader};
12use std::path::Path;
13
14use crate::error::{IoError, Result};
15
16#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
22pub enum LogLevel {
23 Trace,
25 Debug,
27 Info,
29 Notice,
31 Warning,
33 Error,
35 Critical,
37 Alert,
39 Emergency,
41 Unknown(String),
43}
44
45impl LogLevel {
46 fn from_str(s: &str) -> Self {
47 match s.to_uppercase().as_str() {
48 "TRACE" | "TRC" => LogLevel::Trace,
49 "DEBUG" | "DBG" | "7" => LogLevel::Debug,
50 "INFO" | "INFORMATION" | "6" => LogLevel::Info,
51 "NOTICE" | "5" => LogLevel::Notice,
52 "WARN" | "WARNING" | "4" => LogLevel::Warning,
53 "ERROR" | "ERR" | "3" => LogLevel::Error,
54 "CRIT" | "CRITICAL" | "2" => LogLevel::Critical,
55 "ALERT" | "1" => LogLevel::Alert,
56 "EMERG" | "EMERGENCY" | "0" => LogLevel::Emergency,
57 other => LogLevel::Unknown(other.to_string()),
58 }
59 }
60
61 pub fn as_str(&self) -> &str {
63 match self {
64 LogLevel::Trace => "TRACE",
65 LogLevel::Debug => "DEBUG",
66 LogLevel::Info => "INFO",
67 LogLevel::Notice => "NOTICE",
68 LogLevel::Warning => "WARNING",
69 LogLevel::Error => "ERROR",
70 LogLevel::Critical => "CRITICAL",
71 LogLevel::Alert => "ALERT",
72 LogLevel::Emergency => "EMERGENCY",
73 LogLevel::Unknown(s) => s.as_str(),
74 }
75 }
76}
77
78#[derive(Debug, Clone)]
80pub struct LogEntry {
81 pub timestamp_ms: i64,
83 pub level: LogLevel,
85 pub message: String,
87 pub host: Option<String>,
89 pub app: Option<String>,
91 pub pid: Option<u32>,
93 pub fields: HashMap<String, String>,
95 pub raw: String,
97}
98
99impl LogEntry {
100 fn new_raw(raw: impl Into<String>) -> Self {
101 LogEntry {
102 timestamp_ms: 0,
103 level: LogLevel::Unknown(String::new()),
104 message: String::new(),
105 host: None,
106 app: None,
107 pid: None,
108 fields: HashMap::new(),
109 raw: raw.into(),
110 }
111 }
112}
113
114pub fn parse_timestamp(s: &str) -> Result<i64> {
129 let trimmed = s.trim();
130
131 if let Ok(n) = trimmed.parse::<i64>() {
133 return if n.abs() > 9_999_999_999 {
135 Ok(n)
136 } else {
137 Ok(n * 1000)
138 };
139 }
140
141 if let Ok(f) = trimmed.parse::<f64>() {
143 return Ok((f * 1000.0) as i64);
144 }
145
146 if let Some(ms) = parse_iso8601(trimmed) {
148 return Ok(ms);
149 }
150
151 if let Some(ms) = parse_clf_timestamp(trimmed) {
153 return Ok(ms);
154 }
155
156 if let Some(ms) = parse_syslog_timestamp(trimmed) {
158 return Ok(ms);
159 }
160
161 Err(IoError::ParseError(format!(
162 "unrecognised timestamp format: '{}'",
163 s
164 )))
165}
166
167fn parse_iso8601(s: &str) -> Option<i64> {
169 if s.len() < 19 {
171 return None;
172 }
173 let year: i64 = s[0..4].parse().ok()?;
174 let month: i64 = s[5..7].parse().ok()?;
175 let day: i64 = s[8..10].parse().ok()?;
176 if s.as_bytes().get(10) != Some(&b'T') && s.as_bytes().get(10) != Some(&b' ') {
177 return None;
178 }
179 let hour: i64 = s[11..13].parse().ok()?;
180 let min: i64 = s[14..16].parse().ok()?;
181 let sec: i64 = s[17..19].parse().ok()?;
182
183 let mut frac_ms: i64 = 0;
185 let mut rest = &s[19..];
186 if rest.starts_with('.') {
187 let end = rest[1..]
188 .find(|c: char| !c.is_ascii_digit())
189 .unwrap_or(rest.len() - 1);
190 let frac_str = &rest[1..end + 1];
191 frac_ms = match frac_str.len() {
193 0 => 0,
194 1 => frac_str.parse::<i64>().unwrap_or(0) * 100,
195 2 => frac_str.parse::<i64>().unwrap_or(0) * 10,
196 _ => frac_str[..3].parse::<i64>().unwrap_or(0),
197 };
198 rest = &rest[end + 1..];
199 }
200
201 let _tz = rest; let days_since_epoch = days_from_ymd(year, month, day)?;
205 let secs = days_since_epoch * 86400 + hour * 3600 + min * 60 + sec;
206 Some(secs * 1000 + frac_ms)
207}
208
209fn days_from_ymd(year: i64, month: i64, day: i64) -> Option<i64> {
210 if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
211 return None;
212 }
213 let m = if month <= 2 { month + 12 } else { month };
215 let y = if month <= 2 { year - 1 } else { year };
216 let jdn = day + (153 * m - 457) / 5 + 365 * y + y / 4 - y / 100 + y / 400 + 1721119;
217 let unix_epoch_jdn: i64 = 2440588; Some(jdn - unix_epoch_jdn)
219}
220
221const MONTHS: &[&str] = &[
222 "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
223];
224
225fn month_from_abbr(s: &str) -> Option<i64> {
226 MONTHS
227 .iter()
228 .position(|m| m.eq_ignore_ascii_case(s))
229 .map(|i| i as i64 + 1)
230}
231
232fn parse_clf_timestamp(s: &str) -> Option<i64> {
234 let parts: Vec<&str> = s.splitn(4, '/').collect();
236 if parts.len() < 3 {
237 return None;
238 }
239 let day: i64 = parts[0].parse().ok()?;
240 let month = month_from_abbr(parts[1])?;
241 let rest = parts[2]; let colon = rest.find(':')?;
243 let year: i64 = rest[..colon].parse().ok()?;
244 let time = &rest[colon + 1..]; let time_parts: Vec<&str> = time.splitn(4, ':').collect();
246 if time_parts.len() < 3 {
247 return None;
248 }
249 let hour: i64 = time_parts[0].parse().ok()?;
250 let min: i64 = time_parts[1].parse().ok()?;
251 let sec_str = time_parts[2].split_whitespace().next()?;
252 let sec: i64 = sec_str.parse().ok()?;
253
254 let days = days_from_ymd(year, month, day)?;
255 Some((days * 86400 + hour * 3600 + min * 60 + sec) * 1000)
256}
257
258fn parse_syslog_timestamp(s: &str) -> Option<i64> {
260 let parts: Vec<&str> = s.split_whitespace().collect();
261 if parts.len() < 3 {
262 return None;
263 }
264 let month = month_from_abbr(parts[0])?;
265 let day: i64 = parts[1].parse().ok()?;
266 let time_parts: Vec<&str> = parts[2].split(':').collect();
267 if time_parts.len() < 3 {
268 return None;
269 }
270 let hour: i64 = time_parts[0].parse().ok()?;
271 let min: i64 = time_parts[1].parse().ok()?;
272 let sec: i64 = time_parts[2].parse().ok()?;
273
274 let days = days_from_ymd(1970, month, day)?;
275 Some((days * 86400 + hour * 3600 + min * 60 + sec) * 1000)
276}
277
278#[derive(Debug, Default)]
291pub struct CommonLogParser;
292
293impl CommonLogParser {
294 pub fn new() -> Self {
296 CommonLogParser
297 }
298
299 pub fn parse(&self, line: &str) -> Result<LogEntry> {
301 let mut entry = LogEntry::new_raw(line);
302 entry.level = LogLevel::Info;
303
304 let mut rest = line.trim();
305
306 let (remote_host, r) = split_field(rest);
308 entry
309 .fields
310 .insert("remote_host".into(), remote_host.into());
311 rest = r.trim_start();
312
313 let (ident, r) = split_field(rest);
315 if ident != "-" {
316 entry.fields.insert("ident".into(), ident.into());
317 }
318 rest = r.trim_start();
319
320 let (auth_user, r) = split_field(rest);
322 if auth_user != "-" {
323 entry.fields.insert("auth_user".into(), auth_user.into());
324 }
325 rest = r.trim_start();
326
327 if rest.starts_with('[') {
329 if let Some(end) = rest.find(']') {
330 let ts_str = &rest[1..end];
331 entry.timestamp_ms = parse_timestamp(ts_str).unwrap_or(0);
332 rest = rest[end + 1..].trim_start();
333 }
334 }
335
336 if rest.starts_with('"') {
338 let (req, r) = extract_quoted(rest);
339 entry.message = req.to_string();
340 let parts: Vec<&str> = req.splitn(3, ' ').collect();
341 if parts.len() >= 2 {
342 entry.fields.insert("method".into(), parts[0].into());
343 entry.fields.insert("path".into(), parts[1].into());
344 }
345 if parts.len() == 3 {
346 entry.fields.insert("protocol".into(), parts[2].into());
347 }
348 rest = r.trim_start();
349 }
350
351 let (status, r) = split_field(rest);
353 entry.fields.insert("status".into(), status.into());
354 if let Ok(code) = status.parse::<u16>() {
355 if code >= 400 {
356 entry.level = LogLevel::Warning;
357 }
358 if code >= 500 {
359 entry.level = LogLevel::Error;
360 }
361 }
362 rest = r.trim_start();
363
364 let (bytes, r) = split_field(rest);
366 if bytes != "-" {
367 entry.fields.insert("bytes".into(), bytes.into());
368 }
369 rest = r.trim_start();
370
371 if rest.starts_with('"') {
373 let (referer, r) = extract_quoted(rest);
374 if referer != "-" {
375 entry.fields.insert("referer".into(), referer.to_string());
376 }
377 rest = r.trim_start();
378 }
379
380 if rest.starts_with('"') {
382 let (ua, _) = extract_quoted(rest);
383 if ua != "-" {
384 entry.fields.insert("user_agent".into(), ua.to_string());
385 }
386 }
387
388 Ok(entry)
389 }
390
391 pub fn parse_reader<R: BufRead>(&self, reader: R) -> Vec<Result<LogEntry>> {
393 reader
394 .lines()
395 .filter_map(|lr| match lr {
396 Err(e) => Some(Err(IoError::Io(e))),
397 Ok(line) => {
398 let trimmed = line.trim().to_string();
399 if trimmed.is_empty() {
400 None
401 } else {
402 Some(self.parse(&trimmed))
403 }
404 }
405 })
406 .collect()
407 }
408}
409
410#[derive(Debug, Clone)]
416pub struct JsonLogFieldMap {
417 pub timestamp: String,
419 pub level: String,
421 pub message: String,
423 pub host: String,
425}
426
427impl Default for JsonLogFieldMap {
428 fn default() -> Self {
429 JsonLogFieldMap {
430 timestamp: "timestamp".into(),
431 level: "level".into(),
432 message: "message".into(),
433 host: "host".into(),
434 }
435 }
436}
437
438#[derive(Debug, Default)]
440pub struct JsonLogParser {
441 pub field_map: JsonLogFieldMap,
443}
444
445impl JsonLogParser {
446 pub fn new() -> Self {
448 JsonLogParser::default()
449 }
450
451 pub fn with_field_map(field_map: JsonLogFieldMap) -> Self {
453 JsonLogParser { field_map }
454 }
455
456 pub fn parse(&self, line: &str) -> Result<LogEntry> {
458 let mut entry = LogEntry::new_raw(line);
459
460 let inner = line
462 .trim()
463 .strip_prefix('{')
464 .and_then(|s| s.strip_suffix('}'))
465 .ok_or_else(|| IoError::ParseError("JSON log: not a JSON object".into()))?;
466
467 let fields = parse_json_flat_fields(inner);
468
469 if let Some(ts_str) = fields.get(&self.field_map.timestamp) {
471 entry.timestamp_ms = parse_timestamp(ts_str).unwrap_or(0);
472 }
473 if let Some(lv) = fields.get(&self.field_map.level) {
474 entry.level = LogLevel::from_str(lv);
475 }
476 if let Some(msg) = fields.get(&self.field_map.message) {
477 entry.message = msg.clone();
478 }
479 if let Some(host) = fields.get(&self.field_map.host) {
480 entry.host = Some(host.clone());
481 }
482
483 for (k, v) in &fields {
485 entry.fields.insert(k.clone(), v.clone());
486 }
487
488 Ok(entry)
489 }
490
491 pub fn parse_reader<R: BufRead>(&self, reader: R) -> Vec<Result<LogEntry>> {
493 reader
494 .lines()
495 .filter_map(|lr| match lr {
496 Err(e) => Some(Err(IoError::Io(e))),
497 Ok(line) => {
498 let trimmed = line.trim().to_string();
499 if trimmed.is_empty() {
500 None
501 } else {
502 Some(self.parse(&trimmed))
503 }
504 }
505 })
506 .collect()
507 }
508}
509
510fn parse_json_flat_fields(inner: &str) -> HashMap<String, String> {
512 let mut map = HashMap::new();
513 let mut rest = inner.trim();
514
515 while !rest.is_empty() {
516 rest = rest.trim_start_matches(',').trim();
517 if rest.is_empty() {
518 break;
519 }
520 let (key, after_key) = match parse_json_string_simple(rest) {
522 Some(v) => v,
523 None => break,
524 };
525 rest = after_key.trim_start();
526 if !rest.starts_with(':') {
527 break;
528 }
529 rest = rest[1..].trim_start();
530
531 let (val_str, after_val) = extract_json_value_str(rest);
533 map.insert(key, val_str);
534 rest = after_val.trim_start();
535 }
536 map
537}
538
539fn parse_json_string_simple(s: &str) -> Option<(String, &str)> {
541 if !s.starts_with('"') {
542 return None;
543 }
544 let mut result = String::new();
545 let mut chars = s[1..].char_indices();
546 loop {
547 match chars.next() {
548 None => return None,
549 Some((i, '"')) => return Some((result, &s[i + 2..])),
550 Some((_, '\\')) => match chars.next() {
551 Some((_, 'n')) => result.push('\n'),
552 Some((_, 'r')) => result.push('\r'),
553 Some((_, 't')) => result.push('\t'),
554 Some((_, '"')) => result.push('"'),
555 Some((_, '\\')) => result.push('\\'),
556 Some((_, c)) => result.push(c),
557 None => return None,
558 },
559 Some((_, c)) => result.push(c),
560 }
561 }
562}
563
564fn extract_json_value_str(s: &str) -> (String, &str) {
566 if s.starts_with('"') {
567 if let Some((val, rest)) = parse_json_string_simple(s) {
568 return (val, rest);
569 }
570 }
571 if s.starts_with('{') || s.starts_with('[') {
572 let open = s.as_bytes()[0];
574 let close = if open == b'{' { b'}' } else { b']' };
575 let mut depth = 0usize;
576 let mut in_str = false;
577 let mut escape = false;
578 for (i, &b) in s.as_bytes().iter().enumerate() {
579 if escape {
580 escape = false;
581 continue;
582 }
583 if b == b'\\' && in_str {
584 escape = true;
585 continue;
586 }
587 if b == b'"' {
588 in_str = !in_str;
589 continue;
590 }
591 if in_str {
592 continue;
593 }
594 if b == open {
595 depth += 1;
596 } else if b == close {
597 depth -= 1;
598 if depth == 0 {
599 return (s[..=i].to_string(), &s[i + 1..]);
600 }
601 }
602 }
603 }
604 let end = s.find([',', '}', ']']).unwrap_or(s.len());
606 (s[..end].trim().to_string(), &s[end..])
607}
608
609#[derive(Debug, Default)]
621pub struct SyslogParser;
622
623impl SyslogParser {
624 pub fn new() -> Self {
626 SyslogParser
627 }
628
629 pub fn parse(&self, line: &str) -> Result<LogEntry> {
631 let mut entry = LogEntry::new_raw(line);
632 let rest = line.trim();
633
634 if rest.starts_with('<') {
635 if let Some(close) = rest.find('>') {
637 let pri_str = &rest[1..close];
638 if let Ok(pri) = pri_str.parse::<u8>() {
639 let severity = pri & 0x07;
640 entry.level = LogLevel::from_str(&severity.to_string());
641 let _facility = pri >> 3;
642 entry
643 .fields
644 .insert("facility".into(), (_facility).to_string());
645 }
646 let after_pri = &rest[close + 1..];
647 let payload = if after_pri.starts_with(|c: char| c.is_ascii_digit()) {
649 after_pri[1..].trim_start()
651 } else {
652 after_pri.trim_start()
653 };
654 self.parse_syslog_payload(payload, &mut entry);
655 }
656 } else {
657 self.parse_syslog_payload(rest, &mut entry);
659 }
660
661 Ok(entry)
662 }
663
664 fn parse_syslog_payload(&self, payload: &str, entry: &mut LogEntry) {
665 let mut parts = payload.splitn(7, ' ');
666
667 if let Some(ts_str) = parts.next() {
669 if ts_str != "-" {
670 entry.timestamp_ms = parse_timestamp(ts_str).unwrap_or(0);
671 }
672 }
673
674 if let Some(host) = parts.next() {
676 if host != "-" {
677 entry.host = Some(host.to_string());
678 }
679 }
680
681 if let Some(app) = parts.next() {
683 if app != "-" {
684 entry.app = Some(app.to_string());
685 }
686 }
687
688 if let Some(pid_str) = parts.next() {
690 if pid_str != "-" {
691 entry.pid = pid_str.parse::<u32>().ok();
692 }
693 }
694
695 if let Some(msgid) = parts.next() {
697 if msgid != "-" {
698 entry.fields.insert("msgid".into(), msgid.to_string());
699 }
700 }
701
702 parts.next();
704
705 if let Some(msg) = parts.next() {
707 entry.message = msg.trim_start_matches('\u{FEFF}').to_string();
709 }
710 }
711
712 pub fn parse_reader<R: BufRead>(&self, reader: R) -> Vec<Result<LogEntry>> {
714 reader
715 .lines()
716 .filter_map(|lr| match lr {
717 Err(e) => Some(Err(IoError::Io(e))),
718 Ok(line) => {
719 let trimmed = line.trim().to_string();
720 if trimmed.is_empty() {
721 None
722 } else {
723 Some(self.parse(&trimmed))
724 }
725 }
726 })
727 .collect()
728 }
729}
730
731#[derive(Debug, Clone, Default)]
737pub struct AggregationConfig {
738 pub min_level: Option<LogLevel>,
740 pub message_contains: Option<String>,
742 pub host_filter: Option<String>,
744 pub bucket_secs: u64,
746}
747
748#[derive(Debug, Default, Clone)]
750pub struct LogSummary {
751 pub total: usize,
753 pub by_level: HashMap<String, usize>,
755 pub by_host: HashMap<String, usize>,
757 pub by_bucket: HashMap<i64, usize>,
759 pub earliest_ms: i64,
761 pub latest_ms: i64,
763 pub retained: Vec<LogEntry>,
765}
766
767pub struct LogAggregator {
769 config: AggregationConfig,
770}
771
772impl LogAggregator {
773 pub fn new(config: AggregationConfig) -> Self {
775 LogAggregator { config }
776 }
777
778 pub fn aggregate(&self, entries: &[LogEntry]) -> LogSummary {
780 let mut summary = LogSummary {
781 earliest_ms: i64::MAX,
782 latest_ms: i64::MIN,
783 ..Default::default()
784 };
785
786 for entry in entries {
787 if let Some(ref min_level) = self.config.min_level {
789 if &entry.level < min_level {
790 continue;
791 }
792 }
793
794 if let Some(ref substr) = self.config.message_contains {
796 if !entry.message.contains(substr.as_str()) {
797 continue;
798 }
799 }
800
801 if let Some(ref host_filter) = self.config.host_filter {
803 if entry.host.as_deref() != Some(host_filter.as_str()) {
804 continue;
805 }
806 }
807
808 summary.total += 1;
810
811 *summary
812 .by_level
813 .entry(entry.level.as_str().to_string())
814 .or_insert(0) += 1;
815
816 if let Some(ref host) = entry.host {
817 *summary.by_host.entry(host.clone()).or_insert(0) += 1;
818 }
819
820 if entry.timestamp_ms > 0 {
821 if entry.timestamp_ms < summary.earliest_ms {
822 summary.earliest_ms = entry.timestamp_ms;
823 }
824 if entry.timestamp_ms > summary.latest_ms {
825 summary.latest_ms = entry.timestamp_ms;
826 }
827
828 if self.config.bucket_secs > 0 {
829 let bucket_ms = (entry.timestamp_ms / 1000 / self.config.bucket_secs as i64)
830 * self.config.bucket_secs as i64
831 * 1000;
832 *summary.by_bucket.entry(bucket_ms).or_insert(0) += 1;
833 }
834 }
835
836 summary.retained.push(entry.clone());
837 }
838
839 if summary.earliest_ms == i64::MAX {
840 summary.earliest_ms = 0;
841 }
842 if summary.latest_ms == i64::MIN {
843 summary.latest_ms = 0;
844 }
845
846 summary
847 }
848
849 pub fn aggregate_clf_file<P: AsRef<Path>>(&self, path: P) -> Result<LogSummary> {
851 let file = std::fs::File::open(path.as_ref()).map_err(IoError::Io)?;
852 let reader = BufReader::new(file);
853 let parser = CommonLogParser::new();
854 let entries: Vec<LogEntry> = parser
855 .parse_reader(reader)
856 .into_iter()
857 .filter_map(|r| r.ok())
858 .collect();
859 Ok(self.aggregate(&entries))
860 }
861
862 pub fn aggregate_json_file<P: AsRef<Path>>(&self, path: P) -> Result<LogSummary> {
864 let file = std::fs::File::open(path.as_ref()).map_err(IoError::Io)?;
865 let reader = BufReader::new(file);
866 let parser = JsonLogParser::new();
867 let entries: Vec<LogEntry> = parser
868 .parse_reader(reader)
869 .into_iter()
870 .filter_map(|r| r.ok())
871 .collect();
872 Ok(self.aggregate(&entries))
873 }
874}
875
876fn split_field(s: &str) -> (&str, &str) {
882 if let Some(pos) = s.find(char::is_whitespace) {
883 (&s[..pos], &s[pos..])
884 } else {
885 (s, "")
886 }
887}
888
889fn extract_quoted(s: &str) -> (&str, &str) {
891 if !s.starts_with('"') {
892 return split_field(s);
893 }
894 let inner = &s[1..];
896 let mut prev_backslash = false;
897 for (i, c) in inner.char_indices() {
898 if prev_backslash {
899 prev_backslash = false;
900 continue;
901 }
902 if c == '\\' {
903 prev_backslash = true;
904 continue;
905 }
906 if c == '"' {
907 return (&inner[..i], &inner[i + 1..]);
908 }
909 }
910 (inner, "")
911}
912
913#[cfg(test)]
918mod tests {
919 use super::*;
920
921 #[test]
922 fn test_parse_iso8601_timestamp() {
923 let ms = parse_timestamp("2024-03-23T14:22:47Z").expect("parse ISO 8601");
924 assert!(ms > 0);
925 }
926
927 #[test]
928 fn test_parse_unix_millis_timestamp() {
929 let ms = parse_timestamp("1711234567890").expect("parse unix millis");
930 assert_eq!(ms, 1711234567890);
931 }
932
933 #[test]
934 fn test_parse_unix_secs_timestamp() {
935 let ms = parse_timestamp("1711234567").expect("parse unix secs");
936 assert_eq!(ms, 1711234567000);
937 }
938
939 #[test]
940 fn test_clf_parser() {
941 let line =
942 r#"127.0.0.1 - frank [23/Mar/2024:14:22:47 +0000] "GET /index.html HTTP/1.1" 200 1234"#;
943 let parser = CommonLogParser::new();
944 let entry = parser.parse(line).expect("parse CLF line");
945 assert_eq!(entry.fields.get("method").map(|s| s.as_str()), Some("GET"));
946 assert_eq!(entry.fields.get("status").map(|s| s.as_str()), Some("200"));
947 }
948
949 #[test]
950 fn test_clf_parser_500_level() {
951 let line = r#"10.0.0.1 - - [23/Mar/2024:14:22:47 +0000] "POST /api HTTP/1.1" 500 0"#;
952 let parser = CommonLogParser::new();
953 let entry = parser.parse(line).expect("parse error CLF line");
954 assert_eq!(entry.level, LogLevel::Error);
955 }
956
957 #[test]
958 fn test_json_log_parser() {
959 let line = r#"{"timestamp":"2024-03-23T14:22:47Z","level":"ERROR","message":"disk full","host":"srv01"}"#;
960 let parser = JsonLogParser::new();
961 let entry = parser.parse(line).expect("parse JSON log");
962 assert_eq!(entry.level, LogLevel::Error);
963 assert_eq!(entry.message, "disk full");
964 assert_eq!(entry.host.as_deref(), Some("srv01"));
965 }
966
967 #[test]
968 fn test_syslog_parser_rfc5424() {
969 let line = "<34>1 2024-03-23T14:22:47Z myhost myapp 1234 ID47 - An application event";
970 let parser = SyslogParser::new();
971 let entry = parser.parse(line).expect("parse syslog");
972 assert_eq!(entry.host.as_deref(), Some("myhost"));
973 assert_eq!(entry.app.as_deref(), Some("myapp"));
974 assert_eq!(entry.pid, Some(1234));
975 assert_eq!(entry.message, "An application event");
976 }
977
978 #[test]
979 fn test_log_aggregator_level_filter() {
980 let entries = vec![
981 {
982 let mut e = LogEntry::new_raw("a");
983 e.level = LogLevel::Debug;
984 e.timestamp_ms = 1000;
985 e
986 },
987 {
988 let mut e = LogEntry::new_raw("b");
989 e.level = LogLevel::Error;
990 e.timestamp_ms = 2000;
991 e
992 },
993 ];
994 let config = AggregationConfig {
995 min_level: Some(LogLevel::Warning),
996 ..Default::default()
997 };
998 let agg = LogAggregator::new(config);
999 let summary = agg.aggregate(&entries);
1000 assert_eq!(summary.total, 1);
1001 assert_eq!(summary.retained[0].level, LogLevel::Error);
1002 }
1003
1004 #[test]
1005 fn test_log_aggregator_bucket() {
1006 let config = AggregationConfig {
1007 bucket_secs: 60,
1008 ..Default::default()
1009 };
1010 let agg = LogAggregator::new(config);
1011 let entries: Vec<LogEntry> = (0..5)
1012 .map(|i| {
1013 let mut e = LogEntry::new_raw(format!("msg {i}"));
1014 e.level = LogLevel::Info;
1015 e.timestamp_ms = 1_700_000_000_000 + i * 1000;
1016 e
1017 })
1018 .collect();
1019 let summary = agg.aggregate(&entries);
1020 assert_eq!(summary.total, 5);
1021 assert_eq!(summary.by_bucket.values().sum::<usize>(), 5);
1023 }
1024}