1use std::cell::RefCell;
16use std::sync::Arc;
17
18use crate::console::{Console, ConsoleOptions, Overflow};
19use crate::containers::Renderables;
20use crate::measure::Measurement;
21use crate::protocol::Renderable;
22use crate::segment::Segment;
23use crate::style::{Style, StyleType};
24use crate::table::{Cell, Table};
25use crate::text::Text;
26
27pub struct LogRender {
29 show_time: bool,
30 show_level: bool,
31 show_path: bool,
32 omit_repeated_times: bool,
33 level_width: Option<usize>,
34 last_time: RefCell<Option<Text>>,
35}
36
37impl Default for LogRender {
38 fn default() -> Self {
39 LogRender {
40 show_time: true,
41 show_level: false,
42 show_path: true,
43 omit_repeated_times: true,
44 level_width: Some(8),
45 last_time: RefCell::new(None),
46 }
47 }
48}
49
50impl LogRender {
51 pub fn new() -> Self {
54 LogRender::default()
55 }
56
57 pub fn show_time(mut self, show: bool) -> Self {
59 self.show_time = show;
60 self
61 }
62
63 pub fn show_level(mut self, show: bool) -> Self {
65 self.show_level = show;
66 self
67 }
68
69 pub fn show_path(mut self, show: bool) -> Self {
71 self.show_path = show;
72 self
73 }
74
75 pub fn omit_repeated_times(mut self, omit: bool) -> Self {
78 self.omit_repeated_times = omit;
79 self
80 }
81
82 pub fn level_width(mut self, width: Option<usize>) -> Self {
85 self.level_width = width;
86 self
87 }
88
89 #[allow(clippy::too_many_arguments)]
96 pub fn render(
97 &self,
98 console: &Console,
99 message: Text,
100 time: Option<Text>,
101 level: Text,
102 path: Option<&str>,
103 line_no: Option<u32>,
104 link_path: Option<&str>,
105 ) -> Table {
106 self.render_cell(
107 console,
108 Cell::Text(message),
109 time,
110 level,
111 path,
112 line_no,
113 link_path,
114 )
115 }
116
117 #[allow(clippy::too_many_arguments)]
123 pub fn render_renderables(
124 &self,
125 console: &Console,
126 renderables: Vec<Arc<dyn Renderable + Send + Sync>>,
127 time: Option<Text>,
128 level: Text,
129 path: Option<&str>,
130 line_no: Option<u32>,
131 link_path: Option<&str>,
132 ) -> Table {
133 self.render_cell(
134 console,
135 Cell::Renderable(Arc::new(Renderables::new(renderables))),
136 time,
137 level,
138 path,
139 line_no,
140 link_path,
141 )
142 }
143
144 #[allow(clippy::too_many_arguments)]
145 fn render_cell(
146 &self,
147 console: &Console,
148 message: Cell,
149 time: Option<Text>,
150 level: Text,
151 path: Option<&str>,
152 line_no: Option<u32>,
153 link_path: Option<&str>,
154 ) -> Table {
155 let style = |name: &str| {
156 console
157 .get_style(&StyleType::from(name))
158 .unwrap_or_default()
159 };
160 let mut output = Table::grid().padding(0, 1, 0, 1).expand(true);
161 if self.show_time {
162 output.add_column("").column_style(style("log.time"));
163 }
164 if self.show_level {
165 output.add_column("").column_style(style("log.level"));
166 if let Some(width) = self.level_width {
167 output.column_width(width);
168 }
169 }
170 output
171 .add_column("")
172 .column_ratio(1)
173 .column_style(style("log.message"))
174 .column_overflow(Overflow::Fold);
175 let path = path
176 .filter(|_| self.show_path)
177 .filter(|path| !path.is_empty());
178 if path.is_some() {
179 output.add_column("").column_style(style("log.path"));
180 }
181
182 let mut row: Vec<Cell> = Vec::new();
183 if self.show_time {
184 let display = time.unwrap_or_default();
185 let mut last = self.last_time.borrow_mut();
186 let repeated = last.as_ref().is_some_and(|last| same_text(last, &display));
187 if repeated && self.omit_repeated_times {
188 row.push(Cell::Text(Text::new(
189 " ".repeat(display.plain().chars().count()),
190 )));
191 } else {
192 row.push(Cell::Text(display.clone()));
193 *last = Some(display);
194 }
195 }
196 if self.show_level {
197 row.push(Cell::Text(level));
198 }
199 row.push(message);
200 if let Some(path) = path {
201 let link = |target: String| Style::new().with_link(target);
202 let mut path_text = Text::new("");
203 path_text.append(
204 path,
205 link_path.map(|link_path| link(format!("file://{link_path}")).into()),
206 );
207 if let Some(line_no) = line_no.filter(|line| *line > 0) {
208 path_text.append(":", None);
209 path_text.append(
210 &line_no.to_string(),
211 link_path.map(|link_path| link(format!("file://{link_path}#{line_no}")).into()),
212 );
213 }
214 row.push(Cell::Text(path_text));
215 }
216 output.add_row_cells(row);
217 output
218 }
219}
220
221fn same_text(a: &Text, b: &Text) -> bool {
223 a.plain() == b.plain() && a.spans() == b.spans()
224}
225
226pub fn level_text(name: &str) -> Text {
229 Text::styled(
230 format!("{name:<8}"),
231 format!("logging.level.{}", name.to_lowercase()),
232 )
233}
234
235pub(crate) fn strftime(time: std::time::SystemTime, format: &str) -> String {
244 let (seconds, micros) = match time.duration_since(std::time::UNIX_EPOCH) {
245 Ok(since) => (since.as_secs() as i64, since.subsec_micros()),
246 Err(before) => {
247 let before = before.duration();
248 let mut seconds = -(before.as_secs() as i64);
249 let mut micros = before.subsec_micros();
250 if micros > 0 {
251 seconds -= 1;
252 micros = 1_000_000 - micros;
253 }
254 (seconds, micros)
255 }
256 };
257 let days = seconds.div_euclid(86_400);
258 let of_day = seconds.rem_euclid(86_400);
259 let (year, month, day) = civil_from_days(days);
260 let (hour, minute, second) = (of_day / 3600, of_day / 60 % 60, of_day % 60);
261 let weekday = (days + 4).rem_euclid(7);
263 let year_day = days - days_from_civil(year, 1, 1);
264 let hour12 = if hour % 12 == 0 { 12 } else { hour % 12 };
265 const DAYS: [&str; 7] = [
266 "Sunday",
267 "Monday",
268 "Tuesday",
269 "Wednesday",
270 "Thursday",
271 "Friday",
272 "Saturday",
273 ];
274 const MONTHS: [&str; 12] = [
275 "January",
276 "February",
277 "March",
278 "April",
279 "May",
280 "June",
281 "July",
282 "August",
283 "September",
284 "October",
285 "November",
286 "December",
287 ];
288 let day_name = DAYS[weekday as usize];
289 let month_name = MONTHS[(month - 1) as usize];
290 let mut out = String::new();
291 let mut chars = format.chars();
292 while let Some(ch) = chars.next() {
293 if ch != '%' {
294 out.push(ch);
295 continue;
296 }
297 let Some(directive) = chars.next() else {
298 out.push('%');
299 break;
300 };
301 let piece = match directive {
302 'a' => day_name[..3].to_string(),
303 'A' => day_name.to_string(),
304 'b' | 'h' => month_name[..3].to_string(),
305 'B' => month_name.to_string(),
306 'c' => format!(
307 "{} {} {day:>2} {hour:02}:{minute:02}:{second:02} {year}",
308 &day_name[..3],
309 &month_name[..3]
310 ),
311 'C' => format!("{:02}", year.div_euclid(100)),
312 'd' => format!("{day:02}"),
313 'D' | 'x' => format!("{month:02}/{day:02}/{:02}", year.rem_euclid(100)),
314 'e' => format!("{day:>2}"),
315 'f' => format!("{micros:06}"),
316 'F' => format!("{year}-{month:02}-{day:02}"),
317 'H' => format!("{hour:02}"),
318 'I' => format!("{hour12:02}"),
319 'j' => format!("{:03}", year_day + 1),
320 'm' => format!("{month:02}"),
321 'M' => format!("{minute:02}"),
322 'n' => "\n".to_string(),
323 'p' => if hour < 12 { "AM" } else { "PM" }.to_string(),
324 'r' => format!(
325 "{hour12:02}:{minute:02}:{second:02} {}",
326 if hour < 12 { "AM" } else { "PM" }
327 ),
328 'R' => format!("{hour:02}:{minute:02}"),
329 'S' => format!("{second:02}"),
330 't' => "\t".to_string(),
331 'T' | 'X' => format!("{hour:02}:{minute:02}:{second:02}"),
332 'u' => (if weekday == 0 { 7 } else { weekday }).to_string(),
333 'U' => format!("{:02}", (year_day + 7 - weekday) / 7),
334 'w' => weekday.to_string(),
335 'W' => format!("{:02}", (year_day + 7 - (weekday + 6) % 7) / 7),
336 'y' => format!("{:02}", year.rem_euclid(100)),
337 'Y' => year.to_string(),
338 'z' | 'Z' => String::new(),
339 '%' => "%".to_string(),
340 other => format!("%{other}"),
341 };
342 out.push_str(&piece);
343 }
344 out
345}
346
347fn civil_from_days(days: i64) -> (i64, i64, i64) {
350 let z = days + 719_468;
351 let era = z.div_euclid(146_097);
352 let doe = z.rem_euclid(146_097);
353 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
354 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
355 let mp = (5 * doy + 2) / 153;
356 let day = doy - (153 * mp + 2) / 5 + 1;
357 let month = if mp < 10 { mp + 3 } else { mp - 9 };
358 let year = yoe + era * 400 + i64::from(month <= 2);
359 (year, month, day)
360}
361
362fn days_from_civil(year: i64, month: i64, day: i64) -> i64 {
365 let year = if month <= 2 { year - 1 } else { year };
366 let era = year.div_euclid(400);
367 let yoe = year.rem_euclid(400);
368 let mp = if month > 2 { month - 3 } else { month + 9 };
369 let doy = (153 * mp + 2) / 5 + day - 1;
370 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
371 era * 146_097 + doe - 719_468
372}
373
374#[derive(Debug, Clone, Copy, PartialEq, Eq)]
376pub enum LogLevel {
377 Trace,
378 Debug,
379 Info,
380 Warn,
381 Error,
382}
383
384impl LogLevel {
385 pub fn name(self) -> &'static str {
388 match self {
389 LogLevel::Trace => "TRACE",
390 LogLevel::Debug => "DEBUG",
391 LogLevel::Info => "INFO",
392 LogLevel::Warn => "WARNING",
393 LogLevel::Error => "ERROR",
394 }
395 }
396
397 pub fn text(self) -> Text {
399 match self {
400 LogLevel::Trace => Text::styled(format!("{:<8}", "TRACE"), "logging.level.notset"),
401 level => level_text(level.name()),
402 }
403 }
404}
405
406pub struct LogRecord {
410 level: LogLevel,
411 message: String,
412 time: Option<String>,
413 path: Option<String>,
414 line_no: Option<u32>,
415}
416
417impl LogRecord {
418 pub fn new(level: LogLevel, message: impl Into<String>) -> Self {
420 LogRecord {
421 level,
422 message: message.into(),
423 time: None,
424 path: None,
425 line_no: None,
426 }
427 }
428
429 pub fn time(mut self, time: impl Into<String>) -> Self {
431 self.time = Some(time.into());
432 self
433 }
434
435 pub fn path(mut self, path: impl Into<String>) -> Self {
437 self.path = Some(path.into());
438 self
439 }
440
441 pub fn line(mut self, line: u32) -> Self {
443 self.line_no = Some(line);
444 self
445 }
446
447 fn table(&self, console: &Console) -> Table {
448 LogRender::new()
449 .show_level(true)
450 .show_time(self.time.is_some())
451 .render(
452 console,
453 Text::new(self.message.clone()),
454 self.time.as_deref().map(Text::new),
455 self.level.text(),
456 self.path.as_deref(),
457 self.line_no,
458 None,
459 )
460 }
461}
462
463impl Renderable for LogRecord {
464 fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
465 self.table(console).rich_render(console, options)
466 }
467
468 fn measure(&self, console: &Console, options: &ConsoleOptions) -> Measurement {
469 self.table(console).measure(console, options)
470 }
471}
472
473#[cfg(test)]
474mod tests {
475 use super::*;
476 use crate::color::ColorSystem;
477
478 fn console() -> Console {
479 Console::builder()
480 .force_terminal(true)
481 .color_system(Some(ColorSystem::Truecolor))
482 .width(40)
483 .highlight(false)
484 .build()
485 }
486
487 #[test]
488 fn a_repeated_time_is_blanked() {
489 let console = console();
490 let render = LogRender::new();
491 let first = console.render_to_string(&render.render(
492 &console,
493 Text::new("one"),
494 Some(Text::new("[12:00]")),
495 Text::new(""),
496 None,
497 None,
498 None,
499 ));
500 let second = console.render_to_string(&render.render(
501 &console,
502 Text::new("two"),
503 Some(Text::new("[12:00]")),
504 Text::new(""),
505 None,
506 None,
507 None,
508 ));
509 assert!(first.contains("[12:00]"), "{first:?}");
510 assert!(!second.contains("[12:00]"), "{second:?}");
511 assert!(second.contains("two"));
512 }
513
514 #[test]
515 fn warn_uses_pythons_level_name() {
516 let out = console().render_to_string(&LogRecord::new(LogLevel::Warn, "low disk"));
517 assert!(out.contains("\x1b[33mWARNING \x1b[0m"), "{out:?}");
518 assert!(out.contains("low disk"));
519 }
520}