use std::cmp::Ordering;
use std::fs::File;
use std::io::{Read, Seek, SeekFrom};
use std::path::{Path, PathBuf};
pub const TAIL_BYTES: u64 = 512 * 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum LogLevel {
Error,
Warn,
Info,
Debug,
Trace,
}
impl LogLevel {
pub fn parse(token: &str) -> Option<Self> {
match token {
"ERROR" => Some(LogLevel::Error),
"WARN" => Some(LogLevel::Warn),
"INFO" => Some(LogLevel::Info),
"DEBUG" => Some(LogLevel::Debug),
"TRACE" => Some(LogLevel::Trace),
_ => None,
}
}
pub fn label(self) -> &'static str {
match self {
LogLevel::Error => "ERROR",
LogLevel::Warn => "WARN",
LogLevel::Info => "INFO",
LogLevel::Debug => "DEBUG",
LogLevel::Trace => "TRACE",
}
}
pub fn at_least(self, minimum: LogLevel) -> bool {
matches!(self.cmp(&minimum), Ordering::Less | Ordering::Equal)
}
pub fn all() -> [LogLevel; 5] {
[
LogLevel::Error,
LogLevel::Warn,
LogLevel::Info,
LogLevel::Debug,
LogLevel::Trace,
]
}
}
#[derive(Debug, Clone)]
pub struct LogEntry {
pub timestamp: String,
pub level: LogLevel,
pub target: String,
pub message: String,
pub continuation: Vec<String>,
}
impl LogEntry {
pub fn lines(&self) -> usize {
1 + self.continuation.len()
}
pub fn matches(&self, needle: &str) -> bool {
if needle.is_empty() {
return true;
}
let needle = needle.to_lowercase();
self.target.to_lowercase().contains(&needle)
|| self.message.to_lowercase().contains(&needle)
|| self
.continuation
.iter()
.any(|l| l.to_lowercase().contains(&needle))
}
}
fn split_header(line: &str) -> Option<(&str, LogLevel, &str)> {
let (timestamp, rest) = line.split_once(' ')?;
if !looks_like_timestamp(timestamp) {
return None;
}
let (level_token, rest) = rest.split_once(' ')?;
let level = LogLevel::parse(level_token)?;
Some((timestamp, level, rest))
}
fn looks_like_timestamp(token: &str) -> bool {
let b = token.as_bytes();
b.len() >= 20
&& b[0..4].iter().all(u8::is_ascii_digit)
&& b[4] == b'-'
&& b[5..7].iter().all(u8::is_ascii_digit)
&& b[7] == b'-'
&& b[8..10].iter().all(u8::is_ascii_digit)
&& b[10] == b'T'
}
fn split_target(rest: &str) -> (String, String) {
let rest = match rest.split_once(' ') {
Some((first, tail)) if first.starts_with("ThreadId(") => tail,
_ => rest,
};
let Some((target, after)) = rest.split_once(": ") else {
return (String::new(), rest.trim().to_string());
};
let message = match after.split_once(": ") {
Some((loc, msg)) if loc.contains(".rs:") => msg,
_ => after,
};
(target.to_string(), message.trim().to_string())
}
pub fn parse(text: &str) -> Vec<LogEntry> {
let mut entries: Vec<LogEntry> = Vec::new();
for line in text.lines() {
match split_header(line) {
Some((timestamp, level, rest)) => {
let (target, message) = split_target(rest);
entries.push(LogEntry {
timestamp: timestamp.to_string(),
level,
target,
message,
continuation: Vec::new(),
});
}
None => {
if let Some(last) = entries.last_mut() {
last.continuation.push(line.to_string());
}
}
}
}
entries
}
pub fn tail_entries(path: &Path, bytes: u64) -> std::io::Result<Vec<LogEntry>> {
let mut file = File::open(path)?;
let len = file.metadata()?.len();
let from_start = len <= bytes;
let start = len.saturating_sub(bytes);
file.seek(SeekFrom::Start(start))?;
let mut buf = Vec::with_capacity(bytes.min(len) as usize);
file.take(bytes).read_to_end(&mut buf)?;
let text = String::from_utf8_lossy(&buf);
let text = if from_start {
text.as_ref()
} else {
match text.find('\n') {
Some(i) => &text[i + 1..],
None => "",
}
};
Ok(parse(text))
}
pub fn available_logs(dir: &Path) -> Vec<PathBuf> {
let Ok(entries) = std::fs::read_dir(dir) else {
return Vec::new();
};
let mut files: Vec<PathBuf> = entries
.filter_map(Result::ok)
.map(|e| e.path())
.filter(|p| {
p.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with("opencrabs."))
})
.collect();
files.sort();
files
}
pub fn log_dir() -> PathBuf {
crate::config::opencrabs_home().join("logs")
}