use std::fmt::Write;
use owo_colors::{OwoColorize, Style};
use crate::api::models::LogLine;
use crate::render::{sanitise, theme};
#[derive(Clone, Copy)]
pub struct LineFormat {
pub stream: bool,
pub time: bool,
pub trace: bool,
}
impl LineFormat {
pub fn with_stream() -> Self {
Self {
stream: true,
time: false,
trace: false,
}
}
pub fn plain() -> Self {
Self {
stream: false,
time: false,
trace: false,
}
}
pub fn showing_time(self, time: bool) -> Self {
Self { time, ..self }
}
pub fn showing_traces(self, trace: bool) -> Self {
Self { trace, ..self }
}
}
pub fn render_line(line: &LogLine, format: LineFormat) -> String {
let mut out = String::with_capacity(line.message.len() + 48);
if format.time {
let _ = write!(
out,
"{} ",
clock_of(line.at.as_deref()).style(theme::muted())
);
}
if format.stream {
let _ = write!(
out,
"{} ",
pad_stream(&line.stream).style(stream_style(&line.stream))
);
}
let (mark, style) = level_mark(&line.level);
let _ = write!(out, "{} ", mark.style(style));
if format.trace {
if let Some(id) = line.trace_id.as_deref() {
let _ = write!(out, "{} ", short_trace(id).style(theme::muted()));
}
}
let message = sanitise::with_colour(&line.message);
let highlighted = if has_escapes(&message) {
None
} else {
highlight(&message, &line.level)
};
match highlighted {
Some(style) => {
let _ = write!(out, "{}", message.style(style));
}
None => out.push_str(&message),
}
out
}
fn clock_of(at: Option<&str>) -> String {
let Some(stamp) = at else {
return " ".repeat(8);
};
match stamp.split('T').nth(1) {
Some(time) if time.len() >= 8 => time[..8].to_owned(),
_ => format!("{stamp:<8.8}"),
}
}
fn has_escapes(message: &str) -> bool {
message.contains('\u{1b}')
}
fn short_trace(id: &str) -> String {
let shown: String = id.chars().take(8).collect();
format!("[{shown}]")
}
fn pad_stream(stream: &str) -> String {
let shown = if stream.is_empty() { "—" } else { stream };
format!("{shown:<7}")
}
fn stream_style(stream: &str) -> Style {
match stream {
"build" => theme::accent(),
"deploy" => theme::info(),
"runtime" => theme::success(),
_ => theme::muted(),
}
}
fn level_mark(level: &str) -> (&'static str, Style) {
match level {
"error" => ("✖", theme::danger()),
"warn" => ("!", theme::warning()),
"debug" => ("·", theme::muted()),
_ => ("│", theme::muted()),
}
}
fn highlight(message: &str, level: &str) -> Option<Style> {
match level {
"error" => return Some(theme::danger()),
"warn" => return Some(theme::warning()),
_ => {}
}
let mentions = |words: &[&str]| {
words
.iter()
.any(|word| contains_ignoring_case(message, word))
};
if mentions(&["error", "failed", "exception"]) {
return Some(theme::danger());
}
if mentions(&["warn", "deprecated"]) {
return Some(theme::warning());
}
None
}
fn contains_ignoring_case(text: &str, word: &str) -> bool {
text.as_bytes()
.windows(word.len())
.any(|window| window.eq_ignore_ascii_case(word.as_bytes()))
}
#[cfg(test)]
mod tests {
use super::*;
fn line(message: &str, level: &str) -> LogLine {
LogLine {
sequence: 1,
at: Some("2026-07-30T20:59:16.695Z".into()),
stream: "build".into(),
level: level.into(),
message: message.into(),
stage: Some("build".into()),
trace_id: None,
}
}
fn traced(message: &str) -> LogLine {
LogLine {
trace_id: Some("4bf92f3577b34da6a3ce929d0e0e4736".into()),
..line(message, "info")
}
}
#[test]
fn it_should_draw_the_trace_a_line_carries_when_asked() {
let drawn = render_line(
&traced("saved"),
LineFormat::with_stream().showing_traces(true),
);
assert!(drawn.contains("4bf92f35"));
}
#[test]
fn it_should_abbreviate_the_trace_it_draws() {
let drawn = render_line(
&traced("saved"),
LineFormat::with_stream().showing_traces(true),
);
assert!(!drawn.contains("4bf92f3577b34da6a3ce929d0e0e4736"));
}
#[test]
fn it_should_leave_no_trace_column_on_a_line_that_carries_none() {
let untraced = line("compiling", "info");
assert_eq!(
render_line(&untraced, LineFormat::with_stream().showing_traces(true)),
render_line(&untraced, LineFormat::with_stream())
);
}
#[test]
fn it_should_keep_the_message_text_intact() {
assert!(render_line(&line("compiling", "info"), LineFormat::plain()).contains("compiling"));
}
#[test]
fn it_should_name_the_stream_when_asked() {
assert!(
render_line(&line("compiling", "info"), LineFormat::with_stream()).contains("build")
);
}
#[test]
fn it_should_leave_a_message_that_already_has_escapes_untouched() {
let coloured = "\u{1b}[32mok\u{1b}[0m";
assert!(render_line(&line(coloured, "info"), LineFormat::plain()).contains(coloured));
}
#[test]
fn it_should_show_the_wall_clock_when_asked() {
let shown = render_line(
&line("compiling", "info"),
LineFormat::plain().showing_time(true),
);
assert!(shown.contains("20:59:16"));
}
#[test]
fn it_should_leave_out_the_date_that_every_line_would_repeat() {
let shown = render_line(
&line("compiling", "info"),
LineFormat::plain().showing_time(true),
);
assert!(!shown.contains("2026-07-30"));
}
#[test]
fn it_should_hold_the_column_when_a_line_arrived_without_a_time() {
assert_eq!(clock_of(None).len(), 8);
}
#[test]
fn it_should_hold_the_column_when_the_time_is_not_a_stamp() {
assert_eq!(clock_of(Some("soon")).len(), 8);
}
#[test]
fn it_should_pad_a_missing_stream_rather_than_shifting_the_column() {
assert_eq!(pad_stream("").chars().count(), 7);
}
#[test]
fn it_should_pad_every_stream_to_the_same_width() {
assert_eq!(
pad_stream("build").chars().count(),
pad_stream("runtime").chars().count()
);
}
#[test]
fn it_should_leave_the_deploy_stream_bare_when_colour_is_off() {
let drawn = theme::silenced(|| {
render_line(
&LogLine {
stream: "deploy".into(),
..line("starting", "info")
},
LineFormat::with_stream(),
)
});
assert!(!drawn.contains('\u{1b}'));
}
#[test]
fn it_should_notice_a_failure_written_in_capitals() {
assert!(contains_ignoring_case("Build FAILED after 3s", "failed"));
}
#[test]
fn it_should_not_notice_a_word_that_is_not_there() {
assert!(!contains_ignoring_case("compiled in 3s", "error"));
}
#[test]
fn it_should_not_notice_a_word_longer_than_the_line() {
assert!(!contains_ignoring_case("warn", "deprecated"));
}
}