Skip to main content

cortexkit_log/
format.rs

1use std::fmt;
2use std::time::SystemTime;
3
4use chrono::{DateTime, SecondsFormat, Utc};
5use tracing::Level;
6
7/// Renders one r2 fleet log line: `<ts> <LEVEL> <logger>: [<bound>] <message> <fields>`.
8///
9/// `logger` is the full dotted name (`engram`, `engram.gc.walk`), already
10/// validated by [`logger_name`]. `bound` renders inside the bracket in the order
11/// given; an empty slice renders NO bracket, because "nothing bound" and "an
12/// empty context" are different facts and only the first is ever true.
13pub(crate) fn render_line(
14    at: SystemTime,
15    level: &Level,
16    logger: &str,
17    bound: &[(String, String)],
18    message: &str,
19    fields: &[(String, String)],
20) -> String {
21    let timestamp = DateTime::<Utc>::from(at).to_rfc3339_opts(SecondsFormat::Millis, true);
22    let mut line = format!("{timestamp} {:<5} {logger}:", level.as_str());
23
24    if !bound.is_empty() {
25        line.push_str(" [");
26        for (index, (key, value)) in bound.iter().enumerate() {
27            if index > 0 {
28                line.push(' ');
29            }
30            line.push_str(key);
31            line.push('=');
32            line.push_str(&format_value(value));
33        }
34        line.push(']');
35    }
36    if !message.is_empty() {
37        line.push(' ');
38        line.push_str(&escape_message(message));
39    }
40    for (key, value) in fields {
41        line.push(' ');
42        line.push_str(key);
43        line.push('=');
44        line.push_str(&format_value(value));
45    }
46
47    line
48}
49
50/// Joins a module id and an optional component into the logger name, refusing
51/// a component that is not in the segment grammar. A `tracing` target that is
52/// a Rust module path (`synapse::engine::decode`) is NOT a component: it would
53/// make logger names an accident of code layout, so it maps to the bare module
54/// id. A target equal to the module id is the same case spelled differently.
55pub(crate) fn logger_name(module_id: &str, target: &str) -> String {
56    if target.is_empty() || target == module_id || target.contains("::") {
57        return module_id.to_owned();
58    }
59    if !target.split('.').all(is_segment) {
60        return module_id.to_owned();
61    }
62    format!("{module_id}.{target}")
63}
64
65pub(crate) fn is_segment(segment: &str) -> bool {
66    let mut chars = segment.chars();
67    matches!(chars.next(), Some('a'..='z'))
68        && chars.all(|character| matches!(character, 'a'..='z' | '0'..='9' | '-'))
69}
70
71// Backslash is escaped first so a literal `\n` in the input survives as
72// `\\n` and cannot be read back as a newline. Same order as @cortexkit/log.
73fn escape_message(message: &str) -> String {
74    message
75        .replace('\\', "\\\\")
76        .replace('\r', "\\r")
77        .replace('\n', "\\n")
78}
79
80fn format_value(value: &str) -> String {
81    if value.is_empty()
82        || value
83            .chars()
84            .any(|character| matches!(character, ' ' | '"' | '\n' | '\r' | ']'))
85    {
86        let escaped = value
87            .replace('\\', "\\\\")
88            .replace('"', "\\\"")
89            .replace('\r', "\\r")
90            .replace('\n', "\\n");
91        format!("\"{escaped}\"")
92    } else {
93        // An unquoted value is verbatim, backslashes included: only the quoted
94        // form has an escape grammar, and a reader decodes only quoted values.
95        value.to_owned()
96    }
97}
98
99pub(crate) fn strip_ansi(input: &str) -> String {
100    let without_c1 = strip_c1_sequences(input);
101    let input = without_c1.as_str();
102    let bytes = input.as_bytes();
103    let mut output = String::with_capacity(input.len());
104    let mut index = 0;
105    let mut plain_start = 0;
106
107    while index < bytes.len() {
108        if bytes[index] != 0x1b {
109            index += 1;
110            continue;
111        }
112
113        output.push_str(&input[plain_start..index]);
114        index += 1;
115        if index >= bytes.len() {
116            plain_start = index;
117            break;
118        }
119
120        match bytes[index] {
121            b'[' => {
122                index += 1;
123                while index < bytes.len() {
124                    let byte = bytes[index];
125                    index += 1;
126                    if (0x40..=0x7e).contains(&byte) {
127                        break;
128                    }
129                }
130            }
131            b']' => {
132                index += 1;
133                while index < bytes.len() {
134                    if bytes[index] == 0x07 {
135                        index += 1;
136                        break;
137                    }
138                    if bytes[index] == 0x1b
139                        && bytes.get(index + 1).is_some_and(|next| *next == b'\\')
140                    {
141                        index += 2;
142                        break;
143                    }
144                    index += 1;
145                }
146            }
147            _ => index += 1,
148        }
149        plain_start = index;
150    }
151
152    if plain_start == 0 {
153        return without_c1;
154    }
155    output.push_str(&input[plain_start..]);
156    output
157}
158
159fn strip_c1_sequences(input: &str) -> String {
160    let mut characters = input.chars();
161    let mut output = String::with_capacity(input.len());
162    while let Some(character) = characters.next() {
163        match character {
164            '\u{009b}' => {
165                for parameter in characters.by_ref() {
166                    if ('@'..='~').contains(&parameter) {
167                        break;
168                    }
169                }
170            }
171            '\u{009d}' => {
172                for payload in characters.by_ref() {
173                    if matches!(payload, '\u{0007}' | '\u{009c}') {
174                        break;
175                    }
176                }
177            }
178            '\u{0080}'..='\u{009f}' => {}
179            _ => output.push(character),
180        }
181    }
182    output
183}
184
185/// A level parsed from a fleet log line.
186#[derive(Clone, Copy, Debug, Eq, PartialEq)]
187pub enum ParsedLevel {
188    /// A trace event.
189    Trace,
190    /// A debug event.
191    Debug,
192    /// An informational event.
193    Info,
194    /// A warning event.
195    Warn,
196    /// An error event.
197    Error,
198}
199
200/// The fields needed to merge and filter a fleet log line.
201#[derive(Clone, Debug, Eq, PartialEq)]
202pub struct ParsedLine<'a> {
203    /// Event time decoded from the UTC timestamp.
204    pub timestamp: SystemTime,
205    /// Event severity.
206    pub level: ParsedLevel,
207    /// The full dotted logger name, rooted at the module id.
208    pub logger: &'a str,
209    /// The module id: the logger's first segment.
210    pub module_id: &'a str,
211    /// The raw text inside the bound bracket, absent when there was none.
212    pub bound: Option<&'a str>,
213    /// The message and ordered event fields after the bracket.
214    pub body: &'a str,
215}
216
217impl ParsedLine<'_> {
218    /// The `session=` bound value when one is present, whole, issuer included.
219    pub fn session(&self) -> Option<&str> {
220        self.bound.and_then(|bound| bound_value(bound, "session"))
221    }
222}
223
224fn bound_value<'a>(bound: &'a str, key: &str) -> Option<&'a str> {
225    bound
226        .split(' ')
227        .filter_map(|pair| pair.split_once('='))
228        .find(|(candidate, _)| *candidate == key)
229        .map(|(_, value)| value)
230}
231
232/// A stable parse failure returned by [`parse_line`](crate::parse_line).
233#[derive(Clone, Copy, Debug, Eq, PartialEq)]
234pub struct ParseError {
235    reason: &'static str,
236}
237
238impl ParseError {
239    pub(crate) const fn new(reason: &'static str) -> Self {
240        Self { reason }
241    }
242
243    /// Returns the stable reason used by conformance fixtures and CLI diagnostics.
244    pub const fn reason(self) -> &'static str {
245        self.reason
246    }
247}
248
249impl fmt::Display for ParseError {
250    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
251        formatter.write_str(self.reason)
252    }
253}
254
255impl std::error::Error for ParseError {}
256
257pub(crate) fn parse(line: &str) -> Result<ParsedLine<'_>, ParseError> {
258    if line.contains('\u{1b}') || line.contains('\u{9b}') {
259        return Err(ParseError::new("ansi_forbidden"));
260    }
261    if line.contains(['\n', '\r']) {
262        return Err(ParseError::new("line_break"));
263    }
264
265    let (timestamp_text, after_timestamp) = line
266        .split_once(' ')
267        .ok_or_else(|| ParseError::new("timestamp_missing"))?;
268    if !timestamp_text.ends_with('Z') {
269        return Err(ParseError::new("timestamp_not_utc_z"));
270    }
271    if timestamp_text.len() != 24 {
272        return Err(ParseError::new("timestamp_precision"));
273    }
274    let timestamp = DateTime::parse_from_rfc3339(timestamp_text)
275        .map_err(|_| ParseError::new("timestamp_invalid"))?;
276
277    let (level, after_level) = parse_level(after_timestamp)?;
278
279    // The logger runs to the first space and MUST end in a colon. An r1 line
280    // (`fusiform poll changed`) has no colon and fails here by design: it is
281    // the colon that makes the logger unambiguous against a one-word message.
282    let (logger_token, mut body) = match after_level.split_once(' ') {
283        Some(split) => split,
284        None => (after_level, ""),
285    };
286    let logger = logger_token
287        .strip_suffix(':')
288        .ok_or_else(|| ParseError::new("logger_not_terminated"))?;
289    if logger.is_empty() {
290        return Err(ParseError::new("logger_missing"));
291    }
292    if !logger.split('.').all(is_segment) {
293        return Err(ParseError::new("logger_segment_grammar"));
294    }
295    let module_id = logger.split('.').next().unwrap_or(logger);
296
297    let mut bound = None;
298    if let Some(rest) = body.strip_prefix('[') {
299        let close =
300            find_bracket_close(rest).ok_or_else(|| ParseError::new("bound_unterminated"))?;
301        let inside = &rest[..close];
302        if inside.is_empty() {
303            return Err(ParseError::new("empty_bound_bracket"));
304        }
305        if let Some(session) = bound_value(inside, "session") {
306            let valid = session
307                .rsplit_once(':')
308                .is_some_and(|(issuer, id)| !issuer.is_empty() && !id.is_empty());
309            // `session=global` and other issuer-less placeholders fail here on
310            // their missing `issuer:` half; the renderer never has to know any
311            // particular sentinel.
312            if !valid {
313                return Err(ParseError::new("session_missing_issuer"));
314            }
315        }
316        bound = Some(inside);
317        body = rest[close + 1..]
318            .strip_prefix(' ')
319            .unwrap_or(&rest[close + 1..]);
320    }
321
322    // A bracket AFTER the message is not context: context precedes the message
323    // so its column is stable. Reject rather than silently reading it as a
324    // field, because a reader that accepted both would train writers to put
325    // it wherever, and the alignment property would be gone in a month.
326    if bound.is_none() && body.contains(" [") && body.ends_with(']') {
327        return Err(ParseError::new("bound_after_message"));
328    }
329
330    Ok(ParsedLine {
331        timestamp: SystemTime::from(timestamp),
332        level,
333        logger,
334        module_id,
335        bound,
336        body,
337    })
338}
339
340// The bracket closes at the first `]` that is not inside a quoted value. A
341// bound value containing `]` was quoted by the renderer for exactly this
342// reason, so a naive `find(']')` would split a path like `[root="a]b"]`.
343fn find_bracket_close(input: &str) -> Option<usize> {
344    let mut in_quotes = false;
345    let mut escaped = false;
346    for (index, character) in input.char_indices() {
347        if escaped {
348            escaped = false;
349            continue;
350        }
351        match character {
352            '\\' if in_quotes => escaped = true,
353            '"' => in_quotes = !in_quotes,
354            ']' if !in_quotes => return Some(index),
355            _ => {}
356        }
357    }
358    None
359}
360
361fn parse_level(input: &str) -> Result<(ParsedLevel, &str), ParseError> {
362    for (prefix, level) in [
363        ("TRACE ", ParsedLevel::Trace),
364        ("DEBUG ", ParsedLevel::Debug),
365        ("INFO  ", ParsedLevel::Info),
366        ("WARN  ", ParsedLevel::Warn),
367        ("ERROR ", ParsedLevel::Error),
368    ] {
369        if let Some(rest) = input.strip_prefix(prefix) {
370            return Ok((level, rest));
371        }
372    }
373
374    if ["TRACE", "DEBUG", "INFO", "WARN", "ERROR"]
375        .iter()
376        .any(|level| input.starts_with(level))
377    {
378        Err(ParseError::new("level_column_width"))
379    } else {
380        Err(ParseError::new("level_invalid"))
381    }
382}