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// Control characters are handled per field, never across the rendered line.
72// A complete terminal escape sequence inside one message or value is removed;
73// every control character left is written as `\uXXXX`. Stripping escape
74// sequences over the whole rendered line, as this crate did before 0.3.3, let
75// a stray `ESC ]` in one value open an OSC that consumed the value's closing
76// quote and every field after it. The TypeScript twin (`@cortexkit/log`)
77// follows the same rule, and the golden fixture shared by both pins it.
78
79/// Whether `character` is written as a `\uXXXX` escape: C0 controls other than
80/// `\n` and `\r` (which keep their own escapes), DEL, and the C1 range.
81fn is_escaped_control(character: char) -> bool {
82    matches!(character as u32, 0x00..=0x1f | 0x7f..=0x9f) && !matches!(character, '\n' | '\r')
83}
84
85fn push_control_escape(output: &mut String, character: char) {
86    output.push_str(&format!("\\u{:04x}", character as u32));
87}
88
89/// Removes CSI and OSC sequences that start and end inside `field`, in their
90/// 7-bit (`ESC [`, `ESC ]`) and C1 (`U+009B`, `U+009D`) forms. A sequence that
91/// does not end inside the field is not a sequence and is left for escaping.
92fn strip_complete_sequences(field: &str) -> std::borrow::Cow<'_, str> {
93    if !field
94        .chars()
95        .any(|character| matches!(character, '\u{1b}' | '\u{9b}' | '\u{9d}'))
96    {
97        return std::borrow::Cow::Borrowed(field);
98    }
99    let characters: Vec<char> = field.chars().collect();
100    let mut output = String::with_capacity(field.len());
101    let mut index = 0;
102    while index < characters.len() {
103        let character = characters[index];
104        let next = characters.get(index + 1).copied();
105        let csi_body = match (character, next) {
106            ('\u{1b}', Some('[')) => Some(index + 2),
107            ('\u{9b}', _) => Some(index + 1),
108            _ => None,
109        };
110        let osc_body = match (character, next) {
111            ('\u{1b}', Some(']')) => Some(index + 2),
112            ('\u{9d}', _) => Some(index + 1),
113            _ => None,
114        };
115        let end = if let Some(body) = csi_body {
116            csi_end(&characters, body)
117        } else if let Some(body) = osc_body {
118            osc_end(&characters, body)
119        } else {
120            None
121        };
122        match end {
123            Some(end) => index = end,
124            None => {
125                output.push(character);
126                index += 1;
127            }
128        }
129    }
130    std::borrow::Cow::Owned(output)
131}
132
133/// A CSI body is parameter and intermediate bytes (`0x20`-`0x3f`) ended by a
134/// final byte (`0x40`-`0x7e`). Anything else before the final byte means it
135/// was never a sequence.
136fn csi_end(characters: &[char], body: usize) -> Option<usize> {
137    for (offset, character) in characters[body..].iter().enumerate() {
138        match *character as u32 {
139            0x40..=0x7e => return Some(body + offset + 1),
140            0x20..=0x3f => {}
141            _ => return None,
142        }
143    }
144    None
145}
146
147/// An OSC ends at `BEL`, `ESC \` or C1 `ST` (`U+009C`).
148fn osc_end(characters: &[char], body: usize) -> Option<usize> {
149    let mut index = body;
150    while index < characters.len() {
151        match characters[index] {
152            '\u{7}' | '\u{9c}' => return Some(index + 1),
153            '\u{1b}' if characters.get(index + 1) == Some(&'\\') => return Some(index + 2),
154            _ => index += 1,
155        }
156    }
157    None
158}
159
160// Backslash is escaped first so a literal `\n` or `\u0041` in the input
161// survives as `\\n` or `\\u0041` and cannot be read back as an escape.
162fn escape_message(message: &str) -> String {
163    let cleaned = strip_complete_sequences(message);
164    let mut output = String::with_capacity(cleaned.len());
165    for character in cleaned.chars() {
166        match character {
167            '\\' => output.push_str("\\\\"),
168            '\r' => output.push_str("\\r"),
169            '\n' => output.push_str("\\n"),
170            control if is_escaped_control(control) => push_control_escape(&mut output, control),
171            other => output.push(other),
172        }
173    }
174    output
175}
176
177fn format_value(value: &str) -> String {
178    // Stripped before the quoting decision, so a colored plain word stays unquoted.
179    let cleaned = strip_complete_sequences(value);
180    let needs_quotes = cleaned.is_empty()
181        || cleaned.chars().any(|character| {
182            matches!(character, ' ' | '"' | '\n' | '\r' | ']') || is_escaped_control(character)
183        });
184    if !needs_quotes {
185        // An unquoted value is verbatim, backslashes included: only the quoted
186        // form has an escape grammar, and a reader decodes only quoted values.
187        return cleaned.into_owned();
188    }
189    let mut output = String::with_capacity(cleaned.len() + 2);
190    output.push('"');
191    for character in cleaned.chars() {
192        match character {
193            '\\' => output.push_str("\\\\"),
194            '"' => output.push_str("\\\""),
195            '\r' => output.push_str("\\r"),
196            '\n' => output.push_str("\\n"),
197            control if is_escaped_control(control) => push_control_escape(&mut output, control),
198            other => output.push(other),
199        }
200    }
201    output.push('"');
202    output
203}
204
205/// The guard after a module redactor: writes any raw control character the
206/// redactor put into an already-rendered line as an escape, and never strips.
207/// Backslashes are left alone, since the line's own escapes are already there.
208pub(crate) fn escape_raw_controls(line: &str) -> std::borrow::Cow<'_, str> {
209    if !line
210        .chars()
211        .any(|character| matches!(character, '\n' | '\r') || is_escaped_control(character))
212    {
213        return std::borrow::Cow::Borrowed(line);
214    }
215    let mut output = String::with_capacity(line.len());
216    for character in line.chars() {
217        match character {
218            '\r' => output.push_str("\\r"),
219            '\n' => output.push_str("\\n"),
220            control if is_escaped_control(control) => push_control_escape(&mut output, control),
221            other => output.push(other),
222        }
223    }
224    std::borrow::Cow::Owned(output)
225}
226
227/// A level parsed from a fleet log line.
228#[derive(Clone, Copy, Debug, Eq, PartialEq)]
229pub enum ParsedLevel {
230    /// A trace event.
231    Trace,
232    /// A debug event.
233    Debug,
234    /// An informational event.
235    Info,
236    /// A warning event.
237    Warn,
238    /// An error event.
239    Error,
240}
241
242/// The fields needed to merge and filter a fleet log line.
243#[derive(Clone, Debug, Eq, PartialEq)]
244pub struct ParsedLine<'a> {
245    /// Event time decoded from the UTC timestamp.
246    pub timestamp: SystemTime,
247    /// Event severity.
248    pub level: ParsedLevel,
249    /// The full dotted logger name, rooted at the module id.
250    pub logger: &'a str,
251    /// The module id: the logger's first segment.
252    pub module_id: &'a str,
253    /// The raw text inside the bound bracket, absent when there was none.
254    pub bound: Option<&'a str>,
255    /// The message and ordered event fields after the bracket.
256    pub body: &'a str,
257}
258
259impl ParsedLine<'_> {
260    /// The `session=` bound value when one is present, whole, issuer included.
261    pub fn session(&self) -> Option<&str> {
262        self.bound.and_then(|bound| bound_value(bound, "session"))
263    }
264}
265
266fn bound_value<'a>(bound: &'a str, key: &str) -> Option<&'a str> {
267    bound
268        .split(' ')
269        .filter_map(|pair| pair.split_once('='))
270        .find(|(candidate, _)| *candidate == key)
271        .map(|(_, value)| value)
272}
273
274/// A stable parse failure returned by [`parse_line`](crate::parse_line).
275#[derive(Clone, Copy, Debug, Eq, PartialEq)]
276pub struct ParseError {
277    reason: &'static str,
278}
279
280impl ParseError {
281    pub(crate) const fn new(reason: &'static str) -> Self {
282        Self { reason }
283    }
284
285    /// Returns the stable reason used by conformance fixtures and CLI diagnostics.
286    pub const fn reason(self) -> &'static str {
287        self.reason
288    }
289}
290
291impl fmt::Display for ParseError {
292    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
293        formatter.write_str(self.reason)
294    }
295}
296
297impl std::error::Error for ParseError {}
298
299pub(crate) fn parse(line: &str) -> Result<ParsedLine<'_>, ParseError> {
300    if line.contains('\u{1b}') || line.contains('\u{9b}') {
301        return Err(ParseError::new("ansi_forbidden"));
302    }
303    if line.contains(['\n', '\r']) {
304        return Err(ParseError::new("line_break"));
305    }
306
307    let (timestamp_text, after_timestamp) = line
308        .split_once(' ')
309        .ok_or_else(|| ParseError::new("timestamp_missing"))?;
310    if !timestamp_text.ends_with('Z') {
311        return Err(ParseError::new("timestamp_not_utc_z"));
312    }
313    if timestamp_text.len() != 24 {
314        return Err(ParseError::new("timestamp_precision"));
315    }
316    let timestamp = DateTime::parse_from_rfc3339(timestamp_text)
317        .map_err(|_| ParseError::new("timestamp_invalid"))?;
318
319    let (level, after_level) = parse_level(after_timestamp)?;
320
321    // The logger runs to the first space and MUST end in a colon. An r1 line
322    // (`fusiform poll changed`) has no colon and fails here by design: it is
323    // the colon that makes the logger unambiguous against a one-word message.
324    let (logger_token, mut body) = match after_level.split_once(' ') {
325        Some(split) => split,
326        None => (after_level, ""),
327    };
328    let logger = logger_token
329        .strip_suffix(':')
330        .ok_or_else(|| ParseError::new("logger_not_terminated"))?;
331    if logger.is_empty() {
332        return Err(ParseError::new("logger_missing"));
333    }
334    if !logger.split('.').all(is_segment) {
335        return Err(ParseError::new("logger_segment_grammar"));
336    }
337    let module_id = logger.split('.').next().unwrap_or(logger);
338
339    let mut bound = None;
340    if let Some(rest) = body.strip_prefix('[') {
341        let close =
342            find_bracket_close(rest).ok_or_else(|| ParseError::new("bound_unterminated"))?;
343        let inside = &rest[..close];
344        if inside.is_empty() {
345            return Err(ParseError::new("empty_bound_bracket"));
346        }
347        if let Some(session) = bound_value(inside, "session") {
348            let valid = session
349                .rsplit_once(':')
350                .is_some_and(|(issuer, id)| !issuer.is_empty() && !id.is_empty());
351            // `session=global` and other issuer-less placeholders fail here on
352            // their missing `issuer:` half; the renderer never has to know any
353            // particular sentinel.
354            if !valid {
355                return Err(ParseError::new("session_missing_issuer"));
356            }
357        }
358        bound = Some(inside);
359        body = rest[close + 1..]
360            .strip_prefix(' ')
361            .unwrap_or(&rest[close + 1..]);
362    }
363
364    // A bracket AFTER the message is not context: context precedes the message
365    // so its column is stable. Reject rather than silently reading it as a
366    // field, because a reader that accepted both would train writers to put
367    // it wherever, and the alignment property would be gone in a month.
368    if bound.is_none() && body.contains(" [") && body.ends_with(']') {
369        return Err(ParseError::new("bound_after_message"));
370    }
371
372    Ok(ParsedLine {
373        timestamp: SystemTime::from(timestamp),
374        level,
375        logger,
376        module_id,
377        bound,
378        body,
379    })
380}
381
382// The bracket closes at the first `]` that is not inside a quoted value. A
383// bound value containing `]` was quoted by the renderer for exactly this
384// reason, so a naive `find(']')` would split a path like `[root="a]b"]`.
385fn find_bracket_close(input: &str) -> Option<usize> {
386    let mut in_quotes = false;
387    let mut escaped = false;
388    for (index, character) in input.char_indices() {
389        if escaped {
390            escaped = false;
391            continue;
392        }
393        match character {
394            '\\' if in_quotes => escaped = true,
395            '"' => in_quotes = !in_quotes,
396            ']' if !in_quotes => return Some(index),
397            _ => {}
398        }
399    }
400    None
401}
402
403fn parse_level(input: &str) -> Result<(ParsedLevel, &str), ParseError> {
404    for (prefix, level) in [
405        ("TRACE ", ParsedLevel::Trace),
406        ("DEBUG ", ParsedLevel::Debug),
407        ("INFO  ", ParsedLevel::Info),
408        ("WARN  ", ParsedLevel::Warn),
409        ("ERROR ", ParsedLevel::Error),
410    ] {
411        if let Some(rest) = input.strip_prefix(prefix) {
412            return Ok((level, rest));
413        }
414    }
415
416    if ["TRACE", "DEBUG", "INFO", "WARN", "ERROR"]
417        .iter()
418        .any(|level| input.starts_with(level))
419    {
420        Err(ParseError::new("level_column_width"))
421    } else {
422        Err(ParseError::new("level_invalid"))
423    }
424}