use std::cell::Cell;
use std::collections::{HashSet, VecDeque};
use std::fs::{File, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{LazyLock, Mutex};
pub use log::LevelFilter;
use log::{Log, Metadata, Record};
pub const LOG_FILE_NAME: &str = "datui.log";
const MAX_BYTES: u64 = 1024 * 1024;
const DEFAULT_LEVEL: LevelFilter = LevelFilter::Warn;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LogSettings {
pub path: Option<PathBuf>,
pub level: LevelFilter,
pub unknown_level: Option<String>,
}
impl LogSettings {
pub fn resolve(
configured: Option<&str>,
level: Option<&str>,
cache_dir: Option<&Path>,
) -> Self {
let (level, unknown_level) = match level.map(str::trim).filter(|l| !l.is_empty()) {
None => (DEFAULT_LEVEL, None),
Some(text) => match text.parse::<LevelFilter>() {
Ok(level) => (level, None),
Err(_) => (DEFAULT_LEVEL, Some(text.to_string())),
},
};
let path = match configured.map(str::trim).filter(|p| !p.is_empty()) {
Some(path) => Some(crate::config::expand_config_path(path)),
None => cache_dir.map(|dir| dir.join(LOG_FILE_NAME)),
};
Self {
path: path.filter(|_| level != LevelFilter::Off),
level,
unknown_level,
}
}
}
pub fn rotated_path(path: &Path) -> PathBuf {
let mut name = path.as_os_str().to_owned();
name.push(".1");
PathBuf::from(name)
}
pub struct FileLog {
path: PathBuf,
file: File,
cap: u64,
}
impl FileLog {
pub fn open(path: &Path, cap: u64) -> std::io::Result<Self> {
if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) {
std::fs::create_dir_all(dir)?;
}
let file = Self::append(path)?;
let mut log = Self {
path: path.to_path_buf(),
file,
cap,
};
log.rotate_if_full()?;
Ok(log)
}
fn append(path: &Path) -> std::io::Result<File> {
OpenOptions::new().create(true).append(true).open(path)
}
pub fn write_line(&mut self, line: &str) -> std::io::Result<bool> {
self.file.write_all(line.as_bytes())?;
if !line.ends_with('\n') {
self.file.write_all(b"\n")?;
}
self.rotate_if_full()
}
fn rotate_if_full(&mut self) -> std::io::Result<bool> {
if self.file.metadata()?.len() <= self.cap {
return Ok(false);
}
let mut lock_name = self.path.as_os_str().to_owned();
lock_name.push(".lock");
let Some(_lock) =
crate::cache::lock_file(Path::new(&lock_name), std::time::Duration::from_millis(200))?
else {
return Ok(false);
};
let full = std::fs::metadata(&self.path).is_ok_and(|m| m.len() > self.cap);
if full {
std::fs::rename(&self.path, rotated_path(&self.path))?;
}
self.file = Self::append(&self.path)?;
Ok(full)
}
}
struct State {
level: LevelFilter,
file: Option<FileLog>,
started: bool,
secrets: Vec<String>,
}
static STATE: Mutex<State> = Mutex::new(State {
level: LevelFilter::Off,
file: None,
started: false,
secrets: Vec::new(),
});
fn state() -> std::sync::MutexGuard<'static, State> {
STATE.lock().unwrap_or_else(|e| e.into_inner())
}
struct Logger;
static LOGGER: Logger = Logger;
impl Log for Logger {
fn enabled(&self, metadata: &Metadata) -> bool {
metadata.level() <= state().level
}
fn log(&self, record: &Record) {
if self.enabled(record.metadata()) {
write_record(record.level().as_str(), record.target(), record.args());
}
}
fn flush(&self) {
if let Some(file) = state().file.as_mut() {
let _ = file.file.flush();
}
}
}
thread_local! {
static WRITING: Cell<bool> = const { Cell::new(false) };
}
struct Writing;
impl Writing {
fn enter() -> Option<Self> {
(!WRITING.with(|w| w.replace(true))).then_some(Self)
}
}
impl Drop for Writing {
fn drop(&mut self) {
WRITING.with(|w| w.set(false));
}
}
fn write_record(level: &str, target: &str, message: impl std::fmt::Display) {
let Some(_writing) = Writing::enter() else {
return;
};
let message = message.to_string().replace('\n', "\n ");
let now = chrono::Local::now()
.format("%Y-%m-%d %H:%M:%S%.3f")
.to_string();
let mut state = state();
if state.file.is_none() {
return;
}
let mut text = String::new();
if !state.started {
state.started = true;
text.push_str(&format!(
"{now} ----- datui {} (pid {})\n",
env!("CARGO_PKG_VERSION"),
std::process::id()
));
}
text.push_str(&format!(
"{now} {level:<5} {target}: {}",
redact(&message, &state.secrets)
));
if let Some(file) = state.file.as_mut() {
let _ = file.write_line(&text);
}
}
pub fn init(settings: &LogSettings) -> Option<String> {
static INSTALLED: std::sync::Once = std::sync::Once::new();
INSTALLED.call_once(|| {
let _ = log::set_logger(&LOGGER);
polars_error::set_warning_function(polars_warning);
});
let mut note = None;
let file = settings
.path
.as_deref()
.and_then(|path| match FileLog::open(path, MAX_BYTES) {
Ok(file) => Some(file),
Err(e) => {
note = Some(format!("cannot write the log {}: {e}", path.display()));
None
}
});
let level = if file.is_some() {
settings.level
} else {
LevelFilter::Off
};
{
let mut state = state();
state.file = file;
state.level = level;
state.started = false;
}
log::set_max_level(level);
keep_out_of_log_from_env();
if let Some(text) = &settings.unknown_level {
log::warn!(target: "datui", "DATUI_LOG={text} is not a level; using warn");
}
note
}
pub fn current_path() -> Option<PathBuf> {
state().file.as_ref().map(|f| f.path.clone())
}
pub fn keep_out_of_log(secret: &str) {
if secret.len() < 8 {
return;
}
let mut state = state();
if !state.secrets.iter().any(|s| s == secret) {
state.secrets.push(secret.to_string());
}
}
fn keep_out_of_log_from_env() {
for (name, value) in crate::cloud_env::vars() {
if holds_a_credential(&name, &value) {
keep_out_of_log(&value);
}
}
}
fn holds_a_credential(name: &str, value: &str) -> bool {
let name = name.to_ascii_uppercase();
let named = [
"SECRET",
"TOKEN",
"PASSWORD",
"PASSWD",
"ACCESS_KEY",
"ACCOUNT_KEY",
"CONNECTION_STRING",
]
.iter()
.any(|word| name.contains(word))
|| name.ends_with("_KEY");
let names_a_place = [
"_FILE",
"_PATH",
"_DIR",
"_DIRECTORY",
"_HOME",
"_URL",
"_URI",
]
.iter()
.any(|suffix| name.ends_with(suffix));
let path = Path::new(value);
named && !names_a_place && !(path.is_absolute() && path.exists())
}
pub trait LogFailure {
fn or_log(self, what: &str);
}
impl<T, E: std::fmt::Display> LogFailure for Result<T, E> {
fn or_log(self, what: &str) {
if let Err(e) = self {
log::warn!(target: "datui", "{what}: {e:#}");
}
}
}
pub fn redact(text: &str, secrets: &[String]) -> String {
static PATTERNS: LazyLock<Vec<regex::Regex>> = LazyLock::new(|| {
[
r"(?i)(\b[a-z][a-z0-9+.-]*://)[^/\s:@]+:[^/\s@]+@",
r"(?i)((?:^|[?&;])(?:x-amz-signature|x-amz-credential|x-amz-security-token|x-goog-signature|x-goog-credential|signature|sig|token|access_token|api_key|apikey|key|password|secret)=)[^&\s]+",
r#"(?i)(authorization"?\s*[:=]\s*"?)[^"\r\n]+"#,
r"(\b(?i:bearer)\s+|\bBasic\s+)[A-Za-z0-9._~+/=-]{8,}",
r#"(?i)((?:secret[_-]?access[_-]?key|session[_-]?token|account[_-]?key|client[_-]?secret|sas[_-]?token|password)"?\s*[:=]\s*"?)[^\s",;}]+"#,
]
.iter()
.filter_map(|p| regex::Regex::new(p).ok())
.collect()
});
let mut out = text.to_string();
for secret in secrets {
if out.contains(secret.as_str()) {
out = out.replace(secret.as_str(), "***");
}
}
for pattern in PATTERNS.iter() {
out = pattern.replace_all(&out, "${1}***").into_owned();
}
out
}
struct PolarsWarnings {
seen: HashSet<String>,
unshown: VecDeque<String>,
}
static POLARS: Mutex<Option<PolarsWarnings>> = Mutex::new(None);
const MAX_POLARS_WARNINGS: usize = 256;
fn polars_warning(message: &str, kind: polars_error::PolarsWarning) {
use polars_error::PolarsWarning as W;
let text = message.split_whitespace().collect::<Vec<_>>().join(" ");
{
let mut polars = POLARS.lock().unwrap_or_else(|e| e.into_inner());
let polars = polars.get_or_insert_with(|| PolarsWarnings {
seen: HashSet::new(),
unshown: VecDeque::new(),
});
if polars.seen.len() >= MAX_POLARS_WARNINGS || !polars.seen.insert(text.clone()) {
return;
}
if matches!(kind, W::UserWarning | W::CategoricalRemappingWarning) {
polars.unshown.push_back(text.clone());
tell_the_loop();
}
}
log::warn!(target: "polars", "{kind:?}: {text}");
}
static NEWS: Mutex<Option<Box<dyn Fn() + Send + Sync>>> = Mutex::new(None);
fn tell_the_loop() {
if let Some(wake) = NEWS.lock().unwrap_or_else(|e| e.into_inner()).as_ref() {
wake();
}
}
pub fn next_polars_warning() -> Option<String> {
POLARS
.lock()
.unwrap_or_else(|e| e.into_inner())
.as_mut()
.and_then(|p| p.unshown.pop_front())
}
fn reset_polars_warnings() {
*POLARS.lock().unwrap_or_else(|e| e.into_inner()) = None;
}
static TUI_ACTIVE: AtomicBool = AtomicBool::new(false);
static BACKGROUND_PANIC: Mutex<Option<String>> = Mutex::new(None);
static UNREPORTED_PANICS: AtomicUsize = AtomicUsize::new(0);
thread_local! {
static REPORTS_ITS_PANICS: Cell<bool> = const { Cell::new(false) };
}
pub fn catch_panic<T>(work: impl FnOnce() -> T) -> Result<T, String> {
let reported = REPORTS_ITS_PANICS.with(|r| r.replace(true));
let unreported = UNREPORTED_PANICS.load(Ordering::SeqCst);
let caught = std::panic::catch_unwind(std::panic::AssertUnwindSafe(work));
REPORTS_ITS_PANICS.with(|r| r.set(reported));
caught.map_err(|payload| {
UNREPORTED_PANICS.fetch_min(unreported, Ordering::SeqCst);
let what = payload
.downcast_ref::<&str>()
.map(|s| s.to_string())
.or_else(|| payload.downcast_ref::<String>().cloned())
.unwrap_or_else(|| "panic".to_string());
match current_path() {
Some(path) => format!("Internal error: {what}\n\nDetails: {}", path.display()),
None => format!("Internal error: {what}"),
}
})
}
pub fn take_unreported_panic() -> Option<String> {
if UNREPORTED_PANICS.swap(0, Ordering::SeqCst) == 0 {
return None;
}
Some(match current_path() {
Some(path) => format!(
"A background task failed; see {}",
path.file_name().unwrap_or_default().to_string_lossy()
),
None => "A background task failed".to_string(),
})
}
pub struct TuiSession {
restore_terminal: fn(),
}
impl TuiSession {
pub fn begin(restore_terminal: fn()) -> Self {
reset_polars_warnings();
*BACKGROUND_PANIC.lock().unwrap_or_else(|e| e.into_inner()) = None;
UNREPORTED_PANICS.store(0, Ordering::SeqCst);
#[cfg(unix)]
{
stderr::redirect(true);
}
TUI_ACTIVE.store(true, Ordering::SeqCst);
install_panic_hook();
Self { restore_terminal }
}
pub fn wake_with(&self, wake: impl Fn() + Send + Sync + 'static) {
*NEWS.lock().unwrap_or_else(|e| e.into_inner()) = Some(Box::new(wake));
}
}
impl Drop for TuiSession {
fn drop(&mut self) {
NEWS.lock().unwrap_or_else(|e| e.into_inner()).take();
let hook_handled_it = !TUI_ACTIVE.swap(false, Ordering::SeqCst);
#[cfg(unix)]
stderr::restore();
if std::thread::panicking() && !hook_handled_it {
(self.restore_terminal)();
if let Some(message) = BACKGROUND_PANIC
.lock()
.unwrap_or_else(|e| e.into_inner())
.take()
{
eprintln!("{message}");
}
}
}
}
fn install_panic_hook() {
let tui_thread = std::thread::current().id();
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
if !TUI_ACTIVE.load(Ordering::SeqCst) {
previous(info);
return;
}
if std::thread::current().id() != tui_thread {
let message = format!(
"a background thread panicked at {}: {}\n{}",
info.location()
.map(|l| l.to_string())
.unwrap_or_else(|| "?".into()),
panic_payload(info),
std::backtrace::Backtrace::force_capture()
);
log::error!(target: "datui::panic", "{message}");
*BACKGROUND_PANIC.lock().unwrap_or_else(|e| e.into_inner()) = Some(message);
if !REPORTS_ITS_PANICS.with(Cell::get) {
UNREPORTED_PANICS.fetch_add(1, Ordering::SeqCst);
tell_the_loop();
}
return;
}
TUI_ACTIVE.store(false, Ordering::SeqCst);
let _ = crossterm::execute!(std::io::stdout(), crossterm::event::DisableMouseCapture);
BACKGROUND_PANIC
.lock()
.unwrap_or_else(|e| e.into_inner())
.take();
#[cfg(unix)]
stderr::restore();
previous(info);
}));
}
fn panic_payload(info: &std::panic::PanicHookInfo<'_>) -> String {
let payload = info.payload();
payload
.downcast_ref::<&str>()
.map(|s| s.to_string())
.or_else(|| payload.downcast_ref::<String>().cloned())
.unwrap_or_else(|| "panic".to_string())
}
#[cfg(unix)]
fn write_stray_line(line: &[u8]) {
let text = String::from_utf8_lossy(line);
let text = text.trim_end_matches(['\n', '\r']);
if !text.trim().is_empty() {
write_record("WARN", "stderr", text);
}
}
#[cfg(unix)]
mod stderr {
use std::io::BufRead;
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd};
use std::sync::Mutex;
use std::sync::mpsc::{Receiver, channel};
use std::time::Duration;
struct Redirect {
terminal: OwnedFd,
drained: Option<Receiver<()>>,
}
static REDIRECT: Mutex<Option<Redirect>> = Mutex::new(None);
fn current() -> std::sync::MutexGuard<'static, Option<Redirect>> {
REDIRECT.lock().unwrap_or_else(|e| e.into_inner())
}
pub(super) fn redirect(logging: bool) {
let mut current = current();
if current.is_some() {
return;
}
let copy = unsafe { libc::fcntl(libc::STDERR_FILENO, libc::F_DUPFD_CLOEXEC, 0) };
if copy < 0 {
return;
}
let terminal = unsafe { OwnedFd::from_raw_fd(copy) };
let target = logging.then(into_the_log).flatten().or_else(|| {
std::fs::OpenOptions::new()
.write(true)
.open("/dev/null")
.ok()
.map(|null| (OwnedFd::from(null), None))
});
if let Some((target, drained)) = target
&& point(target.as_raw_fd())
{
*current = Some(Redirect { terminal, drained });
}
}
fn into_the_log() -> Option<(OwnedFd, Option<Receiver<()>>)> {
let (reader, writer) = std::io::pipe().ok()?;
let (done, drained) = channel();
std::thread::Builder::new()
.name("datui-stderr".into())
.spawn(move || {
let mut reader = std::io::BufReader::new(reader);
let mut line = Vec::new();
loop {
line.clear();
match reader.read_until(b'\n', &mut line) {
Ok(0) => break,
Ok(_) => {
let _ = std::panic::catch_unwind(|| super::write_stray_line(&line));
}
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {}
Err(_) => break,
}
}
let _ = done.send(());
})
.ok()?;
Some((writer.into(), Some(drained)))
}
pub(super) fn restore() {
let Some(redirect) = current().take() else {
return;
};
point(redirect.terminal.as_raw_fd());
if let Some(drained) = redirect.drained {
let _ = drained.recv_timeout(Duration::from_millis(500));
}
}
fn point(fd: RawFd) -> bool {
unsafe { libc::dup2(fd, libc::STDERR_FILENO) >= 0 }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_log_goes_to_the_cache_unless_configured() {
let cache = Path::new("/cache/datui");
let default = LogSettings::resolve(None, None, Some(cache));
assert_eq!(default.path, Some(cache.join("datui.log")));
assert_eq!(default.level, LevelFilter::Warn);
let chosen = LogSettings::resolve(Some("/elsewhere/x.log"), Some("debug"), Some(cache));
assert_eq!(chosen.path, Some(PathBuf::from("/elsewhere/x.log")));
assert_eq!(chosen.level, LevelFilter::Debug);
let blank = LogSettings::resolve(Some(" "), Some(" "), Some(cache));
assert_eq!(blank.path, Some(cache.join("datui.log")));
assert_eq!(blank.level, LevelFilter::Warn);
}
#[test]
fn off_means_no_file_and_a_typo_means_the_default() {
let off = LogSettings::resolve(None, Some("OFF"), Some(Path::new("/c")));
assert_eq!(off.path, None);
let typo = LogSettings::resolve(None, Some("loud"), Some(Path::new("/c")));
assert_eq!(typo.level, LevelFilter::Warn);
assert_eq!(typo.unknown_level.as_deref(), Some("loud"));
assert!(typo.path.is_some());
}
#[test]
fn a_full_log_moves_aside_once() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("sub").join("datui.log");
let mut log = FileLog::open(&path, 100).unwrap();
let line = "x".repeat(60);
assert!(!log.write_line(&line).unwrap());
assert!(log.write_line(&line).unwrap(), "past the cap it rotates");
assert!(rotated_path(&path).exists());
assert_eq!(std::fs::metadata(&path).unwrap().len(), 0);
log.write_line(&"y".repeat(120)).unwrap();
let old = std::fs::read_to_string(rotated_path(&path)).unwrap();
assert!(old.starts_with('y'), "{old:?}");
assert!(!dir.path().join("sub").join("datui.log.1.1").exists());
}
#[test]
fn two_sessions_never_rotate_each_others_lines_away() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("datui.log");
let mut a = FileLog::open(&path, 100).unwrap();
let mut b = FileLog::open(&path, 100).unwrap();
let mut written = Vec::new();
for n in 0..2 {
for (who, log) in [("a", &mut a), ("b", &mut b)] {
let line = format!("{who}{n} {}", "x".repeat(40));
log.write_line(&line).unwrap();
written.push(line);
}
}
let all = std::fs::read_to_string(rotated_path(&path)).unwrap_or_default()
+ &std::fs::read_to_string(&path).unwrap();
for line in &written {
assert!(all.contains(line.as_str()), "lost {line:?} from {all:?}");
}
}
#[test]
fn credentials_are_masked() {
let secrets = vec!["wJalrXUtnFEMI/K7MDENG".to_string()];
let cases = [
("key wJalrXUtnFEMI/K7MDENG refused", "wJalrXUtnFEMI"),
("GET https://alice:hunter22@host/x failed", "hunter22"),
(
"403 for https://b.s3.amazonaws.com/k?X-Amz-Credential=AKIA123%2F&X-Amz-Signature=abcdef12",
"abcdef12",
),
(
"https://acct.blob.core.windows.net/c?sv=2022&sig=Zm9vYmFy",
"Zm9vYmFy",
),
("Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.x.y", "eyJhbGci"),
(
"DefaultEndpointsProtocol=https;AccountKey=c2VjcmV0a2V5;",
"c2VjcmV0a2V5",
),
];
for (line, secret) in cases {
let masked = redact(line, &secrets);
assert!(!masked.contains(secret), "{line} -> {masked}");
assert!(masked.contains("***"), "{masked}");
}
assert_eq!(
redact("s3://bucket/key.parquet: not found", &secrets),
"s3://bucket/key.parquet: not found"
);
for plain in [
"basic statistics failed for column_with_long_name",
"abfss://container@account.dfs.core.windows.net/data.parquet",
] {
assert_eq!(redact(plain, &secrets), plain);
}
assert!(!redact("Basic YWxpY2U6aHVudGVyMg==", &secrets).contains("YWxpY2U6"));
assert!(!redact("sig=Zm9vYmFyYmF6&sv=2022", &secrets).contains("Zm9vYmFy"));
}
#[test]
fn a_variable_is_masked_by_its_name_but_not_when_it_names_a_place() {
let key = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY";
for name in [
"AWS_SECRET_ACCESS_KEY",
"AWS_SESSION_TOKEN",
"AZURE_STORAGE_ACCOUNT_KEY",
"AZURE_STORAGE_CONNECTION_STRING",
"MINIO_ROOT_PASSWORD",
"hf_token",
"OPENAI_API_KEY",
] {
assert!(holds_a_credential(name, key), "{name}");
}
for name in [
"AWS_WEB_IDENTITY_TOKEN_FILE",
"GITHUB_TOKEN_PATH",
"PASSWORD_STORE_DIR",
"VAULT_TOKEN_URL",
] {
assert!(!holds_a_credential(name, key), "{name}");
}
assert!(!holds_a_credential("AWS_REGION", key));
let dir = tempfile::tempdir().unwrap();
let place = dir.path().to_string_lossy();
assert!(
!holds_a_credential("SOME_SECRET", &place),
"a path that exists is not the secret"
);
}
#[test]
fn a_caught_panic_becomes_a_message() {
assert_eq!(catch_panic(|| 7), Ok(7));
let message = catch_panic::<()>(|| panic!("worker died")).unwrap_err();
assert!(
message.starts_with("Internal error: worker died"),
"{message}"
);
let formatted = catch_panic::<()>(|| panic!("row {} of {}", 3, 9)).unwrap_err();
assert!(formatted.contains("row 3 of 9"), "{formatted}");
}
#[test]
fn a_polars_warning_lands_in_the_log_once_and_a_user_warning_is_queued() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("datui.log");
init(&LogSettings {
path: Some(path.clone()),
level: LevelFilter::Warn,
unknown_level: None,
});
assert_eq!(current_path(), Some(path.clone()));
for _ in 0..3 {
polars_error::polars_warn!(
Deprecation,
"casting in test {} is deprecated.\nUse something else.",
std::process::id()
);
}
polars_error::polars_warn!(UserWarning, "remapped categories in test {}", 7);
log::info!("below the level");
log::logger().flush();
let text = std::fs::read_to_string(&path).unwrap();
let deprecation = format!(
"Deprecation: casting in test {} is deprecated. Use something else.",
std::process::id()
);
assert_eq!(text.matches(&deprecation).count(), 1, "{text}");
assert!(
text.contains("UserWarning: remapped categories in test 7"),
"{text}"
);
assert!(!text.contains("below the level"), "{text}");
let (tx, _rx) = std::sync::mpsc::channel();
let mut app = crate::App::new(tx, crate::tests::test_runtime());
app.busy = true;
assert!(!app.flash_polars_warning(), "a busy message outranks it");
app.busy = false;
app.error_modal.active = true;
assert!(!app.flash_polars_warning(), "a modal would hide it");
app.error_modal.active = false;
let mut flashed = Vec::new();
while app.flash_polars_warning() {
flashed.extend(app.flash_message().map(str::to_string));
app.flash = None;
}
assert!(
flashed.contains(&"Polars: remapped categories in test 7".to_string()),
"{flashed:?}"
);
assert!(!flashed.iter().any(|w| w.contains("casting in test")));
polars_error::polars_warn!(UserWarning, "remapped categories in test {}", 7);
assert!(
std::iter::from_fn(next_polars_warning).all(|w| !w.contains("in test 7")),
"a user warning is flashed once"
);
}
}