gam_solve/
progress_log.rs1use log::{LevelFilter, Log, Metadata, Record};
14use std::io::{self, Write};
15use std::sync::{Mutex, OnceLock};
16use std::time::{Duration, Instant};
17
18static LOGGER: ProgressLogger = ProgressLogger;
19static LOG_START: OnceLock<Instant> = OnceLock::new();
20static LOG_WRITE_LOCK: Mutex<()> = Mutex::new(());
21
22struct ProgressLogger;
23
24impl Log for ProgressLogger {
25 fn enabled(&self, metadata: &Metadata<'_>) -> bool {
26 metadata.level() <= log::max_level()
27 }
28
29 fn log(&self, record: &Record<'_>) {
30 if !self.enabled(record.metadata()) {
31 return;
32 }
33 let lines = format_log_record(record);
34 let log_lock_guard = LOG_WRITE_LOCK.lock().unwrap_or_else(|p| p.into_inner());
35 let mut stderr = io::stderr().lock();
36 for line in lines {
37 if writeln!(stderr, "{line}").is_err() {
41 break;
42 }
43 }
44 drop(log_lock_guard);
45 }
46
47 fn flush(&self) {}
48}
49
50fn format_log_record(record: &Record<'_>) -> Vec<String> {
51 let elapsed = LOG_START.get_or_init(Instant::now).elapsed();
52 let prefix = format!("[{}]", human_elapsed(elapsed));
53 sanitize_log_message(&record.args().to_string())
54 .lines()
55 .map(|line| format!("{prefix} {line}"))
56 .collect()
57}
58
59fn sanitize_log_message(message: &str) -> String {
60 let mut sanitized = String::with_capacity(message.len());
61 let mut chars = message.chars().peekable();
62 while let Some(ch) = chars.next() {
63 match ch {
64 '\x1b' => {
65 strip_escape_sequence(&mut chars);
66 }
67 '\r' => sanitized.push('\n'),
68 '\n' | '\t' => sanitized.push(ch),
69 ch if ch.is_control() => {}
70 ch => sanitized.push(ch),
71 }
72 }
73 sanitized
74}
75
76fn strip_escape_sequence<I>(chars: &mut std::iter::Peekable<I>)
77where
78 I: Iterator<Item = char>,
79{
80 match chars.next() {
81 Some('[') => {
82 for seq_ch in chars.by_ref() {
83 if ('@'..='~').contains(&seq_ch) {
84 break;
85 }
86 }
87 }
88 Some(']') => strip_string_escape(chars),
89 Some('P' | 'X' | '^' | '_') => strip_string_escape(chars),
90 Some(_) | None => {}
91 }
92}
93
94fn strip_string_escape<I>(chars: &mut std::iter::Peekable<I>)
95where
96 I: Iterator<Item = char>,
97{
98 while let Some(seq_ch) = chars.next() {
99 if seq_ch == '\x07' {
100 break;
101 }
102 if seq_ch == '\x1b' && chars.next_if_eq(&'\\').is_some() {
103 break;
104 }
105 }
106}
107
108fn human_elapsed(elapsed: Duration) -> String {
109 let total_secs = elapsed.as_secs();
110 let hours = total_secs / 3600;
111 let minutes = (total_secs / 60) % 60;
112 let seconds = total_secs % 60;
113 if hours > 0 {
114 format!("{hours}h {minutes:02}m {seconds:02}s")
115 } else if minutes > 0 {
116 format!("{minutes}m {seconds:02}s")
117 } else {
118 format!("{seconds}s")
119 }
120}
121
122const DEFAULT_LOG_LEVEL: LevelFilter = LevelFilter::Warn;
135
136fn parse_log_level(value: &str) -> Option<LevelFilter> {
140 match value.trim().to_ascii_lowercase().as_str() {
141 "off" | "none" | "silent" => Some(LevelFilter::Off),
142 "error" => Some(LevelFilter::Error),
143 "warn" | "warning" => Some(LevelFilter::Warn),
144 "info" => Some(LevelFilter::Info),
145 "debug" => Some(LevelFilter::Debug),
146 "trace" | "all" => Some(LevelFilter::Trace),
147 _ => None,
148 }
149}
150
151pub fn parse_level_directive(raw: &str) -> Option<LevelFilter> {
156 parse_log_level(raw)
157}
158
159pub fn set_log_level(spelling: &str) -> LevelFilter {
166 let level = parse_log_level(spelling).unwrap_or(DEFAULT_LOG_LEVEL);
167 log::set_max_level(level);
168 level
169}
170
171pub fn init_logging() {
172 init_logging_at(DEFAULT_LOG_LEVEL);
173}
174
175pub fn init_logging_at(level: LevelFilter) {
183 LOG_START.get_or_init(Instant::now);
184 let installed_backend = log::set_logger(&LOGGER).is_ok();
188 log::set_max_level(level);
189 if !installed_backend {
190 log::debug!(
194 "[log] a logging backend was already installed; re-applied max level {level} only"
195 );
196 }
197 gam_gpu::log_backend_inventory_once();
201}
202
203#[cfg(test)]
204mod tests {
205 use super::*;
206
207 #[test]
208 fn default_level_is_warn_when_no_explicit_level() {
209 assert_eq!(DEFAULT_LOG_LEVEL, LevelFilter::Warn);
210 init_logging();
211 assert_eq!(log::max_level(), DEFAULT_LOG_LEVEL);
212 }
213
214 #[test]
215 fn set_log_level_installs_explicit_level_and_defaults_on_typo() {
216 assert_eq!(set_log_level("debug"), LevelFilter::Debug);
218 assert_eq!(log::max_level(), LevelFilter::Debug);
219 assert_eq!(set_log_level("verbose"), DEFAULT_LOG_LEVEL);
221 assert_eq!(log::max_level(), DEFAULT_LOG_LEVEL);
222 log::set_max_level(DEFAULT_LOG_LEVEL);
225 }
226
227 #[test]
228 fn unrecognized_values_fall_back_to_default_not_off() {
229 assert_eq!(set_log_level("verbose"), DEFAULT_LOG_LEVEL);
231 assert_eq!(set_log_level("yes"), DEFAULT_LOG_LEVEL);
232 log::set_max_level(DEFAULT_LOG_LEVEL);
233 }
234
235 #[test]
236 fn parse_level_directive_matches_internal_parser() {
237 for spelling in [
240 "off", "error", "warn", "info", "debug", "trace", "", "garbage",
241 ] {
242 assert_eq!(parse_level_directive(spelling), parse_log_level(spelling));
243 }
244 }
245
246 #[test]
247 fn parsing_is_case_and_whitespace_insensitive() {
248 assert_eq!(parse_log_level(" INFO "), Some(LevelFilter::Info));
249 assert_eq!(parse_log_level("Warn"), Some(LevelFilter::Warn));
250 assert_eq!(parse_log_level("TRACE"), Some(LevelFilter::Trace));
251 assert_eq!(parse_log_level("off"), Some(LevelFilter::Off));
252 assert_eq!(parse_log_level("warning"), Some(LevelFilter::Warn));
253 assert_eq!(parse_log_level("silent"), Some(LevelFilter::Off));
254 assert_eq!(parse_log_level(""), None);
255 }
256}