use chrono::{DateTime, Local};
use std::collections::VecDeque;
use tracing::Level;
const DEFAULT_MAX_ENTRIES: usize = 1000;
const MAX_ENTRIES_UPPER_BOUND: usize = 10000;
const MAX_ENTRIES_ENV_VAR: &str = "BSSH_TUI_LOG_MAX_ENTRIES";
#[derive(Debug, Clone)]
pub struct LogEntry {
pub level: Level,
pub target: String,
pub message: String,
pub timestamp: DateTime<Local>,
}
impl LogEntry {
pub fn new(level: Level, target: String, message: String) -> Self {
Self {
level,
target,
message,
timestamp: Local::now(),
}
}
pub fn format_short(&self) -> String {
let level_str = match self.level {
Level::ERROR => "ERROR",
Level::WARN => "WARN",
Level::INFO => "INFO",
Level::DEBUG => "DEBUG",
Level::TRACE => "TRACE",
};
format!(
"[{}] {}: {}",
level_str,
self.target.rsplit("::").next().unwrap_or(&self.target),
self.message
)
}
pub fn format_with_time(&self) -> String {
let level_str = match self.level {
Level::ERROR => "ERROR",
Level::WARN => "WARN",
Level::INFO => "INFO",
Level::DEBUG => "DEBUG",
Level::TRACE => "TRACE",
};
format!(
"{} [{}] {}: {}",
self.timestamp.format("%H:%M:%S"),
level_str,
self.target.rsplit("::").next().unwrap_or(&self.target),
self.message
)
}
}
#[derive(Debug)]
pub struct LogBuffer {
entries: VecDeque<LogEntry>,
max_entries: usize,
has_new_entries: bool,
}
impl LogBuffer {
pub fn new(max_entries: usize) -> Self {
Self {
entries: VecDeque::with_capacity(max_entries.min(DEFAULT_MAX_ENTRIES)),
max_entries,
has_new_entries: false,
}
}
pub fn from_env() -> Self {
let max_entries = std::env::var(MAX_ENTRIES_ENV_VAR)
.ok()
.and_then(|v| v.parse().ok())
.map(|v: usize| v.clamp(1, MAX_ENTRIES_UPPER_BOUND))
.unwrap_or(DEFAULT_MAX_ENTRIES);
Self::new(max_entries)
}
pub fn push(&mut self, entry: LogEntry) {
if self.entries.len() >= self.max_entries {
self.entries.pop_front();
}
self.entries.push_back(entry);
self.has_new_entries = true;
}
pub fn iter(&self) -> impl Iterator<Item = &LogEntry> {
self.entries.iter()
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn clear(&mut self) {
self.entries.clear();
self.has_new_entries = false;
}
pub fn take_has_new_entries(&mut self) -> bool {
let result = self.has_new_entries;
self.has_new_entries = false;
result
}
pub fn last_n(&self, n: usize) -> impl Iterator<Item = &LogEntry> {
let skip = self.entries.len().saturating_sub(n);
self.entries.iter().skip(skip)
}
pub fn get_window(&self, offset: usize, count: usize) -> Vec<&LogEntry> {
let total = self.entries.len();
if total == 0 || count == 0 {
return Vec::new();
}
let end = total.saturating_sub(offset);
let start = end.saturating_sub(count);
self.entries.iter().skip(start).take(end - start).collect()
}
pub fn filter_by_level(&self, min_level: Level) -> Vec<&LogEntry> {
self.entries
.iter()
.filter(|e| e.level <= min_level)
.collect()
}
}
impl Default for LogBuffer {
fn default() -> Self {
Self::from_env()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_log_buffer_basic() {
let mut buffer = LogBuffer::new(5);
assert!(buffer.is_empty());
buffer.push(LogEntry::new(
Level::INFO,
"test".to_string(),
"message 1".to_string(),
));
assert_eq!(buffer.len(), 1);
assert!(!buffer.is_empty());
}
#[test]
fn test_log_buffer_fifo() {
let mut buffer = LogBuffer::new(3);
for i in 1..=5 {
buffer.push(LogEntry::new(
Level::INFO,
"test".to_string(),
format!("message {i}"),
));
}
assert_eq!(buffer.len(), 3);
let messages: Vec<_> = buffer.iter().map(|e| e.message.as_str()).collect();
assert_eq!(messages, vec!["message 3", "message 4", "message 5"]);
}
#[test]
fn test_log_buffer_last_n() {
let mut buffer = LogBuffer::new(10);
for i in 1..=5 {
buffer.push(LogEntry::new(
Level::INFO,
"test".to_string(),
format!("message {i}"),
));
}
let last_two: Vec<_> = buffer.last_n(2).map(|e| e.message.as_str()).collect();
assert_eq!(last_two, vec!["message 4", "message 5"]);
}
#[test]
fn test_log_buffer_get_window() {
let mut buffer = LogBuffer::new(10);
for i in 1..=10 {
buffer.push(LogEntry::new(
Level::INFO,
"test".to_string(),
format!("message {i}"),
));
}
let window: Vec<_> = buffer
.get_window(0, 3)
.iter()
.map(|e| e.message.as_str())
.collect();
assert_eq!(window, vec!["message 8", "message 9", "message 10"]);
let window: Vec<_> = buffer
.get_window(2, 3)
.iter()
.map(|e| e.message.as_str())
.collect();
assert_eq!(window, vec!["message 6", "message 7", "message 8"]);
}
#[test]
fn test_log_entry_format() {
let entry = LogEntry::new(
Level::ERROR,
"bssh::ssh::client".to_string(),
"Connection failed".to_string(),
);
let short = entry.format_short();
assert!(short.contains("[ERROR]"));
assert!(short.contains("client:"));
assert!(short.contains("Connection failed"));
}
#[test]
fn test_has_new_entries() {
let mut buffer = LogBuffer::new(10);
assert!(!buffer.take_has_new_entries());
buffer.push(LogEntry::new(
Level::INFO,
"test".to_string(),
"message".to_string(),
));
assert!(buffer.take_has_new_entries());
assert!(!buffer.take_has_new_entries()); }
#[test]
fn test_clear() {
let mut buffer = LogBuffer::new(10);
buffer.push(LogEntry::new(
Level::INFO,
"test".to_string(),
"message".to_string(),
));
assert!(!buffer.is_empty());
buffer.clear();
assert!(buffer.is_empty());
}
}