1use std::cell::Cell;
10use std::collections::{HashSet, VecDeque};
11use std::fs::{File, OpenOptions};
12use std::io::Write;
13use std::path::{Path, PathBuf};
14use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
15use std::sync::{LazyLock, Mutex};
16
17pub use log::LevelFilter;
18use log::{Log, Metadata, Record};
19
20pub const LOG_FILE_NAME: &str = "datui.log";
22
23const MAX_BYTES: u64 = 1024 * 1024;
25
26const DEFAULT_LEVEL: LevelFilter = LevelFilter::Warn;
28
29#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct LogSettings {
32 pub path: Option<PathBuf>,
34 pub level: LevelFilter,
35 pub unknown_level: Option<String>,
37}
38
39impl LogSettings {
40 pub fn resolve(
43 configured: Option<&str>,
44 level: Option<&str>,
45 cache_dir: Option<&Path>,
46 ) -> Self {
47 let (level, unknown_level) = match level.map(str::trim).filter(|l| !l.is_empty()) {
48 None => (DEFAULT_LEVEL, None),
49 Some(text) => match text.parse::<LevelFilter>() {
50 Ok(level) => (level, None),
51 Err(_) => (DEFAULT_LEVEL, Some(text.to_string())),
52 },
53 };
54 let path = match configured.map(str::trim).filter(|p| !p.is_empty()) {
55 Some(path) => Some(crate::config::expand_config_path(path)),
56 None => cache_dir.map(|dir| dir.join(LOG_FILE_NAME)),
57 };
58 Self {
59 path: path.filter(|_| level != LevelFilter::Off),
60 level,
61 unknown_level,
62 }
63 }
64}
65
66pub fn rotated_path(path: &Path) -> PathBuf {
68 let mut name = path.as_os_str().to_owned();
69 name.push(".1");
70 PathBuf::from(name)
71}
72
73pub struct FileLog {
75 path: PathBuf,
76 file: File,
77 cap: u64,
78}
79
80impl FileLog {
81 pub fn open(path: &Path, cap: u64) -> std::io::Result<Self> {
83 if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) {
84 std::fs::create_dir_all(dir)?;
85 }
86 let file = Self::append(path)?;
87 let mut log = Self {
88 path: path.to_path_buf(),
89 file,
90 cap,
91 };
92 log.rotate_if_full()?;
93 Ok(log)
94 }
95
96 fn append(path: &Path) -> std::io::Result<File> {
97 OpenOptions::new().create(true).append(true).open(path)
100 }
101
102 pub fn write_line(&mut self, line: &str) -> std::io::Result<bool> {
104 self.file.write_all(line.as_bytes())?;
105 if !line.ends_with('\n') {
106 self.file.write_all(b"\n")?;
107 }
108 self.rotate_if_full()
109 }
110
111 fn rotate_if_full(&mut self) -> std::io::Result<bool> {
118 if self.file.metadata()?.len() <= self.cap {
119 return Ok(false);
120 }
121 let mut lock_name = self.path.as_os_str().to_owned();
122 lock_name.push(".lock");
123 let Some(_lock) =
124 crate::cache::lock_file(Path::new(&lock_name), std::time::Duration::from_millis(200))?
125 else {
126 return Ok(false);
128 };
129 let full = std::fs::metadata(&self.path).is_ok_and(|m| m.len() > self.cap);
130 if full {
131 std::fs::rename(&self.path, rotated_path(&self.path))?;
132 }
133 self.file = Self::append(&self.path)?;
134 Ok(full)
135 }
136}
137
138struct State {
139 level: LevelFilter,
140 file: Option<FileLog>,
141 started: bool,
143 secrets: Vec<String>,
145}
146
147static STATE: Mutex<State> = Mutex::new(State {
148 level: LevelFilter::Off,
149 file: None,
150 started: false,
151 secrets: Vec::new(),
152});
153
154fn state() -> std::sync::MutexGuard<'static, State> {
155 STATE.lock().unwrap_or_else(|e| e.into_inner())
156}
157
158struct Logger;
159
160static LOGGER: Logger = Logger;
161
162impl Log for Logger {
163 fn enabled(&self, metadata: &Metadata) -> bool {
164 metadata.level() <= state().level
165 }
166
167 fn log(&self, record: &Record) {
168 if self.enabled(record.metadata()) {
169 write_record(record.level().as_str(), record.target(), record.args());
170 }
171 }
172
173 fn flush(&self) {
174 if let Some(file) = state().file.as_mut() {
175 let _ = file.file.flush();
176 }
177 }
178}
179
180thread_local! {
181 static WRITING: Cell<bool> = const { Cell::new(false) };
184}
185
186struct Writing;
188
189impl Writing {
190 fn enter() -> Option<Self> {
191 (!WRITING.with(|w| w.replace(true))).then_some(Self)
192 }
193}
194
195impl Drop for Writing {
196 fn drop(&mut self) {
197 WRITING.with(|w| w.set(false));
198 }
199}
200
201fn write_record(level: &str, target: &str, message: impl std::fmt::Display) {
203 let Some(_writing) = Writing::enter() else {
204 return;
205 };
206 let message = message.to_string().replace('\n', "\n ");
208 let now = chrono::Local::now()
209 .format("%Y-%m-%d %H:%M:%S%.3f")
210 .to_string();
211 let mut state = state();
212 if state.file.is_none() {
213 return;
214 }
215 let mut text = String::new();
216 if !state.started {
217 state.started = true;
218 text.push_str(&format!(
219 "{now} ----- datui {} (pid {})\n",
220 env!("CARGO_PKG_VERSION"),
221 std::process::id()
222 ));
223 }
224 text.push_str(&format!(
225 "{now} {level:<5} {target}: {}",
226 redact(&message, &state.secrets)
227 ));
228 if let Some(file) = state.file.as_mut() {
229 let _ = file.write_line(&text);
231 }
232}
233
234pub fn init(settings: &LogSettings) -> Option<String> {
240 static INSTALLED: std::sync::Once = std::sync::Once::new();
241 INSTALLED.call_once(|| {
242 let _ = log::set_logger(&LOGGER);
243 polars_error::set_warning_function(polars_warning);
244 });
245 let mut note = None;
246 let file = settings
247 .path
248 .as_deref()
249 .and_then(|path| match FileLog::open(path, MAX_BYTES) {
250 Ok(file) => Some(file),
251 Err(e) => {
252 note = Some(format!("cannot write the log {}: {e}", path.display()));
253 None
254 }
255 });
256 let level = if file.is_some() {
257 settings.level
258 } else {
259 LevelFilter::Off
260 };
261 {
262 let mut state = state();
263 state.file = file;
264 state.level = level;
265 state.started = false;
266 }
267 log::set_max_level(level);
268 keep_out_of_log_from_env();
269 if let Some(text) = &settings.unknown_level {
270 log::warn!(target: "datui", "DATUI_LOG={text} is not a level; using warn");
271 }
272 note
273}
274
275pub fn current_path() -> Option<PathBuf> {
277 state().file.as_ref().map(|f| f.path.clone())
278}
279
280pub fn keep_out_of_log(secret: &str) {
282 if secret.len() < 8 {
284 return;
285 }
286 let mut state = state();
287 if !state.secrets.iter().any(|s| s == secret) {
288 state.secrets.push(secret.to_string());
289 }
290}
291
292fn keep_out_of_log_from_env() {
295 for (name, value) in crate::cloud_env::vars() {
296 if holds_a_credential(&name, &value) {
297 keep_out_of_log(&value);
298 }
299 }
300}
301
302fn holds_a_credential(name: &str, value: &str) -> bool {
306 let name = name.to_ascii_uppercase();
307 let named = [
308 "SECRET",
309 "TOKEN",
310 "PASSWORD",
311 "PASSWD",
312 "ACCESS_KEY",
313 "ACCOUNT_KEY",
314 "CONNECTION_STRING",
315 ]
316 .iter()
317 .any(|word| name.contains(word))
318 || name.ends_with("_KEY");
319 let names_a_place = [
320 "_FILE",
321 "_PATH",
322 "_DIR",
323 "_DIRECTORY",
324 "_HOME",
325 "_URL",
326 "_URI",
327 ]
328 .iter()
329 .any(|suffix| name.ends_with(suffix));
330 let path = Path::new(value);
331 named && !names_a_place && !(path.is_absolute() && path.exists())
332}
333
334pub trait LogFailure {
336 fn or_log(self, what: &str);
337}
338
339impl<T, E: std::fmt::Display> LogFailure for Result<T, E> {
340 fn or_log(self, what: &str) {
341 if let Err(e) = self {
342 log::warn!(target: "datui", "{what}: {e:#}");
343 }
344 }
345}
346
347pub fn redact(text: &str, secrets: &[String]) -> String {
350 static PATTERNS: LazyLock<Vec<regex::Regex>> = LazyLock::new(|| {
351 [
352 r"(?i)(\b[a-z][a-z0-9+.-]*://)[^/\s:@]+:[^/\s@]+@",
354 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]+",
356 r#"(?i)(authorization"?\s*[:=]\s*"?)[^"\r\n]+"#,
358 r"(\b(?i:bearer)\s+|\bBasic\s+)[A-Za-z0-9._~+/=-]{8,}",
360 r#"(?i)((?:secret[_-]?access[_-]?key|session[_-]?token|account[_-]?key|client[_-]?secret|sas[_-]?token|password)"?\s*[:=]\s*"?)[^\s",;}]+"#,
362 ]
363 .iter()
364 .filter_map(|p| regex::Regex::new(p).ok())
365 .collect()
366 });
367 let mut out = text.to_string();
368 for secret in secrets {
369 if out.contains(secret.as_str()) {
370 out = out.replace(secret.as_str(), "***");
371 }
372 }
373 for pattern in PATTERNS.iter() {
374 out = pattern.replace_all(&out, "${1}***").into_owned();
375 }
376 out
377}
378
379struct PolarsWarnings {
381 seen: HashSet<String>,
382 unshown: VecDeque<String>,
383}
384
385static POLARS: Mutex<Option<PolarsWarnings>> = Mutex::new(None);
386
387const MAX_POLARS_WARNINGS: usize = 256;
390
391fn polars_warning(message: &str, kind: polars_error::PolarsWarning) {
396 use polars_error::PolarsWarning as W;
397 let text = message.split_whitespace().collect::<Vec<_>>().join(" ");
398 {
399 let mut polars = POLARS.lock().unwrap_or_else(|e| e.into_inner());
400 let polars = polars.get_or_insert_with(|| PolarsWarnings {
401 seen: HashSet::new(),
402 unshown: VecDeque::new(),
403 });
404 if polars.seen.len() >= MAX_POLARS_WARNINGS || !polars.seen.insert(text.clone()) {
405 return;
406 }
407 if matches!(kind, W::UserWarning | W::CategoricalRemappingWarning) {
408 polars.unshown.push_back(text.clone());
409 tell_the_loop();
410 }
411 }
412 log::warn!(target: "polars", "{kind:?}: {text}");
413}
414
415static NEWS: Mutex<Option<Box<dyn Fn() + Send + Sync>>> = Mutex::new(None);
419
420fn tell_the_loop() {
421 if let Some(wake) = NEWS.lock().unwrap_or_else(|e| e.into_inner()).as_ref() {
422 wake();
423 }
424}
425
426pub fn next_polars_warning() -> Option<String> {
428 POLARS
429 .lock()
430 .unwrap_or_else(|e| e.into_inner())
431 .as_mut()
432 .and_then(|p| p.unshown.pop_front())
433}
434
435fn reset_polars_warnings() {
437 *POLARS.lock().unwrap_or_else(|e| e.into_inner()) = None;
438}
439
440static TUI_ACTIVE: AtomicBool = AtomicBool::new(false);
442
443static BACKGROUND_PANIC: Mutex<Option<String>> = Mutex::new(None);
445
446static UNREPORTED_PANICS: AtomicUsize = AtomicUsize::new(0);
449
450thread_local! {
451 static REPORTS_ITS_PANICS: Cell<bool> = const { Cell::new(false) };
453}
454
455pub fn catch_panic<T>(work: impl FnOnce() -> T) -> Result<T, String> {
459 let reported = REPORTS_ITS_PANICS.with(|r| r.replace(true));
460 let unreported = UNREPORTED_PANICS.load(Ordering::SeqCst);
461 let caught = std::panic::catch_unwind(std::panic::AssertUnwindSafe(work));
462 REPORTS_ITS_PANICS.with(|r| r.set(reported));
463 caught.map_err(|payload| {
464 UNREPORTED_PANICS.fetch_min(unreported, Ordering::SeqCst);
466 let what = payload
467 .downcast_ref::<&str>()
468 .map(|s| s.to_string())
469 .or_else(|| payload.downcast_ref::<String>().cloned())
470 .unwrap_or_else(|| "panic".to_string());
471 match current_path() {
472 Some(path) => format!("Internal error: {what}\n\nDetails: {}", path.display()),
473 None => format!("Internal error: {what}"),
474 }
475 })
476}
477
478pub fn take_unreported_panic() -> Option<String> {
481 if UNREPORTED_PANICS.swap(0, Ordering::SeqCst) == 0 {
482 return None;
483 }
484 Some(match current_path() {
485 Some(path) => format!(
486 "A background task failed; see {}",
487 path.file_name().unwrap_or_default().to_string_lossy()
488 ),
489 None => "A background task failed".to_string(),
490 })
491}
492
493pub struct TuiSession {
497 restore_terminal: fn(),
498}
499
500impl TuiSession {
501 pub fn begin(restore_terminal: fn()) -> Self {
504 reset_polars_warnings();
505 *BACKGROUND_PANIC.lock().unwrap_or_else(|e| e.into_inner()) = None;
506 UNREPORTED_PANICS.store(0, Ordering::SeqCst);
507 #[cfg(unix)]
508 {
509 stderr::redirect(true);
512 }
513 TUI_ACTIVE.store(true, Ordering::SeqCst);
514 install_panic_hook();
515 Self { restore_terminal }
516 }
517
518 pub fn wake_with(&self, wake: impl Fn() + Send + Sync + 'static) {
521 *NEWS.lock().unwrap_or_else(|e| e.into_inner()) = Some(Box::new(wake));
522 }
523}
524
525impl Drop for TuiSession {
526 fn drop(&mut self) {
527 NEWS.lock().unwrap_or_else(|e| e.into_inner()).take();
528 let hook_handled_it = !TUI_ACTIVE.swap(false, Ordering::SeqCst);
532 #[cfg(unix)]
533 stderr::restore();
534 if std::thread::panicking() && !hook_handled_it {
537 (self.restore_terminal)();
538 if let Some(message) = BACKGROUND_PANIC
539 .lock()
540 .unwrap_or_else(|e| e.into_inner())
541 .take()
542 {
543 eprintln!("{message}");
544 }
545 }
546 }
547}
548
549fn install_panic_hook() {
552 let tui_thread = std::thread::current().id();
553 let previous = std::panic::take_hook();
554 std::panic::set_hook(Box::new(move |info| {
555 if !TUI_ACTIVE.load(Ordering::SeqCst) {
556 previous(info);
557 return;
558 }
559 if std::thread::current().id() != tui_thread {
560 let message = format!(
563 "a background thread panicked at {}: {}\n{}",
564 info.location()
565 .map(|l| l.to_string())
566 .unwrap_or_else(|| "?".into()),
567 panic_payload(info),
568 std::backtrace::Backtrace::force_capture()
569 );
570 log::error!(target: "datui::panic", "{message}");
571 *BACKGROUND_PANIC.lock().unwrap_or_else(|e| e.into_inner()) = Some(message);
572 if !REPORTS_ITS_PANICS.with(Cell::get) {
573 UNREPORTED_PANICS.fetch_add(1, Ordering::SeqCst);
574 tell_the_loop();
575 }
576 return;
577 }
578 TUI_ACTIVE.store(false, Ordering::SeqCst);
582 let _ = crossterm::execute!(std::io::stdout(), crossterm::event::DisableMouseCapture);
586 BACKGROUND_PANIC
587 .lock()
588 .unwrap_or_else(|e| e.into_inner())
589 .take();
590 #[cfg(unix)]
591 stderr::restore();
592 previous(info);
593 }));
594}
595
596fn panic_payload(info: &std::panic::PanicHookInfo<'_>) -> String {
597 let payload = info.payload();
598 payload
599 .downcast_ref::<&str>()
600 .map(|s| s.to_string())
601 .or_else(|| payload.downcast_ref::<String>().cloned())
602 .unwrap_or_else(|| "panic".to_string())
603}
604
605#[cfg(unix)]
607fn write_stray_line(line: &[u8]) {
608 let text = String::from_utf8_lossy(line);
609 let text = text.trim_end_matches(['\n', '\r']);
610 if !text.trim().is_empty() {
611 write_record("WARN", "stderr", text);
612 }
613}
614
615#[cfg(unix)]
621mod stderr {
622 use std::io::BufRead;
623 use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd};
624 use std::sync::Mutex;
625 use std::sync::mpsc::{Receiver, channel};
626 use std::time::Duration;
627
628 struct Redirect {
629 terminal: OwnedFd,
631 drained: Option<Receiver<()>>,
633 }
634
635 static REDIRECT: Mutex<Option<Redirect>> = Mutex::new(None);
636
637 fn current() -> std::sync::MutexGuard<'static, Option<Redirect>> {
638 REDIRECT.lock().unwrap_or_else(|e| e.into_inner())
639 }
640
641 pub(super) fn redirect(logging: bool) {
643 let mut current = current();
644 if current.is_some() {
645 return;
646 }
647 let copy = unsafe { libc::fcntl(libc::STDERR_FILENO, libc::F_DUPFD_CLOEXEC, 0) };
650 if copy < 0 {
651 return;
652 }
653 let terminal = unsafe { OwnedFd::from_raw_fd(copy) };
655 let target = logging.then(into_the_log).flatten().or_else(|| {
656 std::fs::OpenOptions::new()
657 .write(true)
658 .open("/dev/null")
659 .ok()
660 .map(|null| (OwnedFd::from(null), None))
661 });
662 if let Some((target, drained)) = target
665 && point(target.as_raw_fd())
666 {
667 *current = Some(Redirect { terminal, drained });
668 }
669 }
670
671 fn into_the_log() -> Option<(OwnedFd, Option<Receiver<()>>)> {
673 let (reader, writer) = std::io::pipe().ok()?;
674 let (done, drained) = channel();
675 std::thread::Builder::new()
676 .name("datui-stderr".into())
677 .spawn(move || {
678 let mut reader = std::io::BufReader::new(reader);
679 let mut line = Vec::new();
680 loop {
681 line.clear();
682 match reader.read_until(b'\n', &mut line) {
683 Ok(0) => break,
684 Ok(_) => {
687 let _ = std::panic::catch_unwind(|| super::write_stray_line(&line));
688 }
689 Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {}
690 Err(_) => break,
691 }
692 }
693 let _ = done.send(());
694 })
695 .ok()?;
696 Some((writer.into(), Some(drained)))
697 }
698
699 pub(super) fn restore() {
701 let Some(redirect) = current().take() else {
702 return;
703 };
704 point(redirect.terminal.as_raw_fd());
705 if let Some(drained) = redirect.drained {
709 let _ = drained.recv_timeout(Duration::from_millis(500));
710 }
711 }
712
713 fn point(fd: RawFd) -> bool {
714 unsafe { libc::dup2(fd, libc::STDERR_FILENO) >= 0 }
717 }
718}
719
720#[cfg(test)]
721mod tests {
722 use super::*;
723
724 #[test]
725 fn the_log_goes_to_the_cache_unless_configured() {
726 let cache = Path::new("/cache/datui");
727 let default = LogSettings::resolve(None, None, Some(cache));
728 assert_eq!(default.path, Some(cache.join("datui.log")));
729 assert_eq!(default.level, LevelFilter::Warn);
730
731 let chosen = LogSettings::resolve(Some("/elsewhere/x.log"), Some("debug"), Some(cache));
732 assert_eq!(chosen.path, Some(PathBuf::from("/elsewhere/x.log")));
733 assert_eq!(chosen.level, LevelFilter::Debug);
734
735 let blank = LogSettings::resolve(Some(" "), Some(" "), Some(cache));
736 assert_eq!(blank.path, Some(cache.join("datui.log")));
737 assert_eq!(blank.level, LevelFilter::Warn);
738 }
739
740 #[test]
741 fn off_means_no_file_and_a_typo_means_the_default() {
742 let off = LogSettings::resolve(None, Some("OFF"), Some(Path::new("/c")));
743 assert_eq!(off.path, None);
744
745 let typo = LogSettings::resolve(None, Some("loud"), Some(Path::new("/c")));
746 assert_eq!(typo.level, LevelFilter::Warn);
747 assert_eq!(typo.unknown_level.as_deref(), Some("loud"));
748 assert!(typo.path.is_some());
749 }
750
751 #[test]
752 fn a_full_log_moves_aside_once() {
753 let dir = tempfile::tempdir().unwrap();
754 let path = dir.path().join("sub").join("datui.log");
755 let mut log = FileLog::open(&path, 100).unwrap();
756 let line = "x".repeat(60);
757 assert!(!log.write_line(&line).unwrap());
758 assert!(log.write_line(&line).unwrap(), "past the cap it rotates");
759 assert!(rotated_path(&path).exists());
760 assert_eq!(std::fs::metadata(&path).unwrap().len(), 0);
761 log.write_line(&"y".repeat(120)).unwrap();
763 let old = std::fs::read_to_string(rotated_path(&path)).unwrap();
764 assert!(old.starts_with('y'), "{old:?}");
765 assert!(!dir.path().join("sub").join("datui.log.1.1").exists());
766 }
767
768 #[test]
771 fn two_sessions_never_rotate_each_others_lines_away() {
772 let dir = tempfile::tempdir().unwrap();
773 let path = dir.path().join("datui.log");
774 let mut a = FileLog::open(&path, 100).unwrap();
775 let mut b = FileLog::open(&path, 100).unwrap();
776 let mut written = Vec::new();
777 for n in 0..2 {
779 for (who, log) in [("a", &mut a), ("b", &mut b)] {
780 let line = format!("{who}{n} {}", "x".repeat(40));
781 log.write_line(&line).unwrap();
782 written.push(line);
783 }
784 }
785 let all = std::fs::read_to_string(rotated_path(&path)).unwrap_or_default()
786 + &std::fs::read_to_string(&path).unwrap();
787 for line in &written {
788 assert!(all.contains(line.as_str()), "lost {line:?} from {all:?}");
789 }
790 }
791
792 #[test]
793 fn credentials_are_masked() {
794 let secrets = vec!["wJalrXUtnFEMI/K7MDENG".to_string()];
795 let cases = [
796 ("key wJalrXUtnFEMI/K7MDENG refused", "wJalrXUtnFEMI"),
797 ("GET https://alice:hunter22@host/x failed", "hunter22"),
798 (
799 "403 for https://b.s3.amazonaws.com/k?X-Amz-Credential=AKIA123%2F&X-Amz-Signature=abcdef12",
800 "abcdef12",
801 ),
802 (
803 "https://acct.blob.core.windows.net/c?sv=2022&sig=Zm9vYmFy",
804 "Zm9vYmFy",
805 ),
806 ("Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.x.y", "eyJhbGci"),
807 (
808 "DefaultEndpointsProtocol=https;AccountKey=c2VjcmV0a2V5;",
809 "c2VjcmV0a2V5",
810 ),
811 ];
812 for (line, secret) in cases {
813 let masked = redact(line, &secrets);
814 assert!(!masked.contains(secret), "{line} -> {masked}");
815 assert!(masked.contains("***"), "{masked}");
816 }
817 assert_eq!(
818 redact("s3://bucket/key.parquet: not found", &secrets),
819 "s3://bucket/key.parquet: not found"
820 );
821 for plain in [
822 "basic statistics failed for column_with_long_name",
823 "abfss://container@account.dfs.core.windows.net/data.parquet",
824 ] {
825 assert_eq!(redact(plain, &secrets), plain);
826 }
827 assert!(!redact("Basic YWxpY2U6aHVudGVyMg==", &secrets).contains("YWxpY2U6"));
828 assert!(!redact("sig=Zm9vYmFyYmF6&sv=2022", &secrets).contains("Zm9vYmFy"));
829 }
830
831 #[test]
832 fn a_variable_is_masked_by_its_name_but_not_when_it_names_a_place() {
833 let key = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY";
834 for name in [
835 "AWS_SECRET_ACCESS_KEY",
836 "AWS_SESSION_TOKEN",
837 "AZURE_STORAGE_ACCOUNT_KEY",
838 "AZURE_STORAGE_CONNECTION_STRING",
839 "MINIO_ROOT_PASSWORD",
840 "hf_token",
841 "OPENAI_API_KEY",
842 ] {
843 assert!(holds_a_credential(name, key), "{name}");
844 }
845 for name in [
846 "AWS_WEB_IDENTITY_TOKEN_FILE",
847 "GITHUB_TOKEN_PATH",
848 "PASSWORD_STORE_DIR",
849 "VAULT_TOKEN_URL",
850 ] {
851 assert!(!holds_a_credential(name, key), "{name}");
852 }
853 assert!(!holds_a_credential("AWS_REGION", key));
854 let dir = tempfile::tempdir().unwrap();
855 let place = dir.path().to_string_lossy();
856 assert!(
857 !holds_a_credential("SOME_SECRET", &place),
858 "a path that exists is not the secret"
859 );
860 }
861
862 #[test]
863 fn a_caught_panic_becomes_a_message() {
864 assert_eq!(catch_panic(|| 7), Ok(7));
865 let message = catch_panic::<()>(|| panic!("worker died")).unwrap_err();
866 assert!(
867 message.starts_with("Internal error: worker died"),
868 "{message}"
869 );
870 let formatted = catch_panic::<()>(|| panic!("row {} of {}", 3, 9)).unwrap_err();
871 assert!(formatted.contains("row 3 of 9"), "{formatted}");
872 }
873
874 #[test]
877 fn a_polars_warning_lands_in_the_log_once_and_a_user_warning_is_queued() {
878 let dir = tempfile::tempdir().unwrap();
879 let path = dir.path().join("datui.log");
880 init(&LogSettings {
881 path: Some(path.clone()),
882 level: LevelFilter::Warn,
883 unknown_level: None,
884 });
885 assert_eq!(current_path(), Some(path.clone()));
886
887 for _ in 0..3 {
888 polars_error::polars_warn!(
889 Deprecation,
890 "casting in test {} is deprecated.\nUse something else.",
891 std::process::id()
892 );
893 }
894 polars_error::polars_warn!(UserWarning, "remapped categories in test {}", 7);
895 log::info!("below the level");
896 log::logger().flush();
897
898 let text = std::fs::read_to_string(&path).unwrap();
899 let deprecation = format!(
900 "Deprecation: casting in test {} is deprecated. Use something else.",
901 std::process::id()
902 );
903 assert_eq!(text.matches(&deprecation).count(), 1, "{text}");
904 assert!(
905 text.contains("UserWarning: remapped categories in test 7"),
906 "{text}"
907 );
908 assert!(!text.contains("below the level"), "{text}");
909
910 let (tx, _rx) = std::sync::mpsc::channel();
912 let mut app = crate::App::new(tx, crate::tests::test_runtime());
913 app.busy = true;
914 assert!(!app.flash_polars_warning(), "a busy message outranks it");
915 app.busy = false;
916 app.error_modal.active = true;
917 assert!(!app.flash_polars_warning(), "a modal would hide it");
918 app.error_modal.active = false;
919 let mut flashed = Vec::new();
920 while app.flash_polars_warning() {
921 flashed.extend(app.flash_message().map(str::to_string));
922 app.flash = None;
923 }
924 assert!(
925 flashed.contains(&"Polars: remapped categories in test 7".to_string()),
926 "{flashed:?}"
927 );
928 assert!(!flashed.iter().any(|w| w.contains("casting in test")));
929 polars_error::polars_warn!(UserWarning, "remapped categories in test {}", 7);
930 assert!(
931 std::iter::from_fn(next_polars_warning).all(|w| !w.contains("in test 7")),
932 "a user warning is flashed once"
933 );
934 }
935}