Skip to main content

tale_ndjson/
lib.rs

1//! Tale - A high-performance log pretty-printer for ndjson files
2//!
3//! This library provides utilities for reading, parsing, and formatting
4//! newline-delimited JSON log files with memory-efficient processing
5//! and adaptive chunking strategies.
6
7use std::io::{self, Write};
8
9use bytes::{Buf, BytesMut};
10use logpatterns::*;
11use miette::{IntoDiagnostic, Result};
12
13pub mod config;
14pub mod defaults;
15pub mod errors;
16pub mod json_profiler;
17pub mod logpatterns;
18pub mod memory_budget;
19pub mod metrics;
20pub mod multiplexed;
21pub mod readers;
22
23#[cfg(test)]
24mod tests;
25
26// Re-export commonly used types for convenience
27use clap::Parser;
28use clap::builder::Styles;
29use clap::builder::styling::AnsiColor;
30pub use errors::TaleError;
31pub use memory_budget::{MemoryBudget, MemoryPressure};
32pub use readers::strategies::{IsStrategy, Strategy};
33pub use readers::{ChunkedFileReader, FileProcessor};
34
35#[derive(Debug, Clone, Parser, Default)]
36#[clap(name="tale", version, styles = v3_styles(), max_term_width = 100)]
37/// A tail-compatible tool for pretty-printing ndjson files, especially logs.
38///
39/// Tale displays the colorfully-formatted contents of FILE, by default stdin,
40/// to stdout. It highlights the fields likely to appear in log lines for
41/// servers, such as level or severity, the log message, timestamps, and so
42/// on. It also displays every field that shows up in the log line, using
43/// the color theme you have set in your terminal.
44///
45/// Lines that are invalid json are printed intact, without formatting.
46///
47/// Tale can also follow and display more than one file at a time, with
48/// header decoration options like `tail`'s.
49pub struct Args {
50    /// Follow the file, continuing to watch for more data to arrive.
51    #[arg(short, long)]
52    pub follow: bool,
53    /// Follow the file, also checking to see if has been renamed or has an new
54    /// inode number. If the file does not exist yet, wait and display it
55    /// from the beginning if and when it is created.
56    #[arg(short = 'F', long)]
57    pub sticky: bool,
58    /// Start tailing the input offset by ±N blocks.
59    #[arg(short, long, group = "units")]
60    pub blocks: Option<i64>,
61    /// Start tailing the input offset by ±N bytes; e.g., to skip garbage.
62    #[arg(short = 'c', long, group = "units")]
63    pub bytes: Option<i64>,
64    /// Start tailing the input offset by ±N lines.
65    #[arg(short = 'n', long, group = "units")]
66    pub offset: Option<i64>,
67    /// When following more than one file, show a header with the file name
68    /// along with every line from that file.
69    #[arg(short, long)]
70    pub verbose: bool,
71    /// Do not ever show file name headers when following more than one file.
72    #[arg(short, long, conflicts_with = "verbose")]
73    pub quiet: bool,
74
75    // these options are unique to `tale`
76    /// Show timestamps, which are hidden by default.
77    #[arg(short, long)]
78    pub timestamps: bool,
79    /// Batch window size for multi-file tailing (in milliseconds).
80    #[arg(long, default_value = "250")]
81    pub window: u64,
82
83    // TODO These are options we should consider making dev-only.
84    /// Force use of chunked file processing for better memory efficiency on
85    /// large files.
86    #[arg(long)]
87    pub chunked: bool,
88    /// Disable chunked file processing and always use streaming (might use more
89    /// memory).
90    #[arg(long, conflicts_with = "chunked")]
91    pub no_chunked: bool,
92    /// Disable adaptive chunking
93    #[arg(short, long)]
94    pub adaptive: bool,
95    /// Set a limit on how much memory can be used in file buffers
96    #[arg(short, long)]
97    pub max_memory: Option<usize>,
98    #[cfg(debug_assertions)]
99    #[arg(long, hide = true)]
100    pub conservative: bool,
101    #[cfg(debug_assertions)]
102    /// Choose a specific chunk strategy for testing
103    #[arg(short = 's', long)]
104    pub chunk_strategy: Option<Strategy>,
105    /// Print JSON parsing profile report after processing (debug builds only)
106    #[cfg(debug_assertions)]
107    #[arg(long, hide = true)]
108    pub profile_json: bool,
109
110    /// (offset) [file ...] where offset can be +N, -N, or N.
111    #[arg(allow_hyphen_values = true)]
112    pub args: Vec<String>,
113}
114
115/// I like my clap help styled the old way.
116fn v3_styles() -> Styles {
117    Styles::styled()
118        .header(AnsiColor::Yellow.on_default())
119        .usage(AnsiColor::Green.on_default())
120        .literal(AnsiColor::Green.on_default())
121        .placeholder(AnsiColor::Green.on_default())
122}
123
124/// Process a single line of input (JSON or plain text) and write to output.
125#[inline]
126pub fn process_line(line: &str, buffer: &mut BytesMut, outlock: &mut io::StdoutLock<'_>) -> Result<()> {
127    match serde_json::from_str::<Printable<'_>>(line) {
128        Ok(message) => {
129            // Profile which variant was parsed (debug builds only for minimal overhead)
130            #[cfg(debug_assertions)]
131            json_profiler::record_variant(&message);
132
133            message.write(buffer);
134            outlock.write_all(buffer.chunk()).into_diagnostic()?;
135            outlock.write_all(&[0x0a; 1]).into_diagnostic()?; // blank line
136            buffer.clear();
137        }
138        Err(_) => {
139            // Profile parse failures (debug builds only)
140            #[cfg(debug_assertions)]
141            json_profiler::record_parse_error();
142
143            outlock.write_all(line.as_bytes()).into_diagnostic()?;
144            outlock.write_all(b"\n").into_diagnostic()?;
145        }
146    }
147    Ok(())
148}
149
150/// Strip trailing newline(s) from the string input, handling Windows line
151/// endings as well.
152#[inline]
153pub fn strip_line_ending(line: &mut String) {
154    if line.ends_with('\n') {
155        line.pop();
156        if line.ends_with('\r') {
157            // Windows line endings are not handled well by Rust's line
158            // iterators, but we might as well try.
159            line.pop();
160        }
161    }
162}
163
164#[cfg(test)]
165mod cli_tests {
166    use super::*;
167
168    #[test]
169    fn verify_cli() {
170        use clap::CommandFactory;
171        Args::command().debug_assert();
172    }
173
174    #[test]
175    fn offset_unit_args() {
176        use crate::Args;
177        use crate::config::{ConfigOpts, OffsetUnit};
178
179        // Test bytes offset detection - test the config struct directly
180        let args = Args {
181            bytes: Some(100),
182            args: vec!["test.log".to_string()],
183            ..Default::default()
184        };
185        let config = ConfigOpts::new(&args).expect("Config should be valid for test");
186        assert!(matches!(config.offset_unit, OffsetUnit::Bytes));
187        assert_eq!(config.offset, 100);
188
189        // Test blocks offset detection
190        let args = Args {
191            blocks: Some(2),
192            args: vec!["test.log".to_string()],
193            ..Default::default()
194        };
195        let config = ConfigOpts::new(&args).expect("Config should be valid for test");
196        assert!(matches!(config.offset_unit, OffsetUnit::Blocks));
197        assert_eq!(config.offset, 2);
198
199        // Test lines offset detection (default)
200        let args = Args {
201            offset: Some(5),
202            args: vec!["test.log".to_string()],
203            ..Default::default()
204        };
205        let config = ConfigOpts::new(&args).expect("Config should be valid for test");
206        assert!(matches!(config.offset_unit, OffsetUnit::Lines));
207        assert_eq!(config.offset, 5);
208    }
209
210    #[test]
211    fn can_run_cli_and_emit_help() {
212        let output = std::process::Command::new("cargo")
213            .args(["run", "--", "--help"])
214            .output()
215            .expect("failed to execute");
216
217        assert!(output.status.success());
218        // Verify output is correct
219    }
220}