use regex::Regex;
use std::collections::{btree_map, BTreeMap, HashMap, VecDeque};
use std::fs::File;
use std::io::{self, BufRead, BufReader, BufWriter, Write};
use std::process::{Child, ChildStdout, Command, Stdio, exit};
use bloomfilter::Bloom;
use clap::Parser;
use serde::{Serialize, Deserialize};
use std::sync::LazyLock;
mod agent_md;
const MAX_STATS_PATTERNS: usize = 100_000;
const MAX_TRACE_SESSIONS: usize = 10_000;
const MAX_LINES_PER_SESSION: usize = 1_000;
const LEAN_MAX_LINES: usize = 2_000;
#[derive(Parser, Debug)]
#[command(author, version, about = "Trim noise from logs for LLM agents")]
struct Args {
file_path: Option<String>,
#[arg(long)]
query: Option<String>,
#[arg(long)]
dedup: bool,
#[arg(long)]
max_lines: Option<usize>,
#[arg(long)]
json: bool,
#[arg(long)]
tail: Option<usize>,
#[arg(long)]
since: Option<String>,
#[arg(long)]
until: Option<String>,
#[arg(long)]
trace: Option<String>,
#[arg(long)]
summary: bool,
#[arg(long)]
stats: bool,
#[arg(long)]
gcloud: Option<String>,
#[arg(long)]
bq: Option<String>,
#[arg(long)]
config: Option<String>,
#[arg(long)]
raw: bool,
#[arg(long)]
agent_md: bool,
}
#[derive(Serialize, Deserialize, Default)]
struct CardConfig {
timestamp_regex: Option<String>,
boilerplate_regex: Option<String>,
default_trace: Option<String>,
}
#[derive(Serialize)]
struct LogEntry {
index: usize,
content: String,
}
fn compile_regex(pattern: &str, source: &str) -> Regex {
Regex::new(pattern).unwrap_or_else(|e| {
eprintln!("Invalid regex {} {:?}: {}", source, pattern, e);
exit(1);
})
}
static FRAC: &str = r"\.[0-9]+";
static ISO_BODY: LazyLock<String> = LazyLock::new(|| {
format!(
r"[0-9]{{4}}-[0-9]{{2}}-[0-9]{{2}}(?:[ T][0-9]{{2}}:[0-9]{{2}}(?::[0-9]{{2}}(?:{FRAC})?)?(?:Z|[+-][0-9]{{2}}:?[0-9]{{2}})?)?"
)
});
static FULL_ISO_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(&format!("^(?:{})$", *ISO_BODY)).unwrap()
});
static TRIM_BODY: LazyLock<String> = LazyLock::new(|| {
format!(
r"[0-9]{{4}}-[0-9]{{2}}-[0-9]{{2}}[ T][0-9]{{2}}:[0-9]{{2}}(?::[0-9]{{2}}(?:{FRAC})?)?(?:Z|[+-][0-9]{{2}}:?[0-9]{{2}})?"
)
});
fn is_full_iso(s: &str) -> bool {
if !FULL_ISO_RE.is_match(s) {
return false;
}
let b = s.as_bytes();
let m = ((b[5] - b'0') as u32) * 10 + (b[6] - b'0') as u32;
let d = ((b[8] - b'0') as u32) * 10 + (b[9] - b'0') as u32;
if !(1..=12).contains(&m) || !(1..=31).contains(&d) {
return false;
}
if b.len() > 10 {
let h = ((b[11] - b'0') as u32) * 10 + (b[12] - b'0') as u32;
if h > 24 {
return false;
}
if h == 24 && !is_eod_24(b) {
return false;
}
let mi = ((b[14] - b'0') as u32) * 10 + (b[15] - b'0') as u32;
if h <= 23 && !(0..=59).contains(&mi) {
return false;
}
if h <= 23 && b.len() >= 19 && b[16] == b':' {
let sec = ((b[17] - b'0') as u32) * 10 + (b[18] - b'0') as u32;
if sec > 59 {
return false;
}
}
}
true
}
fn on_write_err(e: io::Error, what: &str) {
if e.kind() == io::ErrorKind::BrokenPipe {
exit(0);
}
eprintln!("Error {}: {}", what, e);
}
fn emit<W: Write>(w: &mut W, text: &str) {
if let Err(e) = writeln!(w, "{}", text) {
on_write_err(e, "writing output");
exit(1);
}
}
type Sink<'w> = BufWriter<&'w mut io::StdoutLock<'static>>;
fn flush(outbuf: &mut Sink) {
if let Err(e) = outbuf.flush() {
on_write_err(e, "flushing output");
exit(1);
}
}
const MAX_FRAC: usize = 19;
const ZEROS: &str = "0000000000000000000"; const NINES: &str = "9999999999999999999"; const _: () = assert!(ZEROS.len() == MAX_FRAC && NINES.len() == MAX_FRAC);
fn all_digits(b: &[u8]) -> bool {
b.iter().all(|c| c.is_ascii_digit())
}
fn strip_tz(out: &mut String) {
let n = out.len();
if out.ends_with('Z') {
out.truncate(n - 1);
return;
}
if n < 6 {
return;
}
let b = out.as_bytes();
let sign = |i: usize| b[i] == b'+' || b[i] == b'-';
let six = n - 6;
let five = n - 5;
if sign(six) && all_digits(&b[six + 1..n - 3]) && b[n - 3] == b':' && all_digits(&b[n - 2..]) {
out.truncate(six); } else if sign(five) && all_digits(&b[five + 1..]) {
out.truncate(five); }
}
fn is_eod_24(b: &[u8]) -> bool {
b[11] == b'2' && b[12] == b'4' && b[13] == b':'
&& b[14] == b'0' && b[15] == b'0'
&& b.get(16) != Some(&b'.')
&& (b.len() < 19 || b[16] != b':' || (b[17] == b'0' && b[18] == b'0'))
}
fn to_canonical(s: &str, out: &mut String) {
out.clear();
if s.len() > 10 && s.as_bytes()[10] == b' ' {
out.push_str(&s[..10]);
out.push('T');
out.push_str(&s[11..]);
} else {
out.push_str(s);
}
strip_tz(out);
if out.len() == 10 {
out.push_str("T00:00:00");
out.push('.');
out.push_str(ZEROS);
assert_canonical_shape(out);
return;
}
if out.len() >= 16 && is_eod_24(out.as_bytes()) {
let residual = if out.len() > 19 { out.split_off(19) } else { String::new() };
let next = next_day(&out[..10]);
out.clear();
match next {
Some(d) => {
out.push_str(&d);
out.push_str("T00:00:00");
out.push_str(&residual);
}
None => max_instant(out),
}
}
if {
let b = out.as_bytes();
out.len() >= 16 && b[10] == b'T' && b[13] == b':' && b.get(16) != Some(&b':')
} {
assert!(out.len() == 16, "frac-on-minutes drift: {out:?}");
out.insert_str(16, ":00");
}
if let Some(i) = out.find('.') {
let run = out.len() - i - 1;
if run < MAX_FRAC {
out.push_str(&ZEROS[..MAX_FRAC - run]);
} else {
out.truncate(i + 1 + MAX_FRAC);
}
} else {
out.push('.');
out.push_str(ZEROS);
}
assert_canonical_shape(out);
}
fn normalize_iso<'a>(ts: &'a str, buf: &'a mut String) -> &'a str {
to_canonical(ts, buf);
buf.as_str()
}
fn canonical_bound(s: &str, until: bool) -> String {
if until && s.len() == 10 {
let mut out = String::new();
match next_day(s) {
Some(d) => to_canonical(&d, &mut out),
None => max_instant(&mut out),
}
return out;
}
let mut out = String::new();
to_canonical(s, &mut out);
out
}
fn next_day(date: &str) -> Option<String> {
let (mut y, mut m, mut d) = (
date[..4].parse::<u16>().unwrap_or(0),
date[5..7].parse::<u8>().unwrap_or(12),
date[8..10].parse::<u8>().unwrap_or(31),
);
d += 1;
let leap = (y % 4 == 0 && y % 100 != 0) || y % 400 == 0;
let dim: u8 = match m {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
2 if leap => 29,
2 => 28,
_ => 30,
};
if d > dim {
d = 1;
m += 1;
if m > 12 { m = 1; y += 1; }
}
if y > 9999 {
return None; }
Some(format!("{:04}-{:02}-{:02}", y, m, d))
}
fn max_instant(out: &mut String) {
to_canonical("9999-12-31T23:59:59", out); out.replace_range(20.., NINES);
}
enum Run {
Stats,
Sessions,
Stream,
}
enum Session {
Full(Vec<String>),
Summary(usize, String, String),
}
struct RunResult {
stats_map: Option<HashMap<String, usize>>,
sessions: Option<BTreeMap<String, Session>>,
stats_capped: bool,
sessions_capped: bool,
session_lines_capped: bool,
dedup_inserts: usize,
dropped_dups: usize,
window_full: bool,
}
enum Source {
Lines(Box<dyn BufRead>),
Rows(csv::Reader<ChildStdout>),
}
struct Pipeline {
source: Source,
re_timestamp: Regex,
re_boilerplate: Regex,
query: Option<String>,
since_valid: Option<String>,
until_valid: Option<String>,
range_filter: bool,
child_tool: Option<&'static str>,
ts_norm: String,
warned_non_iso: bool,
read_error: Option<io::Error>,
early_cut: bool,
empty_entries: usize,
line_no: usize,
raw_buf: Vec<u8>,
}
impl Pipeline {
fn next_trimmed(&mut self) -> Option<(usize, String)> {
loop {
let line_cow = match &mut self.source {
Source::Lines(r) => {
self.raw_buf.clear();
match r.read_until(b'\n', &mut self.raw_buf) {
Ok(0) => return None,
Ok(_) => {}
Err(e) => {
self.read_error = Some(e);
return None;
}
}
self.line_no += 1;
String::from_utf8_lossy(&self.raw_buf)
}
Source::Rows(r) => match r.byte_records().next() {
Some(Ok(rec)) => {
self.line_no += 1;
let mut line = String::new();
for (i, f) in rec.iter().enumerate() {
if i > 0 { line.push(' '); }
line.push_str(&String::from_utf8_lossy(f));
}
if line.contains(['\n', '\r']) {
line = line.replace("\r\n", " ");
line = line.replace('\n', " ");
}
std::borrow::Cow::Owned(line)
}
None => return None,
Some(Err(e)) => {
self.line_no += 1;
self.read_error = Some(e.into());
return None;
}
},
};
let line = line_cow.trim_end_matches(['\n', '\r']);
if let Some(q) = &self.query {
if !line.contains(q) { continue; }
}
if self.range_filter {
let lead = self.re_timestamp.find_iter(line)
.find(|m| is_full_iso(m.as_str()))
.map(|m| m.as_str());
if let Some(ts) = lead {
let ts = normalize_iso(ts, &mut self.ts_norm);
if let Some(s) = &self.since_valid {
if ts < s.as_str() { continue; }
}
if let Some(u) = &self.until_valid {
if ts >= u.as_str() { continue; }
}
} else if !self.warned_non_iso && self.re_timestamp.is_match(line) {
self.warned_non_iso = true;
eprintln!("Warning: --since/--until needs a leading ISO (YYYY-...) timestamp; lines with non-ISO leading timestamps bypass the range filter.");
}
}
let trimmed = self.re_timestamp.replace_all(line, "");
let trimmed = self.re_boilerplate.replace_all(&trimmed, "");
let final_line = trimmed.trim();
if final_line.is_empty() {
if self.child_tool.is_some() { self.empty_entries += 1; }
continue;
}
return Some((self.line_no, final_line.to_string()));
}
}
}
fn spawn_fetcher(tool: &'static str, argv: Vec<String>) -> (ChildStdout, Child) {
let mut child = Command::new(tool)
.args(&argv)
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.spawn()
.unwrap_or_else(|e| {
eprintln!("Failed to execute {}: {}. Is the {} CLI installed?", tool, e, tool);
exit(1);
});
let stdout: ChildStdout = child.stdout.take().expect("child stdout");
(stdout, child)
}
fn assert_canonical_shape(canonical: &str) {
let b = canonical.as_bytes();
let ok = b.len() == 19 + 1 + MAX_FRAC
&& all_digits(&b[0..4]) && b[4] == b'-'
&& all_digits(&b[5..7]) && b[7] == b'-'
&& all_digits(&b[8..10]) && b[10] == b'T'
&& all_digits(&b[11..13]) && b[13] == b':'
&& all_digits(&b[14..16]) && b[16] == b':'
&& all_digits(&b[17..19]) && b[19] == b'.'
&& all_digits(&b[20..]);
assert!(ok, "canonical shape drift: {canonical:?}");
}
fn until_server_bound(canonical: &str) -> Option<String> {
assert_canonical_shape(canonical);
let mut date = canonical[..10].to_string();
let mut h: u32 = canonical[11..13].parse().expect("canonical hour");
let mut m: u32 = canonical[14..16].parse().expect("canonical minute");
let mut s: u32 = canonical[17..19].parse().expect("canonical second");
s += 1;
if s == 60 { s = 0; m += 1; }
if m == 60 { m = 0; h += 1; }
if h == 24 {
h = 0;
date = next_day(&date)?;
}
Some(format!("{date}T{h:02}:{m:02}:{s:02}"))
}
fn server_ts(canonical: &str) -> &str {
assert_canonical_shape(canonical);
&canonical[..19]
}
fn make_reader(
args: &Args,
since_valid: &Option<String>,
until_valid: &Option<String>,
) -> (Source, Option<(&'static str, Child)>) {
if let Some(filter) = &args.gcloud {
if since_valid.is_none() {
eprintln!("Warning: no --since — this gcloud call streams the WHOLE matching range, oldest first. Add --since unless you truly want everything.");
}
let mut gc_filter = filter.clone();
if let Some(s) = since_valid {
gc_filter.push_str(&format!(" AND timestamp>=\"{}\"", server_ts(s)));
}
if let Some(next) = until_valid.as_ref().and_then(|u| until_server_bound(u)) {
gc_filter.push_str(&format!(" AND timestamp<\"{}\"", next));
}
let gc_args = vec![
"logging".to_string(), "read".to_string(), gc_filter,
"--format".into(), "value(timestamp,textPayload,jsonPayload.message)".into(),
"--order=asc".into(),
];
let (stdout, child) = spawn_fetcher("gcloud", gc_args);
(Source::Lines(Box::new(BufReader::new(stdout))), Some(("gcloud", child)))
} else if let Some(sql) = &args.bq {
let bq_args = vec![
"query".to_string(),
"--use_legacy_sql=false".to_string(),
"--format=csv".to_string(),
"--max_rows=10000000".to_string(),
sql.clone(),
];
let (stdout, child) = spawn_fetcher("bq", bq_args);
let rows = csv::ReaderBuilder::new().flexible(true).from_reader(stdout);
(Source::Rows(rows), Some(("bq", child)))
} else if let Some(path) = &args.file_path {
let file = File::open(path).unwrap_or_else(|e| {
eprintln!("Error opening file {}: {}", path, e);
exit(1);
});
(Source::Lines(Box::new(BufReader::new(file))), None)
} else {
(Source::Lines(Box::new(BufReader::new(io::stdin()))), None)
}
}
fn run_stream(
proc: &mut Pipeline,
mut bloom: Option<&mut Bloom<String>>,
args: &Args,
outbuf: &mut Sink,
) -> (usize, usize, bool) {
let mut dedup_inserts = 0usize;
let mut dropped_dups = 0usize;
let window_n = args.tail.or(args.max_lines);
let mut tail_buf: VecDeque<(usize, String)> =
window_n.map(|n| VecDeque::with_capacity(n)).unwrap_or_default();
let mut window_full = false;
while let Some((line_no, line)) = proc.next_trimmed() {
if let Some(ref mut b) = bloom {
if b.check_and_set(&line) {
dropped_dups += 1;
continue;
}
dedup_inserts += 1;
}
if let Some(n) = window_n {
if tail_buf.len() >= n {
tail_buf.pop_front();
window_full = true;
}
tail_buf.push_back((line_no, line));
continue;
}
if args.json {
let json = serde_json::to_string(&LogEntry {
index: line_no,
content: line,
}).unwrap();
emit(outbuf, &json);
} else {
emit(outbuf, &line);
}
}
for (line_no, line) in tail_buf {
if args.json {
let json = serde_json::to_string(&LogEntry {
index: line_no,
content: line,
}).unwrap();
emit(outbuf, &json);
} else {
emit(outbuf, &line);
}
}
(dedup_inserts, dropped_dups, window_full)
}
fn run_stats(proc: &mut Pipeline) -> (HashMap<String, usize>, bool) {
let mut stats_map: HashMap<String, usize> = HashMap::new();
let mut capped = false;
while let Some((_, line)) = proc.next_trimmed() {
if let Some(c) = stats_map.get_mut(&line) {
*c += 1;
} else if stats_map.len() < MAX_STATS_PATTERNS {
stats_map.insert(line, 1);
} else {
capped = true;
}
}
(stats_map, capped)
}
fn collect_sessions(
proc: &mut Pipeline,
args: &Args,
re: &Regex,
) -> (BTreeMap<String, Session>, bool, bool) {
let summary = args.summary;
let mut sessions: BTreeMap<String, Session> = BTreeMap::new();
let mut count = 0usize;
let mut sessions_capped = false;
let mut lines_capped = false;
while let Some((_, line)) = proc.next_trimmed() {
if let Some(max) = args.max_lines {
if count >= max {
proc.early_cut = true;
break;
}
}
let Some(caps) = re.captures(&line) else { continue; };
let id = caps.get(1)
.unwrap_or_else(|| caps.get(0).unwrap())
.as_str().to_string();
let at_cap = sessions.len() >= MAX_TRACE_SESSIONS;
match sessions.entry(id) {
btree_map::Entry::Occupied(mut e) => match e.get_mut() {
Session::Full(v) => {
if v.len() < MAX_LINES_PER_SESSION {
v.push(line);
count += 1;
} else {
lines_capped = true;
}
}
Session::Summary(n, _, last) => {
*n += 1;
last.clear();
last.push_str(&line);
count += 1;
}
},
btree_map::Entry::Vacant(e) => {
if at_cap {
sessions_capped = true;
} else {
let l = line.to_string();
e.insert(if summary {
Session::Summary(1, l.clone(), l)
} else {
Session::Full(vec![l])
});
count += 1;
}
}
}
}
(sessions, sessions_capped, lines_capped)
}
fn emit_sessions(sessions: &BTreeMap<String, Session>, json: bool, outbuf: &mut Sink) {
if json {
for (id, s) in sessions {
let value = match s {
Session::Full(lines) => serde_json::json!({"id": id, "events": lines}),
Session::Summary(count, first, last) => serde_json::json!({
"id": id, "count": count, "first": first, "last": last
}),
};
let out = serde_json::to_string(&value).unwrap();
emit(outbuf, &out);
}
} else {
let summary = sessions.values().next()
.map_or(false, |s| matches!(s, Session::Summary(..)));
if summary {
emit(outbuf, &format!("\n=== TRACE SUMMARY: {} sessions ===", sessions.len()));
}
for (id, s) in sessions {
match s {
Session::Full(lines) => {
emit(outbuf, &format!("\n--- [{}] ---\n{}", id, lines.join("\n")));
}
Session::Summary(count, first, last) => {
let body = if *count == 1 {
first.to_string()
} else {
format!("{}\n{}", first, last)
};
emit(outbuf, &format!(
"--- [{}] {} event{} ---\n{}", id, count,
if *count == 1 { "" } else { "s" }, body
));
}
}
}
}
}
fn main() {
let args = Args::parse();
if args.agent_md {
print!("{}", agent_md::AGENT_MD);
exit(0);
}
let mut args = args;
let dedup = args.dedup || !args.raw;
if !args.raw {
args.max_lines = args.max_lines.or(Some(LEAN_MAX_LINES));
}
let sources = [
args.gcloud.is_some(),
args.bq.is_some(),
args.file_path.is_some(),
]
.iter()
.filter(|b| **b)
.count();
if sources > 1 {
eprintln!("Error: pass only one source: a file path, --gcloud, or --bq.");
exit(1);
}
for (label, bound) in [("since", &args.since), ("until", &args.until)] {
if let Some(b) = bound {
if !is_full_iso(b) {
eprintln!(
"Error: --{} {:?} is not a full ISO date (YYYY-MM-DD or with time).",
label, b
);
exit(2);
}
}
}
let config = args.config.as_ref().map(|p| {
let s = std::fs::read_to_string(p).unwrap_or_else(|e| {
eprintln!("Error reading config {}: {}", p, e);
exit(1);
});
toml::from_str::<CardConfig>(&s).unwrap_or_else(|e| {
eprintln!("Error parsing config {}: {}", p, e);
exit(1);
})
}).unwrap_or_default();
let default_ts =
format!(r"(?:{})|(?:[0-9]{{2}}/[A-Za-z]{{3}}/[0-9]{{4}}:[0-9]{{2}}:[0-9]{{2}}:[0-9]{{2}})", *TRIM_BODY);
let re_timestamp = compile_regex(
config.timestamp_regex.as_deref().unwrap_or(&default_ts),
"config timestamp_regex",
);
let re_boilerplate = compile_regex(
config.boilerplate_regex.as_deref().unwrap_or(r"(?i)\b(INFO|DEBUG|TRACE)\b"),
"config boilerplate_regex",
);
let trace_re = args.trace.as_ref().or(config.default_trace.as_ref())
.map(|p| {
let source = if args.trace.is_some() { "--trace" } else { "config default_trace" };
compile_regex(p, source)
});
let run = if args.stats {
Run::Stats
} else if trace_re.is_some() {
Run::Sessions
} else {
Run::Stream
};
if args.summary && trace_re.is_none() {
eprintln!("Error: --summary needs an ID pattern (--trace or config default_trace).");
exit(1);
}
if args.stats && trace_re.is_some() {
eprintln!("Note: --stats is survey mode; --trace/--summary ignored.");
}
if args.dedup && trace_re.is_some() {
eprintln!("Warning: --dedup is ignored in trace mode (sessions keep every event).");
}
if args.tail.is_some() && !matches!(run, Run::Stream) {
eprintln!("Note: --tail applies to the line stream; --stats/--trace/--summary ignore it.");
}
let since_valid: Option<String> = args.since.as_ref()
.map(|s| canonical_bound(s, false));
let until_valid: Option<String> = args.until.as_ref()
.map(|u| canonical_bound(u, true));
let range_filter = since_valid.is_some() || until_valid.is_some();
let (reader, fetched_child) = make_reader(&args, &since_valid, &until_valid);
let child_tool = fetched_child.as_ref().map(|(t, _)| *t);
let mut child = fetched_child.map(|(_, c)| c);
let mut proc = Pipeline {
source: reader,
re_timestamp,
re_boilerplate,
query: args.query.clone(),
range_filter,
child_tool,
since_valid,
until_valid,
ts_norm: String::new(),
warned_non_iso: false,
read_error: None,
early_cut: false,
empty_entries: 0,
line_no: 0,
raw_buf: Vec::new(),
};
let mut bloom = if matches!(run, Run::Stream) && dedup {
Some(Bloom::new_for_fp_rate(10_000_000, 0.01).expect("Failed to init bloom filter"))
} else {
None
};
let mut out = io::stdout().lock();
let mut outbuf: Sink = BufWriter::new(&mut out);
let result = match run {
Run::Stats => {
let (map, capped) = run_stats(&mut proc);
RunResult {
stats_map: Some(map),
sessions: None,
stats_capped: capped,
sessions_capped: false,
session_lines_capped: false,
dedup_inserts: 0,
dropped_dups: 0,
window_full: false,
}
}
Run::Sessions => {
let (map, sessions_capped, session_lines_capped) =
collect_sessions(&mut proc, &args, trace_re.as_ref().unwrap());
RunResult {
stats_map: None,
sessions: Some(map),
stats_capped: false,
sessions_capped,
session_lines_capped,
dedup_inserts: 0,
dropped_dups: 0,
window_full: false,
}
}
Run::Stream => {
let (dedup_inserts, dropped_dups, window_full) = run_stream(&mut proc, bloom.as_mut(), &args, &mut outbuf);
RunResult {
stats_map: None,
sessions: None,
stats_capped: false,
sessions_capped: false,
session_lines_capped: false,
dedup_inserts,
dropped_dups,
window_full,
}
}
};
let stopped_at_line = proc.line_no;
let early_cut = proc.early_cut;
let empty_entries = proc.empty_entries;
let read_error = proc.read_error.take();
if let Some(ref e) = read_error {
eprintln!("Warning: stopped after input line {}: {} (results collected so far are emitted)", stopped_at_line, e);
}
drop(proc);
flush(&mut outbuf);
if result.dropped_dups > 0 {
eprintln!(
"Note: dedup dropped {} duplicate-suspect line{} (a few may actually be unique — --raw keeps everything).",
result.dropped_dups,
if result.dropped_dups == 1 { "" } else { "s" }
);
}
if result.window_full && args.tail.is_none() {
eprintln!(
"Note: kept the last {} lines — the most recent; the rest of the input was read and trimmed (--raw removes the cap; --query TEXT narrows before reading).",
args.max_lines.unwrap_or(0)
);
}
if early_cut {
eprintln!("Note: output stopped at the --max-lines cap; the rest of the input was not read (--raw removes the cap).");
}
if result.dedup_inserts > 10_000_000 {
eprintln!("Warning: --dedup exceeded 10M unique lines; bloom filter is saturated and unique lines may be dropped as false duplicates.");
}
if let Some(tool) = child_tool {
if empty_entries > 0 {
eprintln!("Note: {} empty entr{} from {} were skipped.", empty_entries, if empty_entries == 1 { "y" } else { "ies" }, tool);
}
}
if result.stats_capped {
eprintln!("Warning: --stats reached its {} unique-pattern cap; later patterns untracked (existing counts are complete).", MAX_STATS_PATTERNS);
}
if result.sessions_capped {
eprintln!("Warning: trace session cap ({}) reached; later sessions not collected.", MAX_TRACE_SESSIONS);
}
if result.session_lines_capped {
eprintln!("Warning: some sessions hit the {}-line cap; their tails were cut.", MAX_LINES_PER_SESSION);
}
if let Some(map) = result.stats_map {
let mut sorted: Vec<_> = map.into_iter().collect();
let cmp = |a: &(String, usize), b: &(String, usize)| b.1.cmp(&a.1).then(a.0.cmp(&b.0));
if sorted.len() > 20 {
sorted.select_nth_unstable_by(19, cmp);
}
sorted.truncate(20);
sorted.sort_by(cmp);
if args.json {
for (p, c) in &sorted {
emit(&mut outbuf, &serde_json::json!({"pattern": p, "count": c}).to_string());
}
} else {
eprintln!("\n=== LOG PATTERN STATISTICS ===");
for (p, c) in &sorted {
eprintln!("{:>7} | {}", c, p);
}
eprintln!("==============================");
}
}
if let Some(sessions) = result.sessions {
emit_sessions(&sessions, args.json, &mut outbuf);
}
flush(&mut outbuf);
if early_cut {
if let Some(child) = child.as_mut() { let _ = child.kill(); }
}
if let Some(child) = child.as_mut() {
match child.wait() {
Ok(status) if !status.success() => {
if early_cut {
} else {
eprintln!("Error: {} exited with {}", child_tool.unwrap_or("fetcher"), status);
exit(1);
}
}
Ok(_) => {}
Err(e) => eprintln!("Warning: failed to wait for {}: {}", child_tool.unwrap_or("fetcher"), e),
}
}
if read_error.is_some() {
exit(1);
}
}