Skip to main content

scirs2_io/streaming/
log_parsing.rs

1//! Structured log file parsing
2//!
3//! Provides parsers for common log formats:
4//! - Apache/Nginx combined/common log format
5//! - Structured JSON log lines
6//! - RFC 5424 syslog format
7//!
8//! Plus a `LogAggregator` for filtering, bucketing, and summarising log streams.
9
10use std::collections::HashMap;
11use std::io::{BufRead, BufReader};
12use std::path::Path;
13
14use crate::error::{IoError, Result};
15
16// ──────────────────────────────────────────────────────────────────────────────
17// Core types
18// ──────────────────────────────────────────────────────────────────────────────
19
20/// Severity level for a log entry.
21#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
22pub enum LogLevel {
23    /// Trace-level (most verbose) messages.
24    Trace,
25    /// Debug-level messages.
26    Debug,
27    /// Informational messages.
28    Info,
29    /// Notice-level messages (slightly more severe than Info).
30    Notice,
31    /// Warning conditions.
32    Warning,
33    /// Error conditions.
34    Error,
35    /// Critical conditions.
36    Critical,
37    /// Action must be taken immediately.
38    Alert,
39    /// System is unusable.
40    Emergency,
41    /// Unrecognised severity string.
42    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    /// Display as a canonical uppercase string.
62    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/// A structured log entry.
79#[derive(Debug, Clone)]
80pub struct LogEntry {
81    /// Parsed timestamp, Unix millis (0 if not available).
82    pub timestamp_ms: i64,
83    /// Severity level.
84    pub level: LogLevel,
85    /// Primary log message.
86    pub message: String,
87    /// Optional hostname / source identifier.
88    pub host: Option<String>,
89    /// Optional process/application identifier.
90    pub app: Option<String>,
91    /// Optional process ID.
92    pub pid: Option<u32>,
93    /// Optional structured fields extracted from the entry.
94    pub fields: HashMap<String, String>,
95    /// The raw log line (before parsing).
96    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
114// ──────────────────────────────────────────────────────────────────────────────
115// Timestamp parsing
116// ──────────────────────────────────────────────────────────────────────────────
117
118/// Parse a timestamp string in multiple common formats.
119///
120/// Supported formats (examples):
121/// - Unix millis: `"1711234567890"`
122/// - Unix secs (decimal): `"1711234567.123"`
123/// - ISO 8601 / RFC 3339: `"2024-03-23T14:22:47Z"`, `"2024-03-23T14:22:47.123+05:30"`
124/// - Apache CLF: `"23/Mar/2024:14:22:47 +0000"`
125/// - Syslog: `"Mar 23 14:22:47"`
126///
127/// Returns Unix milliseconds, or an error if none of the formats match.
128pub fn parse_timestamp(s: &str) -> Result<i64> {
129    let trimmed = s.trim();
130
131    // 1. Pure integer (Unix millis or seconds)
132    if let Ok(n) = trimmed.parse::<i64>() {
133        // Heuristic: 13-digit → already millis; fewer → seconds
134        return if n.abs() > 9_999_999_999 {
135            Ok(n)
136        } else {
137            Ok(n * 1000)
138        };
139    }
140
141    // 2. Float (Unix secs with sub-second fraction)
142    if let Ok(f) = trimmed.parse::<f64>() {
143        return Ok((f * 1000.0) as i64);
144    }
145
146    // 3. ISO 8601 / RFC 3339
147    if let Some(ms) = parse_iso8601(trimmed) {
148        return Ok(ms);
149    }
150
151    // 4. Apache CLF: "23/Mar/2024:14:22:47 +0000"
152    if let Some(ms) = parse_clf_timestamp(trimmed) {
153        return Ok(ms);
154    }
155
156    // 5. Syslog: "Mar 23 14:22:47"
157    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
167/// Parse ISO 8601 / RFC 3339 timestamps.
168fn parse_iso8601(s: &str) -> Option<i64> {
169    // Minimum: "YYYY-MM-DDTHH:MM:SS"
170    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    // Optional fractional seconds
184    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        // Normalise to millis (3 digits)
192        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    // Timezone offset (ignored for simplicity — treat as UTC)
202    let _tz = rest; // "Z", "+HH:MM", "-HH:MM"
203
204    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    // Zeller-like algorithm to get days since Unix epoch (1970-01-01)
214    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; // JDN of 1970-01-01
218    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
232/// Parse Apache CLF timestamp: `"23/Mar/2024:14:22:47 +0000"`.
233fn parse_clf_timestamp(s: &str) -> Option<i64> {
234    // Format: DD/Mon/YYYY:HH:MM:SS ±HHMM
235    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]; // "2024:14:22:47 +0000"
242    let colon = rest.find(':')?;
243    let year: i64 = rest[..colon].parse().ok()?;
244    let time = &rest[colon + 1..]; // "14:22:47 +0000"
245    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
258/// Parse syslog timestamp: `"Mar 23 14:22:47"` (no year — use 1970).
259fn 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// ──────────────────────────────────────────────────────────────────────────────
279// CommonLogParser (Apache / Nginx combined log format)
280// ──────────────────────────────────────────────────────────────────────────────
281
282/// Parser for Apache/Nginx Common Log / Combined Log Format.
283///
284/// CLF line format:
285/// ```text
286/// 127.0.0.1 - frank [10/Oct/2000:13:55:36 -0700] "GET /apache_pb.gif HTTP/1.0" 200 2326
287/// ```
288///
289/// Combined log adds: `"http://www.example.com/start.html" "Mozilla/5.0 ..."`
290#[derive(Debug, Default)]
291pub struct CommonLogParser;
292
293impl CommonLogParser {
294    /// Create a new `CommonLogParser`.
295    pub fn new() -> Self {
296        CommonLogParser
297    }
298
299    /// Parse a single CLF / combined log line.
300    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        // 1. Remote host
307        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        // 2. Ident (usually "-")
314        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        // 3. Auth user
321        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        // 4. Timestamp in brackets
328        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        // 5. Request line in quotes
337        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        // 6. Status code
352        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        // 7. Bytes
365        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        // 8. Optional referrer (combined log)
372        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        // 9. Optional user-agent
381        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    /// Parse all lines from a reader.
392    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// ──────────────────────────────────────────────────────────────────────────────
411// JsonLogParser
412// ──────────────────────────────────────────────────────────────────────────────
413
414/// Field name mappings for the JSON log parser.
415#[derive(Debug, Clone)]
416pub struct JsonLogFieldMap {
417    /// Field key that holds the timestamp.
418    pub timestamp: String,
419    /// Field key that holds the severity level.
420    pub level: String,
421    /// Field key that holds the message.
422    pub message: String,
423    /// Field key that holds the hostname.
424    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/// Parser for structured JSON log lines (e.g., logfmt / Winston / Bunyan).
439#[derive(Debug, Default)]
440pub struct JsonLogParser {
441    /// Mapping from JSON keys to canonical `LogEntry` fields.
442    pub field_map: JsonLogFieldMap,
443}
444
445impl JsonLogParser {
446    /// Create a new `JsonLogParser` with default field mappings.
447    pub fn new() -> Self {
448        JsonLogParser::default()
449    }
450
451    /// Create a `JsonLogParser` with a custom field name map.
452    pub fn with_field_map(field_map: JsonLogFieldMap) -> Self {
453        JsonLogParser { field_map }
454    }
455
456    /// Parse a single JSON log line into a `LogEntry`.
457    pub fn parse(&self, line: &str) -> Result<LogEntry> {
458        let mut entry = LogEntry::new_raw(line);
459
460        // We parse the flat JSON object manually to avoid a serde dependency
461        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        // Map well-known fields
470        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        // All remaining fields go into entry.fields
484        for (k, v) in &fields {
485            entry.fields.insert(k.clone(), v.clone());
486        }
487
488        Ok(entry)
489    }
490
491    /// Parse all non-empty lines.
492    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
510/// Very simple flat-JSON field extractor — handles string and number values.
511fn 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        // Read key
521        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        // Read value as string representation
532        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
539/// Returns (unescaped_string, remaining_slice) for a JSON string starting at `s`.
540fn 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
564/// Extract a JSON value and return it as a String plus the remaining slice.
565fn 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        // nested object/array — find matching bracket
573        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    // Primitive: read until comma, `}`, `]`
605    let end = s.find([',', '}', ']']).unwrap_or(s.len());
606    (s[..end].trim().to_string(), &s[end..])
607}
608
609// ──────────────────────────────────────────────────────────────────────────────
610// SyslogParser (RFC 5424)
611// ──────────────────────────────────────────────────────────────────────────────
612
613/// RFC 5424 syslog parser.
614///
615/// Full syslog line format:
616/// ```text
617/// <PRI>VERSION TIMESTAMP HOSTNAME APP-NAME PROCID MSGID STRUCTURED-DATA MSG
618/// ```
619/// Also handles legacy BSD syslog (RFC 3164).
620#[derive(Debug, Default)]
621pub struct SyslogParser;
622
623impl SyslogParser {
624    /// Create a new `SyslogParser`.
625    pub fn new() -> Self {
626        SyslogParser
627    }
628
629    /// Parse a single syslog line.
630    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            // Parse <PRI>
636            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                // Check for RFC 5424 version digit
648                let payload = if after_pri.starts_with(|c: char| c.is_ascii_digit()) {
649                    // skip version
650                    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            // Fallback: treat as BSD syslog without priority
658            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        // TIMESTAMP
668        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        // HOSTNAME
675        if let Some(host) = parts.next() {
676            if host != "-" {
677                entry.host = Some(host.to_string());
678            }
679        }
680
681        // APP-NAME
682        if let Some(app) = parts.next() {
683            if app != "-" {
684                entry.app = Some(app.to_string());
685            }
686        }
687
688        // PROCID
689        if let Some(pid_str) = parts.next() {
690            if pid_str != "-" {
691                entry.pid = pid_str.parse::<u32>().ok();
692            }
693        }
694
695        // MSGID
696        if let Some(msgid) = parts.next() {
697            if msgid != "-" {
698                entry.fields.insert("msgid".into(), msgid.to_string());
699            }
700        }
701
702        // STRUCTURED-DATA (skip for now)
703        parts.next();
704
705        // MESSAGE
706        if let Some(msg) = parts.next() {
707            // Strip optional BOM
708            entry.message = msg.trim_start_matches('\u{FEFF}').to_string();
709        }
710    }
711
712    /// Parse all lines from a reader.
713    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// ──────────────────────────────────────────────────────────────────────────────
732// LogAggregator
733// ──────────────────────────────────────────────────────────────────────────────
734
735/// Configuration for log aggregation.
736#[derive(Debug, Clone, Default)]
737pub struct AggregationConfig {
738    /// Keep only entries at or above this severity.
739    pub min_level: Option<LogLevel>,
740    /// Keep only entries containing this substring in their message.
741    pub message_contains: Option<String>,
742    /// Keep only entries from this host.
743    pub host_filter: Option<String>,
744    /// Time bucket width for time-based aggregation (seconds). 0 = no bucketing.
745    pub bucket_secs: u64,
746}
747
748/// Aggregated summary over a set of log entries.
749#[derive(Debug, Default, Clone)]
750pub struct LogSummary {
751    /// Total number of entries processed.
752    pub total: usize,
753    /// Count per severity level.
754    pub by_level: HashMap<String, usize>,
755    /// Count per host.
756    pub by_host: HashMap<String, usize>,
757    /// Count per time bucket (bucket_start_ms → count).
758    pub by_bucket: HashMap<i64, usize>,
759    /// Earliest timestamp seen (Unix ms).
760    pub earliest_ms: i64,
761    /// Latest timestamp seen (Unix ms).
762    pub latest_ms: i64,
763    /// Entries retained after filtering.
764    pub retained: Vec<LogEntry>,
765}
766
767/// Aggregate and filter log entries.
768pub struct LogAggregator {
769    config: AggregationConfig,
770}
771
772impl LogAggregator {
773    /// Create a new `LogAggregator` with the given aggregation configuration.
774    pub fn new(config: AggregationConfig) -> Self {
775        LogAggregator { config }
776    }
777
778    /// Process a slice of already-parsed `LogEntry` items.
779    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            // Level filter
788            if let Some(ref min_level) = self.config.min_level {
789                if &entry.level < min_level {
790                    continue;
791                }
792            }
793
794            // Message filter
795            if let Some(ref substr) = self.config.message_contains {
796                if !entry.message.contains(substr.as_str()) {
797                    continue;
798                }
799            }
800
801            // Host filter
802            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            // Passed all filters
809            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    /// Parse and aggregate an Apache CLF log file.
850    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    /// Parse and aggregate a JSON log file.
863    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
876// ──────────────────────────────────────────────────────────────────────────────
877// Parsing utilities
878// ──────────────────────────────────────────────────────────────────────────────
879
880/// Split off the first whitespace-delimited token.
881fn 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
889/// Extract a quoted string starting at `s`; returns (inner, rest).
890fn extract_quoted(s: &str) -> (&str, &str) {
891    if !s.starts_with('"') {
892        return split_field(s);
893    }
894    // Find closing unescaped quote
895    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// ──────────────────────────────────────────────────────────────────────────────
914// Tests
915// ──────────────────────────────────────────────────────────────────────────────
916
917#[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        // All 5 timestamps fall in the same 60-second bucket
1022        assert_eq!(summary.by_bucket.values().sum::<usize>(), 5);
1023    }
1024}