Skip to main content

gammaloop_api/
tracing.rs

1use std::{
2    fs::{self, OpenOptions},
3    io::{self, Write},
4    path::{Path, PathBuf},
5    sync::{Arc, LazyLock, Mutex, OnceLock},
6    time::Duration,
7};
8
9use chrono::{Local, SecondsFormat};
10use eyre::Context;
11use gammaloop_tracing_filter::{
12    strip_ansi_escape_codes, GammaDisplayFormat, GammaLogFilter, LogFormat, LogSink, LogStyle,
13    RoutedFieldsVisitor,
14};
15use indicatif::ProgressState;
16use tracing::{level_filters::LevelFilter, Event, Subscriber};
17use tracing_indicatif::{filter::IndicatifFilter, style::ProgressStyle, IndicatifLayer};
18use tracing_subscriber::field::RecordFields;
19use tracing_subscriber::Layer;
20use tracing_subscriber::{
21    filter::Filtered,
22    fmt::FormattedFields,
23    fmt::{
24        self,
25        format::Writer,
26        writer::{BoxMakeWriter, MakeWriter},
27        FmtContext, FormatEvent, FormatFields,
28    },
29    layer::SubscriberExt,
30    registry::LookupSpan,
31    util::SubscriberInitExt,
32};
33
34use color_eyre::Result;
35
36fn file_filter_from(user_spec: &str) -> Result<GammaLogFilter> {
37    GammaLogFilter::parse_with_default(user_spec, Some(LevelFilter::WARN)).map_err(Into::into)
38}
39
40fn display_filter_from(user_spec: &str) -> Result<GammaLogFilter> {
41    GammaLogFilter::parse_with_default(user_spec, Some(LevelFilter::OFF))
42        .context(format!("Trying to parse filter spec: {user_spec}"))
43}
44
45const ENV_FILE_LOG_FILTER: &str = "GL_LOGFILE_FILTER";
46const ENV_NO_GL_HARD_WARNINGS: &str = "GL_NO_HARD_WARNINGS";
47const ENV_DISPLAY_LOG_FILTER: &str = "GL_DISPLAY_FILTER";
48const ENV_ALL_LOG_FILTER: &str = "GL_ALL_LOG_FILTER";
49
50#[derive(Debug, Clone)]
51struct StderrLogSpecState {
52    base_spec: String,
53    override_spec: Option<String>,
54}
55
56impl Default for StderrLogSpecState {
57    fn default() -> Self {
58        let base_spec = if let Ok(all) = std::env::var(ENV_ALL_LOG_FILTER) {
59            all
60        } else {
61            std::env::var(ENV_DISPLAY_LOG_FILTER)
62                .ok()
63                .unwrap_or_default()
64        };
65        Self {
66            base_spec,
67            override_spec: None,
68        }
69    }
70}
71
72impl StderrLogSpecState {
73    fn effective_spec(&self) -> &str {
74        self.override_spec
75            .as_deref()
76            .unwrap_or(self.base_spec.as_str())
77    }
78
79    fn effective_spec_string(&self) -> String {
80        self.effective_spec().to_string()
81    }
82}
83
84#[derive(Debug, Clone, Default)]
85struct FileLogSpecState {
86    base_spec: String,
87    override_spec: Option<String>,
88    hard_disabled_reason: Option<String>,
89}
90
91impl FileLogSpecState {
92    fn effective_spec(&self) -> &str {
93        self.override_spec
94            .as_deref()
95            .unwrap_or(self.base_spec.as_str())
96    }
97
98    fn effective_spec_string(&self) -> String {
99        self.effective_spec().to_string()
100    }
101
102    fn hard_disabled(&self) -> bool {
103        self.hard_disabled_reason.is_some()
104    }
105}
106
107// Statics to hold the current log specifications
108static FILE_LOG_SPEC: LazyLock<Mutex<FileLogSpecState>> = LazyLock::new(|| {
109    let base_spec = if let Ok(all) = std::env::var(ENV_ALL_LOG_FILTER) {
110        all
111    } else {
112        std::env::var(ENV_FILE_LOG_FILTER).ok().unwrap_or_default()
113    };
114    Mutex::new(FileLogSpecState {
115        base_spec,
116        ..FileLogSpecState::default()
117    })
118});
119static STDERR_LOG_SPEC: LazyLock<Mutex<StderrLogSpecState>> =
120    LazyLock::new(|| Mutex::new(StderrLogSpecState::default()));
121
122type ReloadFilterFn = Box<dyn Fn(GammaLogFilter) -> Result<()> + Send + Sync>;
123
124struct FilterHandles {
125    file_reload: Option<ReloadFilterFn>,
126    stderr_reload: ReloadFilterFn,
127}
128
129static FILTER_HANDLES: OnceLock<FilterHandles> = OnceLock::new();
130
131fn detached_filter_handles(file_reload_enabled: bool) -> FilterHandles {
132    FilterHandles {
133        file_reload: file_reload_enabled.then(|| Box::new(|_| Ok(())) as ReloadFilterFn),
134        stderr_reload: Box::new(|_| Ok(())),
135    }
136}
137
138fn log_filter_env_override(specific_env: &str) -> Option<String> {
139    if let Ok(all) = std::env::var(ENV_ALL_LOG_FILTER) {
140        Some(all)
141    } else {
142        std::env::var(specific_env).ok()
143    }
144}
145
146struct LazyJsonWriterState {
147    dir: PathBuf,
148    filename: String,
149    file: Mutex<Option<fs::File>>,
150}
151
152#[derive(Clone)]
153struct LazyJsonMakeWriter {
154    state: Arc<LazyJsonWriterState>,
155}
156
157struct LazyJsonWriter {
158    state: Arc<LazyJsonWriterState>,
159    buffer: Vec<u8>,
160}
161
162impl LazyJsonMakeWriter {
163    fn new(dir: PathBuf, filename: String) -> Self {
164        Self {
165            state: Arc::new(LazyJsonWriterState {
166                dir,
167                filename,
168                file: Mutex::new(None),
169            }),
170        }
171    }
172}
173
174impl<'a> MakeWriter<'a> for LazyJsonMakeWriter {
175    type Writer = LazyJsonWriter;
176
177    fn make_writer(&'a self) -> Self::Writer {
178        LazyJsonWriter {
179            state: Arc::clone(&self.state),
180            buffer: Vec::new(),
181        }
182    }
183}
184
185impl io::Write for LazyJsonWriter {
186    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
187        self.buffer.extend_from_slice(buf);
188
189        while let Some(newline_index) = self.buffer.iter().position(|byte| *byte == b'\n') {
190            let line = self.buffer.drain(..=newline_index).collect::<Vec<_>>();
191            self.write_sanitized_line(&line)?;
192        }
193        Ok(buf.len())
194    }
195
196    fn flush(&mut self) -> io::Result<()> {
197        if !self.buffer.is_empty() {
198            let line = std::mem::take(&mut self.buffer);
199            self.write_sanitized_line(&line)?;
200        }
201        if let Some(file) = self.state.file.lock().unwrap().as_mut() {
202            file.flush()?;
203        }
204        Ok(())
205    }
206}
207
208impl LazyJsonWriter {
209    fn write_sanitized_line(&self, line: &[u8]) -> io::Result<()> {
210        let mut file = self.state.file.lock().unwrap();
211        if file.is_none() {
212            fs::create_dir_all(&self.state.dir)?;
213            let opened = OpenOptions::new()
214                .create(true)
215                .append(true)
216                .open(self.state.dir.join(&self.state.filename))?;
217            *file = Some(opened);
218        }
219
220        let file = file.as_mut().unwrap();
221        let line = sanitize_json_log_line(line);
222        file.write_all(&line)
223    }
224}
225
226fn sanitize_json_log_line(line: &[u8]) -> Vec<u8> {
227    let mut trailing_newline = false;
228    let line = if let Some(stripped) = line.strip_suffix(b"\n") {
229        trailing_newline = true;
230        stripped
231    } else {
232        line
233    };
234
235    let sanitized = match serde_json::from_slice::<serde_json::Value>(line) {
236        Ok(mut value) => {
237            strip_ansi_from_json_value(&mut value);
238            serde_json::to_vec(&value).unwrap_or_else(|_| line.to_vec())
239        }
240        Err(_) => line.to_vec(),
241    };
242
243    if trailing_newline {
244        let mut with_newline = sanitized;
245        with_newline.push(b'\n');
246        with_newline
247    } else {
248        sanitized
249    }
250}
251
252fn strip_ansi_from_json_value(value: &mut serde_json::Value) {
253    match value {
254        serde_json::Value::String(string) => {
255            *string = strip_ansi_escape_codes(string);
256        }
257        serde_json::Value::Array(items) => {
258            for item in items {
259                strip_ansi_from_json_value(item);
260            }
261        }
262        serde_json::Value::Object(map) => {
263            for value in map.values_mut() {
264                strip_ansi_from_json_value(value);
265            }
266        }
267        serde_json::Value::Null | serde_json::Value::Bool(_) | serde_json::Value::Number(_) => {}
268    }
269}
270
271fn directive_is_effectively_off(spec: &str) -> bool {
272    GammaLogFilter::is_effectively_off(spec)
273}
274
275fn file_log_disabled_error(reason: &str) -> color_eyre::Report {
276    eyre::eyre!(
277        "Cannot enable logfile logging because this session was started with the logfile logger disabled ({reason})"
278    )
279}
280
281pub fn configure_file_log_boot_mode(disabled: bool, reason: Option<&str>) -> Result<()> {
282    let mut state = FILE_LOG_SPEC.lock().unwrap();
283    state.hard_disabled_reason = if disabled {
284        Some(reason.unwrap_or("CLI boot option").to_string())
285    } else {
286        None
287    };
288    if state.hard_disabled() && !directive_is_effectively_off(state.effective_spec()) {
289        return Err(file_log_disabled_error(
290            state
291                .hard_disabled_reason
292                .as_deref()
293                .unwrap_or("CLI boot option"),
294        ));
295    }
296    Ok(())
297}
298
299pub fn file_log_boot_disabled_reason() -> Option<String> {
300    FILE_LOG_SPEC.lock().unwrap().hard_disabled_reason.clone()
301}
302
303/// Set the file log filter at runtime while preserving required targets.
304pub fn set_file_log_filter(user_spec: impl AsRef<str>) -> Result<()> {
305    let user_spec = if let Some(file) = log_filter_env_override(ENV_FILE_LOG_FILTER) {
306        if std::env::var(ENV_NO_GL_HARD_WARNINGS).is_err() {
307            println!(
308                "WARNING, file log filter is set to {file}, will override settings {}",
309                user_spec.as_ref()
310            );
311        }
312        file
313    } else {
314        user_spec.as_ref().to_string()
315    };
316    let effective_spec = {
317        let mut state = FILE_LOG_SPEC.lock().unwrap();
318        let old_base = state.base_spec.clone();
319        state.base_spec = user_spec;
320        let effective = state.effective_spec_string();
321        if let Some(reason) = state.hard_disabled_reason.clone() {
322            if !directive_is_effectively_off(&effective) {
323                state.base_spec = old_base;
324                return Err(file_log_disabled_error(&reason));
325            }
326        }
327        effective
328    };
329
330    let Some(handles) = FILTER_HANDLES.get() else {
331        return Ok(());
332    };
333    let Some(reload) = handles.file_reload.as_ref() else {
334        return Ok(());
335    };
336    let full_spec = file_filter_from(&effective_spec)?;
337    reload(full_spec)?;
338    Ok(())
339}
340
341pub fn set_file_log_filter_override(user_spec: Option<String>) -> Result<()> {
342    let effective_spec = {
343        let mut state = FILE_LOG_SPEC.lock().unwrap();
344        let old_override = state.override_spec.clone();
345        state.override_spec = user_spec;
346        let effective = state.effective_spec_string();
347        if let Some(reason) = state.hard_disabled_reason.clone() {
348            if !directive_is_effectively_off(&effective) {
349                state.override_spec = old_override;
350                return Err(file_log_disabled_error(&reason));
351            }
352        }
353        effective
354    };
355
356    let Some(handles) = FILTER_HANDLES.get() else {
357        return Ok(());
358    };
359    let Some(reload) = handles.file_reload.as_ref() else {
360        return Ok(());
361    };
362    let full_spec = file_filter_from(&effective_spec)?;
363    reload(full_spec)?;
364    Ok(())
365}
366
367pub fn clear_file_log_filter_override_on_settings_change() -> Result<()> {
368    set_file_log_filter_override(None)
369}
370
371/// Set the stderr log filter at runtime.
372pub fn set_stderr_log_filter(user_spec: impl AsRef<str>) -> Result<()> {
373    let user_spec = if let Some(display) = log_filter_env_override(ENV_DISPLAY_LOG_FILTER) {
374        if std::env::var(ENV_NO_GL_HARD_WARNINGS).is_err() {
375            println!(
376                "WARNING, display log filter is set to {display}, will override settings {}",
377                user_spec.as_ref()
378            );
379        }
380        display
381    } else {
382        user_spec.as_ref().to_string()
383    };
384
385    let effective_spec = {
386        let mut state = STDERR_LOG_SPEC.lock().unwrap();
387        state.base_spec = user_spec;
388        state.effective_spec_string()
389    };
390
391    let Some(handles) = FILTER_HANDLES.get() else {
392        return Ok(());
393    };
394    let full_spec = display_filter_from(&effective_spec)?;
395    // println!("Modifying display filter to: {}", full_spec);
396    (handles.stderr_reload)(full_spec)?;
397    Ok(())
398}
399
400pub fn set_stderr_log_filter_override(user_spec: Option<String>) -> Result<()> {
401    let effective_spec = {
402        let mut state = STDERR_LOG_SPEC.lock().unwrap();
403        state.override_spec = user_spec;
404        state.effective_spec_string()
405    };
406
407    let Some(handles) = FILTER_HANDLES.get() else {
408        return Ok(());
409    };
410    let full_spec = display_filter_from(&effective_spec)?;
411    (handles.stderr_reload)(full_spec)?;
412    Ok(())
413}
414
415/// Get the current file log filter specification.
416pub fn get_file_log_filter() -> String {
417    FILE_LOG_SPEC.lock().unwrap().effective_spec_string()
418}
419
420/// Get the current stderr log filter specification.
421pub fn get_stderr_log_filter() -> String {
422    STDERR_LOG_SPEC.lock().unwrap().effective_spec_string()
423}
424
425pub fn get_stderr_log_filter_label() -> String {
426    let spec = get_stderr_log_filter();
427    collapse_scoped_gamma_level(&spec).unwrap_or(spec)
428}
429
430fn collapse_scoped_gamma_level(spec: &str) -> Option<String> {
431    let (left, right) = spec.split_once(',')?;
432    let left = left.trim().strip_prefix("gammaloop_api=")?;
433    let right = right.trim().strip_prefix("gammalooprs=")?;
434    (left == right).then(|| left.to_string())
435}
436
437fn elapsed_subsec(state: &ProgressState, writer: &mut dyn std::fmt::Write) {
438    let seconds = state.elapsed().as_secs();
439    let sub_seconds = (state.elapsed().as_millis() % 1000) / 100;
440    let _ = writer.write_str(&format!("{}.{}s", seconds, sub_seconds));
441}
442
443pub(crate) fn init_tracing(dir: impl AsRef<Path>, log_file_name: Option<String>) {
444    symbolica::GLOBAL_SETTINGS
445        .initialize_tracing
446        .store(false, std::sync::atomic::Ordering::Relaxed);
447    FILTER_HANDLES.get_or_init(|| {
448        let file_state = FILE_LOG_SPEC.lock().unwrap().clone();
449        let file_spec = file_state.effective_spec_string();
450        // Keep startup filter semantics aligned with runtime reloads.
451        let file_filter = file_filter_from(&file_spec).unwrap_or_else(|err| {
452            eprintln!(
453                "Invalid initial file log filter '{file_spec}': {err}. Falling back to warn."
454            );
455            file_filter_from("").expect("default file log filter must parse")
456        });
457        // println!(
458        //     "File filter: {file_filter},:{}",
459        //     FILE_LOG_SPEC.lock().unwrap().as_str()
460        // );
461        let stderr_spec = STDERR_LOG_SPEC.lock().unwrap().effective_spec_string();
462        // Keep startup filter semantics aligned with runtime reloads.
463        let stderr_filter = display_filter_from(&stderr_spec).unwrap_or_else(|err| {
464            eprintln!(
465                "Invalid initial display log filter '{stderr_spec}': {err}. Falling back to off."
466            );
467            display_filter_from("").expect("default display log filter must parse")
468        });
469        // println!(
470        //     "Stderr filter: {stderr_filter},:{}",
471        //     STDERR_LOG_SPEC.lock().unwrap().as_str()
472        // );
473
474        let (file_filter_layer, file_handle) = file_filter.clone().reloadable();
475        let (stderr_filter_layer, stderr_handle) = stderr_filter.clone().reloadable();
476
477        // e.g. "2025-08-26T21-05-33.123"
478        let ts = Local::now()
479            .to_rfc3339_opts(SecondsFormat::Millis, true)
480            .replace([':', '+'], "-"); // keep it filename-friendly
481
482        let filename = if let Some(log_name) = log_file_name {
483            format!("gammalog-{log_name}.jsonl")
484        } else {
485            format!("gammalog-{ts}.jsonl")
486        };
487        let file_writer = BoxMakeWriter::new(LazyJsonMakeWriter::new(
488            dir.as_ref().to_path_buf(),
489            filename,
490        ));
491
492        let json = fmt::layer()
493            .event_format(FileJsonFmt)
494            .with_writer(file_writer);
495
496        let indicatif_layer = IndicatifLayer::new()
497            .with_span_field_formatter(IndicatifPbMsgFields{})
498            .with_max_progress_bars(1024, None) .with_progress_style( ProgressStyle::with_template(
499                      "{color_start}{span_child_prefix}{span_fields} -- {wide_msg} {elapsed_subsec}{color_end}",
500                  )
501                  .unwrap()
502                  .with_key(
503                      "elapsed_subsec",
504                      elapsed_subsec,
505                  )
506                  .with_key(
507                      "color_start",
508                      |state: &ProgressState, writer: &mut dyn std::fmt::Write| {
509                          let elapsed = state.elapsed();
510
511                          if elapsed > Duration::from_secs(60) {
512                              // Red
513                              let _ = write!(writer, "\x1b[{}m", 1 + 30);
514                          } else if elapsed > Duration::from_secs(4) {
515                              // Yellow
516                              let _ = write!(writer, "\x1b[{}m", 3 + 30);
517                          }
518                      },
519                  )
520                  .with_key(
521                      "color_end",
522                      |state: &ProgressState, writer: &mut dyn std::fmt::Write| {
523                          if state.elapsed() > Duration::from_secs(4) {
524                              let _ = write!(writer, "\x1b[0m");
525                          }
526                      },
527                  ),
528              )
529              .with_span_child_prefix_symbol("↳ ")
530              .with_span_child_prefix_indent(" ");
531
532        // Pretty status layer for stderr - only show status events
533        let status_layer = fmt::layer()
534            .with_target(false)
535            .event_format(GammaDisplayFormat::dynamic(|| {
536                LOG_STYLE_STATE.lock().unwrap().effective_style()
537            }))
538            .with_writer(indicatif_layer.get_stderr_writer());
539
540        let subscriber = tracing_subscriber::registry()
541            .with(Filtered::new(status_layer, stderr_filter_layer))
542            .with(indicatif_layer.with_filter(IndicatifFilter::new(false)));
543
544        let init_result = if file_state.hard_disabled() {
545            subscriber.try_init()
546        } else {
547            subscriber
548                .with(Filtered::new(json, file_filter_layer))
549                .try_init()
550        };
551        if init_result.is_err() {
552            return detached_filter_handles(!file_state.hard_disabled());
553        }
554
555        let file_reload = (!file_state.hard_disabled()).then_some(Box::new(move |filter| {
556            file_handle.reload(filter);
557            Ok(())
558        }) as ReloadFilterFn);
559        let stderr_reload = Box::new(move |filter| {
560            stderr_handle.reload(filter);
561            Ok(())
562        }) as ReloadFilterFn;
563
564        FilterHandles {
565            file_reload,
566            stderr_reload,
567        }
568    });
569}
570
571#[derive(Debug, Clone, Default)]
572struct LogStyleState {
573    base_style: LogStyle,
574    format_override: Option<LogFormat>,
575}
576
577impl LogStyleState {
578    fn effective_style(&self) -> LogStyle {
579        let mut style = self.base_style.clone();
580        if let Some(log_format) = self.format_override {
581            style.log_format = log_format;
582        }
583        style
584    }
585}
586
587static LOG_STYLE_STATE: LazyLock<Mutex<LogStyleState>> =
588    LazyLock::new(|| Mutex::new(LogStyleState::default()));
589
590pub fn set_log_style(style: LogStyle) {
591    LOG_STYLE_STATE.lock().unwrap().base_style = style;
592}
593
594pub fn set_log_format_override(log_format: Option<LogFormat>) {
595    LOG_STYLE_STATE.lock().unwrap().format_override = log_format;
596}
597
598#[derive(Clone, Default)]
599struct IndicatifPbMsgFields;
600
601impl<'writer> FormatFields<'writer> for IndicatifPbMsgFields {
602    fn format_fields<R: RecordFields>(
603        &self,
604        mut writer: Writer<'writer>,
605        fields: R,
606    ) -> std::fmt::Result {
607        struct Visitor {
608            msg: Option<String>,
609        }
610
611        impl tracing::field::Visit for Visitor {
612            fn record_debug(&mut self, f: &tracing::field::Field, v: &dyn std::fmt::Debug) {
613                if f.name() == "indicatif.pb_msg" {
614                    self.msg = Some(format!("{v:?}"));
615                }
616            }
617
618            fn record_str(&mut self, f: &tracing::field::Field, v: &str) {
619                if f.name() == "indicatif.pb_msg" {
620                    self.msg = Some(v.to_string());
621                }
622            }
623        }
624
625        let mut v = Visitor { msg: None };
626        fields.record(&mut v);
627        if let Some(msg) = v.msg {
628            write!(writer, "{msg}")?;
629        }
630        Ok(())
631    }
632}
633
634#[derive(Clone, Default)]
635struct FileJsonFmt;
636
637impl<S, N> FormatEvent<S, N> for FileJsonFmt
638where
639    S: Subscriber + for<'a> LookupSpan<'a>,
640    N: for<'writer> FormatFields<'writer> + 'static,
641{
642    fn format_event(
643        &self,
644        ctx: &FmtContext<'_, S, N>,
645        mut w: Writer<'_>,
646        event: &Event<'_>,
647    ) -> std::fmt::Result {
648        let now = Local::now().to_rfc3339_opts(SecondsFormat::Millis, true);
649        let meta = event.metadata();
650
651        let mut visitor = RoutedFieldsVisitor::for_sink(LogSink::File);
652        event.record(&mut visitor);
653
654        let mut payload = serde_json::Map::new();
655        payload.insert("timestamp".to_string(), serde_json::Value::String(now));
656        payload.insert(
657            "level".to_string(),
658            serde_json::Value::String(meta.level().to_string()),
659        );
660        payload.insert(
661            "target".to_string(),
662            serde_json::Value::String(meta.target().to_string()),
663        );
664
665        if let Some(module_path) = meta.module_path() {
666            payload.insert(
667                "module_path".to_string(),
668                serde_json::Value::String(module_path.to_string()),
669            );
670        }
671        if let Some(file) = meta.file() {
672            payload.insert(
673                "file".to_string(),
674                serde_json::Value::String(file.to_string()),
675            );
676        }
677        if let Some(line) = meta.line() {
678            payload.insert("line".to_string(), line.into());
679        }
680        if let Some(message) = visitor.message {
681            payload.insert("message".to_string(), serde_json::Value::String(message));
682        }
683
684        for (key, value) in visitor.fields {
685            payload.insert(key, value);
686        }
687
688        if let Some(span) = ctx.lookup_current() {
689            let mut current_span = serde_json::Map::new();
690            current_span.insert(
691                "name".to_string(),
692                serde_json::Value::String(span.name().to_string()),
693            );
694            if let Some(fields) = span.extensions().get::<FormattedFields<N>>() {
695                let formatted = fields.to_string();
696                if !formatted.is_empty() {
697                    current_span.insert("fields".to_string(), serde_json::Value::String(formatted));
698                }
699            }
700            payload.insert("span".to_string(), serde_json::Value::Object(current_span));
701        }
702
703        let line = serde_json::to_string(&payload).map_err(|_| std::fmt::Error)?;
704        w.write_str(&line)?;
705        w.write_char('\n')
706    }
707}
708
709#[cfg(test)]
710mod tests {
711    use tracing_subscriber::filter::LevelFilter;
712
713    use super::{
714        collapse_scoped_gamma_level, display_filter_from, file_filter_from, get_stderr_log_filter,
715        get_stderr_log_filter_label, set_stderr_log_filter, set_stderr_log_filter_override,
716        StderrLogSpecState, STDERR_LOG_SPEC,
717    };
718
719    struct StderrStateRestore(StderrLogSpecState);
720
721    impl Drop for StderrStateRestore {
722        fn drop(&mut self) {
723            *STDERR_LOG_SPEC.lock().unwrap() = self.0.clone();
724        }
725    }
726
727    fn preserve_stderr_filter_state() -> StderrStateRestore {
728        StderrStateRestore(STDERR_LOG_SPEC.lock().unwrap().clone())
729    }
730
731    #[test]
732    fn stderr_filter_override_supersedes_base_and_can_be_cleared() {
733        let _guard = crate::LOG_TEST_MUTEX
734            .lock()
735            .unwrap_or_else(|err| err.into_inner());
736        let _restore = preserve_stderr_filter_state();
737
738        set_stderr_log_filter("warn").unwrap();
739        set_stderr_log_filter_override(Some("debug".to_string())).unwrap();
740        assert_eq!(get_stderr_log_filter(), "debug");
741
742        set_stderr_log_filter("error").unwrap();
743        assert_eq!(get_stderr_log_filter(), "debug");
744
745        set_stderr_log_filter_override(None).unwrap();
746        assert_eq!(get_stderr_log_filter(), "error");
747    }
748
749    #[test]
750    fn scoped_gamma_directive_collapses_to_plain_level_label() {
751        assert_eq!(
752            collapse_scoped_gamma_level("gammaloop_api=debug,gammalooprs=debug"),
753            Some("debug".to_string())
754        );
755        assert_eq!(
756            collapse_scoped_gamma_level("gammaloop_api=debug,gammalooprs=info"),
757            None
758        );
759        assert_eq!(
760            collapse_scoped_gamma_level("symbolica=debug,gammalooprs=debug"),
761            None
762        );
763    }
764
765    #[test]
766    fn stderr_filter_label_prefers_collapsed_scoped_gamma_directive() {
767        let _guard = crate::LOG_TEST_MUTEX
768            .lock()
769            .unwrap_or_else(|err| err.into_inner());
770        let _restore = preserve_stderr_filter_state();
771
772        set_stderr_log_filter("warn").unwrap();
773        set_stderr_log_filter_override(Some("gammaloop_api=trace,gammalooprs=trace".to_string()))
774            .unwrap();
775        assert_eq!(get_stderr_log_filter_label(), "trace");
776
777        set_stderr_log_filter_override(None).unwrap();
778        assert_eq!(get_stderr_log_filter_label(), "warn");
779    }
780
781    #[test]
782    fn display_filter_empty_spec_defaults_to_off() {
783        assert_eq!(
784            display_filter_from("").unwrap().max_level_hint(),
785            Some(LevelFilter::OFF)
786        );
787    }
788
789    #[test]
790    fn file_filter_empty_spec_defaults_to_warn() {
791        assert_eq!(
792            file_filter_from("").unwrap().max_level_hint(),
793            Some(LevelFilter::WARN)
794        );
795    }
796
797    #[test]
798    fn display_filter_examples_with_multi_field_groups_parse() {
799        for spec in [
800            "gammalooprs::uv::forest[{generation,uv}]=debug",
801            "gammalooprs::uv::forest[{generation,uv,orientation,dump}]=debug",
802            "gammalooprs::uv::forest[{generation,uv,term}]=debug",
803            "gammalooprs::integrands::process::amplitude[{integration,subtraction}]=debug",
804            "gammalooprs::integrands::process::cross_section[{integration,sample,inspect}]=debug",
805            "gammalooprs::integrands::process::cross_section[{integration,cut,solver}]=debug",
806        ] {
807            assert!(
808                display_filter_from(spec).is_ok(),
809                "display filter example should parse: {spec}"
810            );
811        }
812    }
813
814    #[test]
815    fn file_filter_examples_with_multi_field_groups_parse() {
816        let spec = "gammalooprs::uv::forest[{generation,uv,orientation,dump}]=debug";
817        assert!(file_filter_from(spec).is_ok());
818    }
819}