Skip to main content

olai_stack_runtime/
logs.rs

1//! Log lines, parsed from `docker compose logs --timestamps --no-color`.
2
3use serde::Serialize;
4
5/// One line a container logged.
6#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
7pub struct LogLine {
8    /// The container's short name as Compose prefixes it (e.g. `web-1`).
9    pub container: String,
10    /// The RFC 3339 timestamp Docker recorded, when the line carried one.
11    pub timestamp: Option<String>,
12    /// The text.
13    pub text: String,
14}
15
16/// Parse one line of `docker compose logs --timestamps --no-color`, e.g.
17/// `web-1  | 2026-11-02T19:55:16.807260250Z tick 1`. A line without the `<container> | `
18/// prefix is kept whole as the text, with no container.
19pub fn parse_log_line(line: &str) -> LogLine {
20    let Some((container, rest)) = line.split_once(" | ").or_else(|| line.split_once(" |")) else {
21        return LogLine {
22            container: String::new(),
23            timestamp: None,
24            text: line.to_owned(),
25        };
26    };
27    let (timestamp, text) = match rest.split_once(' ') {
28        Some((ts, text)) if looks_like_timestamp(ts) => (Some(ts.to_owned()), text),
29        _ if looks_like_timestamp(rest) => (Some(rest.to_owned()), ""),
30        _ => (None, rest),
31    };
32    LogLine {
33        container: container.trim().to_owned(),
34        timestamp,
35        text: text.to_owned(),
36    }
37}
38
39/// `2026-11-02T19:55:16.807260250Z`: digits, then a `T`, ending in `Z` or an offset.
40fn looks_like_timestamp(s: &str) -> bool {
41    s.len() >= 20 && s.as_bytes()[..4].iter().all(u8::is_ascii_digit) && s.as_bytes()[10] == b'T'
42}
43
44#[cfg(test)]
45mod tests {
46    use super::*;
47
48    #[test]
49    fn a_line_without_a_timestamp_keeps_its_text() {
50        let line = parse_log_line("web-1  | hello there");
51        assert_eq!(line.container, "web-1");
52        assert_eq!(line.timestamp, None);
53        assert_eq!(line.text, "hello there");
54    }
55
56    #[test]
57    fn a_bare_line_has_no_container() {
58        let line = parse_log_line("no prefix here");
59        assert_eq!(line.container, "");
60        assert_eq!(line.text, "no prefix here");
61    }
62}