Skip to main content

tale_ndjson/logpatterns/
sourced.rs

1//! SourcedLine is a wrapper for a printable line that keeps track of
2//! where it came from. It's my solution at the moment for keeping track
3//! of the information I need to multiplex log lines while printing from
4//! more than one file at a time, and to keep them roughly sorted.
5//! I might have overengineered this and solved a problem that doesn't
6//! matter, so this might vanish or shrink.
7
8use std::fmt::Display;
9use std::path::PathBuf;
10use std::sync::{LazyLock, RwLock};
11
12use bytes::BytesMut;
13
14use super::{PrettyPrintable, Printable};
15
16/// A wrapper that combines a parsed log line with source file metadata.
17/// This is used for multi-file processing to track which file each line came
18/// from.
19#[derive(Debug, Clone)]
20pub struct SourcedLine<'a> {
21    /// The parsed log line
22    pub parsed: Printable<'a>,
23    /// The source file path
24    pub source_file: PathBuf,
25    /// Line number within the source file (0-based)
26    pub line_number: usize,
27}
28
29impl<'a> SourcedLine<'a> {
30    /// Create a new SourcedLine
31    pub fn new(parsed: Printable<'a>, source_file: PathBuf, line_number: usize) -> Self {
32        Self {
33            parsed,
34            source_file,
35            line_number,
36        }
37    }
38
39    /// Get the source file name (without path) for display
40    pub fn source_file_name(&self) -> &str {
41        self.source_file
42            .file_name()
43            .and_then(|name| name.to_str())
44            .unwrap_or("unknown")
45    }
46
47    /// Extract timestamp from the parsed line, if available
48    pub fn timestamp(&self) -> Option<&jiff::Timestamp> {
49        match &self.parsed {
50            Printable::Canonical(canonical) => Some(&canonical.timestamp),
51            Printable::Java(java) => Some(&java.timestamp),
52            Printable::Message(message) => message.timestamp.as_ref(),
53            Printable::TimeOnly(timestamped) => Some(&timestamped.timestamp),
54            Printable::Json(_) => None,
55            Printable::Logfmt(logfmt) => logfmt.timestamp(),
56            Printable::Text(_) => None,
57        }
58    }
59
60    /// Get the sort key for multi-file chronological ordering
61    pub fn sort_key(&self) -> SortKey {
62        if let Some(ts) = self.timestamp() {
63            // If we have a timestamp, use it for sorting
64            SortKey::Timestamp(*ts)
65        } else {
66            // If no timestamp, sort by file path and line number to maintain file order
67            SortKey::FileOrder {
68                file: self.source_file.clone(),
69                line: self.line_number,
70            }
71        }
72    }
73}
74
75impl<'a> From<(PathBuf, usize, &'a str)> for SourcedLine<'a> {
76    fn from(value: (PathBuf, usize, &'a str)) -> Self {
77        let parsed = match serde_json::from_str::<Printable<'a>>(value.2) {
78            Ok(v) => v,
79            Err(_) => Printable::Text(value.2.to_owned()),
80        };
81        Self {
82            parsed,
83            source_file: value.0,
84            line_number: value.1,
85        }
86    }
87}
88
89/// Key used for sorting multi-file lines chronologically while preserving file
90/// order for non-timestamped lines
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub enum SortKey {
93    /// Sort by extracted timestamp (highest priority)
94    Timestamp(jiff::Timestamp),
95    /// Sort by file path and line number for non-timestamped lines (lower
96    /// priority)
97    FileOrder { file: PathBuf, line: usize },
98}
99
100impl PartialOrd for SortKey {
101    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
102        Some(self.cmp(other))
103    }
104}
105
106impl Ord for SortKey {
107    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
108        use std::cmp::Ordering;
109        match (self, other) {
110            // Timestamp vs Timestamp: chronological order
111            (SortKey::Timestamp(a), SortKey::Timestamp(b)) => a.cmp(b),
112
113            // FileOrder vs FileOrder: file path, then line number
114            (SortKey::FileOrder { file: f1, line: l1 }, SortKey::FileOrder { file: f2, line: l2 }) => {
115                f1.cmp(f2).then(l1.cmp(l2))
116            }
117
118            // Mixed: Need to be smart about this
119            (SortKey::Timestamp(_), SortKey::FileOrder { .. }) => {
120                // Timestamped lines get sorted chronologically
121                // Non-timestamped lines are assumed to be "now" relative to their position
122                // This is tricky - for now, timestamped lines come first
123                Ordering::Less
124            }
125
126            (SortKey::FileOrder { .. }, SortKey::Timestamp(_)) => {
127                // Reverse of above
128                Ordering::Greater
129            }
130        }
131    }
132}
133
134struct FileNameTracker {
135    last: RwLock<String>,
136    every: bool,
137    on_swap: bool,
138}
139
140impl FileNameTracker {
141    pub fn new() -> Self {
142        let cfg = crate::config::config();
143        Self {
144            last: RwLock::new(String::default()),
145            every: cfg.all_file_names,
146            on_swap: !cfg.no_file_names,
147        }
148    }
149}
150
151static TRACKER: LazyLock<FileNameTracker> = LazyLock::new(FileNameTracker::new);
152
153impl<'a> PrettyPrintable for SourcedLine<'a> {
154    fn write(&self, buffer: &mut BytesMut) -> usize {
155        if TRACKER.every {
156            buffer.extend_from_slice(b"==> ");
157            buffer.extend_from_slice(self.source_file_name().as_bytes());
158            buffer.extend_from_slice(b" <==\n");
159        } else if TRACKER.on_swap
160            && self.source_file_name() != *TRACKER.last.read().expect("FileNameTracker lock poisoned")
161        {
162            buffer.extend_from_slice(b"==> ");
163            buffer.extend_from_slice(self.source_file_name().as_bytes());
164            buffer.extend_from_slice(b" <==\n");
165            *TRACKER.last.write().expect("FileNameTracker lock poisoned") = self.source_file_name().to_owned();
166        }
167        self.parsed.write(buffer)
168    }
169    fn cells(&self) -> Vec<String> {
170        self.parsed.cells()
171    }
172}
173
174impl<'a> Display for SourcedLine<'a> {
175    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
176        // For now, just delegate to the wrapped Printable
177        self.parsed.fmt(f)
178    }
179}
180
181#[cfg(test)]
182mod tests {
183    use super::super::*;
184    use super::*;
185
186    // test helper
187    fn extract_msg(item: &SourcedLine<'_>) -> String {
188        match item.parsed {
189            Printable::Canonical(ref canonical) => canonical.message.as_ref().to_owned(),
190            Printable::Java(ref java) => java.message.as_ref().to_owned(),
191            Printable::Message(ref message) => message.message.as_ref().to_owned(),
192            Printable::TimeOnly(ref timestamped) => {
193                // for these tests, the following is true, and it's reasonable to make these
194                // assertions.
195                let obj = timestamped.rest.as_object().expect("rest should be a json object");
196                let message = obj.get("message").expect("there is a message in this bottle yeah-a");
197                message.as_str().unwrap_or_default().to_string()
198            }
199            Printable::Json(ref generic_json) => {
200                // Handle JSON that didn't match other patterns - try to extract message field
201                if let Some(obj) = generic_json.rest.as_object()
202                    && let Some(message) = obj.get("message")
203                {
204                    return message.as_str().unwrap_or_default().to_string();
205                }
206                String::default()
207            }
208            Printable::Logfmt(ref v) => v.message(),
209            Printable::Text(ref v) => v.clone(),
210        }
211    }
212
213    #[test]
214    fn chronological_sort_across_files() {
215        use std::path::PathBuf;
216
217        // Lines from different files with different timestamps
218        let lines = vec![
219            (
220                PathBuf::from("file1.log"),
221                0,
222                r#"{"timestamp": "2025-08-01T10:02:00Z", "message": "file1 line1"}"#.to_string(),
223            ),
224            (
225                PathBuf::from("file2.log"),
226                0,
227                r#"{"timestamp": "2025-08-01T10:01:00Z", "message": "file2 line1"}"#.to_string(),
228            ),
229            (
230                PathBuf::from("file1.log"),
231                1,
232                r#"{"timestamp": "2025-08-01T10:03:00Z", "message": "file1 line2"}"#.to_string(),
233            ),
234            (
235                PathBuf::from("file2.log"),
236                1,
237                r#"{"timestamp": "2025-08-01T10:00:30Z", "message": "file2 line2"}"#.to_string(),
238            ),
239        ];
240
241        let mut sorted: Vec<SourcedLine<'_>> = lines
242            .iter()
243            .map(|xs| {
244                let input = (xs.0.clone(), xs.1 as usize, xs.2.as_str());
245                SourcedLine::from(input)
246            })
247            .collect();
248        sorted.sort_by_key(|xs| xs.sort_key());
249
250        // Should be sorted chronologically regardless of file
251        assert_eq!(extract_msg(&sorted[0]), "file2 line2");
252        assert_eq!(extract_msg(&sorted[1]), "file2 line1");
253        assert_eq!(extract_msg(&sorted[2]), "file1 line1");
254        assert_eq!(extract_msg(&sorted[3]), "file1 line2");
255    }
256
257    #[test]
258    fn sorting_mixed_types() {
259        use std::path::PathBuf;
260
261        // Mix of timestamped and non-timestamped lines
262        let lines = [
263            (
264                PathBuf::from("file1.log"),
265                0,
266                r#"{"message": "no timestamp 1"}"#.to_string(),
267            ),
268            (
269                PathBuf::from("file2.log"),
270                0,
271                r#"{"timestamp": "2025-08-01T10:01:00Z", "message": "timestamped"}"#.to_string(),
272            ),
273            (
274                PathBuf::from("file1.log"),
275                1,
276                r#"{"message": "no timestamp 2"}"#.to_string(),
277            ),
278        ];
279
280        let mut sorted: Vec<SourcedLine<'_>> = lines
281            .iter()
282            .map(|xs| {
283                let input = (xs.0.clone(), xs.1 as usize, xs.2.as_str());
284                SourcedLine::from(input)
285            })
286            .collect();
287        sorted.sort_by_key(|xs| xs.sort_key());
288
289        // Timestamped lines should come first, then non-timestamped in file order
290        assert_eq!(extract_msg(&sorted[0]), "timestamped");
291        assert_eq!(extract_msg(&sorted[1]), "no timestamp 1");
292        assert_eq!(extract_msg(&sorted[2]), "no timestamp 2");
293    }
294
295    #[test]
296    fn sorting_preserves_file_order() {
297        use std::path::PathBuf;
298
299        // Non-timestamped lines from multiple files
300        let lines = vec![
301            (PathBuf::from("b.log"), 1, r#"{"message": "b file line 2"}"#.to_string()),
302            (PathBuf::from("a.log"), 0, r#"{"message": "a file line 1"}"#.to_string()),
303            (PathBuf::from("b.log"), 0, r#"{"message": "b file line 1"}"#.to_string()),
304            (PathBuf::from("a.log"), 1, r#"{"message": "a file line 2"}"#.to_string()),
305        ];
306
307        let mut sorted: Vec<SourcedLine<'_>> = lines
308            .iter()
309            .map(|xs| {
310                let input = (xs.0.clone(), xs.1 as usize, xs.2.as_str());
311                SourcedLine::from(input)
312            })
313            .collect();
314        sorted.sort_by_key(|xs| xs.sort_key());
315
316        // Should be sorted by file path, then line number
317        assert_eq!(extract_msg(&sorted[0]), "a file line 1");
318        assert_eq!(extract_msg(&sorted[1]), "a file line 2");
319        assert_eq!(extract_msg(&sorted[2]), "b file line 1");
320        assert_eq!(extract_msg(&sorted[3]), "b file line 2");
321    }
322
323    #[test]
324    fn sorting_handle_empty_input() {
325        let mut lines: Vec<SourcedLine<'_>> = vec![];
326        lines.sort_by_key(|xs| xs.sort_key());
327        assert_eq!(lines.len(), 0);
328    }
329
330    #[test]
331    fn sorting_handles_invalid_json() {
332        use std::path::PathBuf;
333
334        // Invalid JSON should be treated as non-timestamped
335        let lines = [
336            (PathBuf::from("test.log"), 0, "not json at all".to_string()),
337            (
338                PathBuf::from("test.log"),
339                1,
340                r#"{"timestamp": "2025-08-01T10:01:00Z", "message": "valid"}"#.to_string(),
341            ),
342        ];
343
344        let mut sorted: Vec<SourcedLine<'_>> = lines
345            .iter()
346            .map(|xs| {
347                let input = (xs.0.clone(), xs.1 as usize, xs.2.as_str());
348                SourcedLine::from(input)
349            })
350            .collect();
351        sorted.sort_by_key(|xs| xs.sort_key());
352
353        // Valid timestamped line should come first
354        assert_eq!(extract_msg(&sorted[0]), "valid");
355        assert_eq!(extract_msg(&sorted[1]), "not json at all");
356    }
357}