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 writeln!(stderr, "{line}").ok();
38 }
39 drop(log_lock_guard);
40 }
41
42 fn flush(&self) {}
43}
44
45fn format_log_record(record: &Record<'_>) -> Vec<String> {
46 let elapsed = LOG_START.get_or_init(Instant::now).elapsed();
47 let prefix = format!("[{}]", human_elapsed(elapsed));
48 sanitize_log_message(&record.args().to_string())
49 .lines()
50 .map(|line| format!("{prefix} {line}"))
51 .collect()
52}
53
54fn sanitize_log_message(message: &str) -> String {
55 let mut sanitized = String::with_capacity(message.len());
56 let mut chars = message.chars().peekable();
57 while let Some(ch) = chars.next() {
58 match ch {
59 '\x1b' => {
60 strip_escape_sequence(&mut chars);
61 }
62 '\r' => sanitized.push('\n'),
63 '\n' | '\t' => sanitized.push(ch),
64 ch if ch.is_control() => {}
65 ch => sanitized.push(ch),
66 }
67 }
68 sanitized
69}
70
71fn strip_escape_sequence<I>(chars: &mut std::iter::Peekable<I>)
72where
73 I: Iterator<Item = char>,
74{
75 match chars.next() {
76 Some('[') => {
77 for seq_ch in chars.by_ref() {
78 if ('@'..='~').contains(&seq_ch) {
79 break;
80 }
81 }
82 }
83 Some(']') => strip_string_escape(chars),
84 Some('P' | 'X' | '^' | '_') => strip_string_escape(chars),
85 Some(_) | None => {}
86 }
87}
88
89fn strip_string_escape<I>(chars: &mut std::iter::Peekable<I>)
90where
91 I: Iterator<Item = char>,
92{
93 while let Some(seq_ch) = chars.next() {
94 if seq_ch == '\x07' {
95 break;
96 }
97 if seq_ch == '\x1b' && chars.next_if_eq(&'\\').is_some() {
98 break;
99 }
100 }
101}
102
103fn human_elapsed(elapsed: Duration) -> String {
104 let total_secs = elapsed.as_secs();
105 let hours = total_secs / 3600;
106 let minutes = (total_secs / 60) % 60;
107 let seconds = total_secs % 60;
108 if hours > 0 {
109 format!("{hours}h {minutes:02}m {seconds:02}s")
110 } else if minutes > 0 {
111 format!("{minutes}m {seconds:02}s")
112 } else {
113 format!("{seconds}s")
114 }
115}
116
117const DEFAULT_LOG_LEVEL: LevelFilter = LevelFilter::Warn;
134
135fn parse_log_level(value: &str) -> Option<LevelFilter> {
139 match value.trim().to_ascii_lowercase().as_str() {
140 "off" | "none" | "silent" => Some(LevelFilter::Off),
141 "error" => Some(LevelFilter::Error),
142 "warn" | "warning" => Some(LevelFilter::Warn),
143 "info" => Some(LevelFilter::Info),
144 "debug" => Some(LevelFilter::Debug),
145 "trace" | "all" => Some(LevelFilter::Trace),
146 _ => None,
147 }
148}
149
150fn log_level_from_overrides(primary: Option<&str>, fallback: Option<&str>) -> LevelFilter {
156 primary
157 .and_then(parse_log_level)
158 .or_else(|| fallback.and_then(parse_log_level))
159 .unwrap_or(DEFAULT_LOG_LEVEL)
160}
161
162pub fn parse_level_directive(raw: &str) -> Option<LevelFilter> {
167 parse_log_level(raw)
168}
169
170pub fn set_log_level(spelling: &str) -> LevelFilter {
178 let level = log_level_from_overrides(Some(spelling), None);
179 log::set_max_level(level);
180 level
181}
182
183pub fn init_logging() {
184 init_logging_at(DEFAULT_LOG_LEVEL);
188}
189
190pub fn init_logging_at(level: LevelFilter) {
198 LOG_START.get_or_init(Instant::now);
199 if log::set_logger(&LOGGER).is_err() {
203 }
205 log::set_max_level(level);
206 gam_gpu::log_backend_inventory_once();
210}
211
212#[cfg(test)]
213mod tests {
214 use super::*;
215
216 #[test]
217 fn default_level_is_warn_when_no_overrides() {
218 assert_eq!(log_level_from_overrides(None, None), LevelFilter::Warn);
219 assert_eq!(DEFAULT_LOG_LEVEL, LevelFilter::Warn);
220 }
221
222 #[test]
223 fn primary_override_takes_precedence_over_fallback() {
224 assert_eq!(
225 log_level_from_overrides(Some("info"), Some("trace")),
226 LevelFilter::Info
227 );
228 assert_eq!(
229 log_level_from_overrides(Some("off"), Some("debug")),
230 LevelFilter::Off
231 );
232 }
233
234 #[test]
235 fn fallback_used_when_primary_absent_or_unrecognized() {
236 assert_eq!(
237 log_level_from_overrides(None, Some("debug")),
238 LevelFilter::Debug
239 );
240 assert_eq!(
242 log_level_from_overrides(Some("loud"), Some("trace")),
243 LevelFilter::Trace
244 );
245 }
246
247 #[test]
248 fn set_log_level_installs_explicit_level_and_defaults_on_typo() {
249 assert_eq!(set_log_level("debug"), LevelFilter::Debug);
251 assert_eq!(log::max_level(), LevelFilter::Debug);
252 assert_eq!(set_log_level("verbose"), DEFAULT_LOG_LEVEL);
254 assert_eq!(log::max_level(), DEFAULT_LOG_LEVEL);
255 log::set_max_level(DEFAULT_LOG_LEVEL);
258 }
259
260 #[test]
261 fn unrecognized_values_fall_back_to_default_not_off() {
262 assert_eq!(
264 log_level_from_overrides(Some("verbose"), None),
265 DEFAULT_LOG_LEVEL
266 );
267 assert_eq!(
268 log_level_from_overrides(Some("yes"), Some("loud")),
269 DEFAULT_LOG_LEVEL
270 );
271 }
272
273 #[test]
274 fn parse_level_directive_matches_internal_parser() {
275 for spelling in ["off", "error", "warn", "info", "debug", "trace", "", "garbage"] {
278 assert_eq!(parse_level_directive(spelling), parse_log_level(spelling));
279 }
280 }
281
282 #[test]
283 fn parsing_is_case_and_whitespace_insensitive() {
284 assert_eq!(parse_log_level(" INFO "), Some(LevelFilter::Info));
285 assert_eq!(parse_log_level("Warn"), Some(LevelFilter::Warn));
286 assert_eq!(parse_log_level("TRACE"), Some(LevelFilter::Trace));
287 assert_eq!(parse_log_level("off"), Some(LevelFilter::Off));
288 assert_eq!(parse_log_level("warning"), Some(LevelFilter::Warn));
289 assert_eq!(parse_log_level("silent"), Some(LevelFilter::Off));
290 assert_eq!(parse_log_level(""), None);
291 }
292}