1use std::fmt;
2use std::time::SystemTime;
3
4use chrono::{DateTime, SecondsFormat, Utc};
5use tracing::Level;
6
7pub(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
50pub(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
71fn 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
89fn 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
133fn 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
147fn 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
160fn 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 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 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
205pub(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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
229pub enum ParsedLevel {
230 Trace,
232 Debug,
234 Info,
236 Warn,
238 Error,
240}
241
242#[derive(Clone, Debug, Eq, PartialEq)]
244pub struct ParsedLine<'a> {
245 pub timestamp: SystemTime,
247 pub level: ParsedLevel,
249 pub logger: &'a str,
251 pub module_id: &'a str,
253 pub bound: Option<&'a str>,
255 pub body: &'a str,
257}
258
259impl ParsedLine<'_> {
260 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#[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 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 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 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 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
382fn 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}