use std::fmt::Display;
use std::path::PathBuf;
use std::sync::{LazyLock, RwLock};
use bytes::BytesMut;
use super::{PrettyPrintable, Printable};
#[derive(Debug, Clone)]
pub struct SourcedLine<'a> {
pub parsed: Printable<'a>,
pub source_file: PathBuf,
pub line_number: usize,
}
impl<'a> SourcedLine<'a> {
pub fn new(parsed: Printable<'a>, source_file: PathBuf, line_number: usize) -> Self {
Self {
parsed,
source_file,
line_number,
}
}
pub fn source_file_name(&self) -> &str {
self.source_file
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("unknown")
}
pub fn timestamp(&self) -> Option<&jiff::Timestamp> {
match &self.parsed {
Printable::Canonical(canonical) => Some(&canonical.timestamp),
Printable::Java(java) => Some(&java.timestamp),
Printable::Message(message) => message.timestamp.as_ref(),
Printable::TimeOnly(timestamped) => Some(×tamped.timestamp),
Printable::Json(_) => None,
Printable::Logfmt(logfmt) => logfmt.timestamp(),
Printable::Text(_) => None,
}
}
pub fn sort_key(&self) -> SortKey {
if let Some(ts) = self.timestamp() {
SortKey::Timestamp(*ts)
} else {
SortKey::FileOrder {
file: self.source_file.clone(),
line: self.line_number,
}
}
}
}
impl<'a> From<(PathBuf, usize, &'a str)> for SourcedLine<'a> {
fn from(value: (PathBuf, usize, &'a str)) -> Self {
let parsed = match serde_json::from_str::<Printable<'a>>(value.2) {
Ok(v) => v,
Err(_) => Printable::Text(value.2.to_owned()),
};
Self {
parsed,
source_file: value.0,
line_number: value.1,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SortKey {
Timestamp(jiff::Timestamp),
FileOrder { file: PathBuf, line: usize },
}
impl PartialOrd for SortKey {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for SortKey {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
use std::cmp::Ordering;
match (self, other) {
(SortKey::Timestamp(a), SortKey::Timestamp(b)) => a.cmp(b),
(SortKey::FileOrder { file: f1, line: l1 }, SortKey::FileOrder { file: f2, line: l2 }) => {
f1.cmp(f2).then(l1.cmp(l2))
}
(SortKey::Timestamp(_), SortKey::FileOrder { .. }) => {
Ordering::Less
}
(SortKey::FileOrder { .. }, SortKey::Timestamp(_)) => {
Ordering::Greater
}
}
}
}
struct FileNameTracker {
last: RwLock<String>,
every: bool,
on_swap: bool,
}
impl FileNameTracker {
pub fn new() -> Self {
let cfg = crate::config::config();
Self {
last: RwLock::new(String::default()),
every: cfg.all_file_names,
on_swap: !cfg.no_file_names,
}
}
}
static TRACKER: LazyLock<FileNameTracker> = LazyLock::new(FileNameTracker::new);
impl<'a> PrettyPrintable for SourcedLine<'a> {
fn write(&self, buffer: &mut BytesMut) -> usize {
if TRACKER.every {
buffer.extend_from_slice(b"==> ");
buffer.extend_from_slice(self.source_file_name().as_bytes());
buffer.extend_from_slice(b" <==\n");
} else if TRACKER.on_swap
&& self.source_file_name() != *TRACKER.last.read().expect("FileNameTracker lock poisoned")
{
buffer.extend_from_slice(b"==> ");
buffer.extend_from_slice(self.source_file_name().as_bytes());
buffer.extend_from_slice(b" <==\n");
*TRACKER.last.write().expect("FileNameTracker lock poisoned") = self.source_file_name().to_owned();
}
self.parsed.write(buffer)
}
fn cells(&self) -> Vec<String> {
self.parsed.cells()
}
}
impl<'a> Display for SourcedLine<'a> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.parsed.fmt(f)
}
}
#[cfg(test)]
mod tests {
use super::super::*;
use super::*;
fn extract_msg(item: &SourcedLine<'_>) -> String {
match item.parsed {
Printable::Canonical(ref canonical) => canonical.message.as_ref().to_owned(),
Printable::Java(ref java) => java.message.as_ref().to_owned(),
Printable::Message(ref message) => message.message.as_ref().to_owned(),
Printable::TimeOnly(ref timestamped) => {
let obj = timestamped.rest.as_object().expect("rest should be a json object");
let message = obj.get("message").expect("there is a message in this bottle yeah-a");
message.as_str().unwrap_or_default().to_string()
}
Printable::Json(ref generic_json) => {
if let Some(obj) = generic_json.rest.as_object()
&& let Some(message) = obj.get("message")
{
return message.as_str().unwrap_or_default().to_string();
}
String::default()
}
Printable::Logfmt(ref v) => v.message(),
Printable::Text(ref v) => v.clone(),
}
}
#[test]
fn chronological_sort_across_files() {
use std::path::PathBuf;
let lines = vec![
(
PathBuf::from("file1.log"),
0,
r#"{"timestamp": "2025-08-01T10:02:00Z", "message": "file1 line1"}"#.to_string(),
),
(
PathBuf::from("file2.log"),
0,
r#"{"timestamp": "2025-08-01T10:01:00Z", "message": "file2 line1"}"#.to_string(),
),
(
PathBuf::from("file1.log"),
1,
r#"{"timestamp": "2025-08-01T10:03:00Z", "message": "file1 line2"}"#.to_string(),
),
(
PathBuf::from("file2.log"),
1,
r#"{"timestamp": "2025-08-01T10:00:30Z", "message": "file2 line2"}"#.to_string(),
),
];
let mut sorted: Vec<SourcedLine<'_>> = lines
.iter()
.map(|xs| {
let input = (xs.0.clone(), xs.1 as usize, xs.2.as_str());
SourcedLine::from(input)
})
.collect();
sorted.sort_by_key(|xs| xs.sort_key());
assert_eq!(extract_msg(&sorted[0]), "file2 line2");
assert_eq!(extract_msg(&sorted[1]), "file2 line1");
assert_eq!(extract_msg(&sorted[2]), "file1 line1");
assert_eq!(extract_msg(&sorted[3]), "file1 line2");
}
#[test]
fn sorting_mixed_types() {
use std::path::PathBuf;
let lines = [
(
PathBuf::from("file1.log"),
0,
r#"{"message": "no timestamp 1"}"#.to_string(),
),
(
PathBuf::from("file2.log"),
0,
r#"{"timestamp": "2025-08-01T10:01:00Z", "message": "timestamped"}"#.to_string(),
),
(
PathBuf::from("file1.log"),
1,
r#"{"message": "no timestamp 2"}"#.to_string(),
),
];
let mut sorted: Vec<SourcedLine<'_>> = lines
.iter()
.map(|xs| {
let input = (xs.0.clone(), xs.1 as usize, xs.2.as_str());
SourcedLine::from(input)
})
.collect();
sorted.sort_by_key(|xs| xs.sort_key());
assert_eq!(extract_msg(&sorted[0]), "timestamped");
assert_eq!(extract_msg(&sorted[1]), "no timestamp 1");
assert_eq!(extract_msg(&sorted[2]), "no timestamp 2");
}
#[test]
fn sorting_preserves_file_order() {
use std::path::PathBuf;
let lines = vec![
(PathBuf::from("b.log"), 1, r#"{"message": "b file line 2"}"#.to_string()),
(PathBuf::from("a.log"), 0, r#"{"message": "a file line 1"}"#.to_string()),
(PathBuf::from("b.log"), 0, r#"{"message": "b file line 1"}"#.to_string()),
(PathBuf::from("a.log"), 1, r#"{"message": "a file line 2"}"#.to_string()),
];
let mut sorted: Vec<SourcedLine<'_>> = lines
.iter()
.map(|xs| {
let input = (xs.0.clone(), xs.1 as usize, xs.2.as_str());
SourcedLine::from(input)
})
.collect();
sorted.sort_by_key(|xs| xs.sort_key());
assert_eq!(extract_msg(&sorted[0]), "a file line 1");
assert_eq!(extract_msg(&sorted[1]), "a file line 2");
assert_eq!(extract_msg(&sorted[2]), "b file line 1");
assert_eq!(extract_msg(&sorted[3]), "b file line 2");
}
#[test]
fn sorting_handle_empty_input() {
let mut lines: Vec<SourcedLine<'_>> = vec![];
lines.sort_by_key(|xs| xs.sort_key());
assert_eq!(lines.len(), 0);
}
#[test]
fn sorting_handles_invalid_json() {
use std::path::PathBuf;
let lines = [
(PathBuf::from("test.log"), 0, "not json at all".to_string()),
(
PathBuf::from("test.log"),
1,
r#"{"timestamp": "2025-08-01T10:01:00Z", "message": "valid"}"#.to_string(),
),
];
let mut sorted: Vec<SourcedLine<'_>> = lines
.iter()
.map(|xs| {
let input = (xs.0.clone(), xs.1 as usize, xs.2.as_str());
SourcedLine::from(input)
})
.collect();
sorted.sort_by_key(|xs| xs.sort_key());
assert_eq!(extract_msg(&sorted[0]), "valid");
assert_eq!(extract_msg(&sorted[1]), "not json at all");
}
}