use std::io::{self, BufRead, BufReader, Write};
use std::path::PathBuf;
use std::process::{Command, Stdio};
use anyhow::{bail, Context};
use clap::Parser;
use regex::bytes::{Regex, RegexBuilder};
use timberfs::grep::{interior_tokens, names_timberfs_source, word_pattern, Entries};
use timberfs::import::Extractor;
use timberfs::note;
const HEAD_SELECT: &str = "Select (every one must hold — repeat to AND)";
const HEAD_ANY: &str = "Alternatives (at least one must hold — repeat to OR)";
const HEAD_EXCLUDE: &str = "Exclude (none may hold)";
const HEAD_WINDOW: &str = "Time window (stores only; handed to the query layer)";
const HEAD_OUTPUT: &str = "Output";
#[derive(Parser)]
#[command(name = "timber-filter", version)]
struct Cli {
files: Vec<PathBuf>,
#[arg(long, value_name = "TEXT", help_heading = HEAD_SELECT)]
has: Vec<String>,
#[arg(long, value_name = "TEXT", help_heading = HEAD_SELECT)]
has_caseless: Vec<String>,
#[arg(long, value_name = "TEXT", help_heading = HEAD_SELECT)]
substring: Vec<String>,
#[arg(long, value_name = "TEXT", help_heading = HEAD_SELECT)]
substring_caseless: Vec<String>,
#[arg(long, value_name = "PATTERN", help_heading = HEAD_SELECT)]
regex: Vec<String>,
#[arg(long, value_name = "TEXT", help_heading = HEAD_ANY)]
any: Vec<String>,
#[arg(long, value_name = "TEXT", help_heading = HEAD_ANY)]
any_caseless: Vec<String>,
#[arg(long, value_name = "TEXT", help_heading = HEAD_EXCLUDE)]
not_has: Vec<String>,
#[arg(long, value_name = "TEXT", help_heading = HEAD_EXCLUDE)]
not_has_caseless: Vec<String>,
#[arg(long, value_name = "TEXT", help_heading = HEAD_EXCLUDE)]
not_substring: Vec<String>,
#[arg(long, value_name = "TEXT", help_heading = HEAD_EXCLUDE)]
not_substring_caseless: Vec<String>,
#[arg(long, value_name = "PATTERN", help_heading = HEAD_EXCLUDE)]
not_regex: Vec<String>,
#[arg(long, value_name = "TIME", help_heading = HEAD_WINDOW)]
from: Option<String>,
#[arg(long, value_name = "TIME", help_heading = HEAD_WINDOW)]
to: Option<String>,
#[arg(short = 'c', long, help_heading = HEAD_OUTPUT)]
count: bool,
#[arg(long, value_name = "N", help_heading = HEAD_OUTPUT)]
max: Option<u64>,
#[arg(short = '0', long = "null", help_heading = HEAD_OUTPUT)]
null_sep: bool,
#[arg(long, help_heading = HEAD_OUTPUT)]
no_filename: bool,
#[arg(long, help_heading = HEAD_OUTPUT, conflicts_with_all = ["count", "null_sep", "no_filename"])]
records: bool,
#[arg(long)]
quiet: bool,
#[arg(long, requires = "timestamp_format")]
timestamp_regex: Option<String>,
#[arg(long, requires = "timestamp_regex")]
timestamp_format: Option<String>,
}
fn main() {
unsafe {
libc::signal(libc::SIGPIPE, libc::SIG_DFL);
}
let cli = Cli::parse();
timberfs::note::set_quiet(cli.quiet);
if let Err(e) = run(cli) {
eprintln!("Error: {e:#}");
std::process::exit(1);
}
}
fn run(cli: Cli) -> anyhow::Result<()> {
let files = cli.files.clone();
for f in &files {
let name = f.display().to_string();
if !names_timberfs_source(&name) && !f.is_file() {
bail!(
"{name}: no such file (positionals are always files; \
predicates are named: --substring {name:?}, --has, --any, --regex, ...)"
);
}
}
let has: Vec<String> = {
let mut seen = std::collections::HashSet::new();
cli.has
.iter()
.filter(|h| seen.insert(h.as_str()))
.cloned()
.collect()
};
let mut select: Vec<Regex> = Vec::new();
for t in &has {
select.push(word_rx(t, false)?);
}
for t in &cli.has_caseless {
select.push(word_rx(t, true)?);
}
for t in &cli.substring {
select.push(sub_rx(t, false)?);
}
for t in &cli.substring_caseless {
select.push(sub_rx(t, true)?);
}
for p in &cli.regex {
select.push(user_rx(p, "--regex")?);
}
let any: Option<Regex> = if cli.any.is_empty() && cli.any_caseless.is_empty() {
None
} else {
let mut branches: Vec<String> = cli.any.iter().map(|w| word_pattern(w)).collect();
branches.extend(
cli.any_caseless
.iter()
.map(|w| format!("(?i:{})", word_pattern(w))),
);
Some(
RegexBuilder::new(&branches.join("|"))
.multi_line(true)
.build()
.with_context(|| "bad --any".to_string())?,
)
};
let mut exclude: Vec<Regex> = Vec::new();
for t in &cli.not_has {
exclude.push(word_rx(t, false)?);
}
for t in &cli.not_has_caseless {
exclude.push(word_rx(t, true)?);
}
for t in &cli.not_substring {
exclude.push(sub_rx(t, false)?);
}
for t in &cli.not_substring_caseless {
exclude.push(sub_rx(t, true)?);
}
for p in &cli.not_regex {
exclude.push(user_rx(p, "--not-regex")?);
}
let preds = Preds {
select,
any,
exclude,
};
let stores: Vec<&PathBuf> = files
.iter()
.filter(|f| names_timberfs_source(&f.display().to_string()))
.collect();
if !stores.is_empty() && stores.len() != files.len() {
bail!(
"mix of timberfs stores and raw files; search them in two \
invocations (selection applies only to stores)"
);
}
let windowed = cli.from.is_some() || cli.to.is_some();
if windowed && stores.is_empty() {
bail!(
"--from/--to select on a timberfs store; this input is already \
a stream (pipe from timberfs query to select first)"
);
}
let mut pushdown: Vec<String> = Vec::new();
if !stores.is_empty() {
for t in &cli.substring {
let toks = interior_tokens(t);
if !toks.is_empty() {
note!(
"timber-filter: --substring {t:?} rides the token index on its \
interior words ({})",
toks.join(", ")
);
pushdown.extend(toks);
}
}
}
let mut any_pushdown: Vec<String> = Vec::new();
if !stores.is_empty() && !cli.any.is_empty() && cli.any_caseless.is_empty() {
note!(
"timber-filter: --any alternative{} ride{} the token index (union of exact branches)",
if cli.any.len() == 1 { "" } else { "s" },
if cli.any.len() == 1 { "s" } else { "" }
);
any_pushdown = cli.any.clone();
}
let mut sink = Sink {
count: cli.count,
null_sep: cli.null_sep,
no_filename: cli.no_filename,
records: cli.records,
started: false,
sources: files.len().max(1),
matched: 0,
filtered: 0,
max: cli.max,
end_extra: String::new(),
out: io::BufWriter::new(io::stdout().lock()),
};
if !stores.is_empty() {
let mut cmd = Command::new(sibling_timberfs());
cmd.args(["query", "--records"]);
if cli.quiet {
cmd.arg("--quiet");
}
if let Some(f) = &cli.from {
cmd.args(["--from", f]);
}
if let Some(t) = &cli.to {
cmd.args(["--to", t]);
}
for h in has.iter().chain(&pushdown) {
cmd.args(["--has", h]);
}
for a in &any_pushdown {
cmd.args(["--any", a]);
}
cmd.args(&files).stdout(Stdio::piped());
let mut child = cmd
.spawn()
.context("spawning timberfs (is it installed next to timber-filter?)")?;
let stdout = BufReader::new(child.stdout.take().expect("piped stdout"));
let note_unselected =
has.is_empty() && pushdown.is_empty() && any_pushdown.is_empty() && !windowed;
grep_records(stdout, &preds, note_unselected, &mut sink)?;
if sink.at_cap() {
let _ = child.kill();
let _ = child.wait();
} else {
let status = child.wait()?;
if !status.success() {
bail!("timberfs query failed");
}
}
} else if files.is_empty() {
let stdin = io::stdin();
let mut reader = BufReader::new(stdin.lock());
let head = reader.fill_buf()?;
if head.starts_with(b"\x1estream-start") {
grep_records(reader, &preds, false, &mut sink)?;
} else {
let extractor = extractor(&cli)?;
grep_raw(reader, extractor, None, &preds, &mut sink)?;
}
} else {
let multi = files.len() > 1;
for f in &files {
let file =
std::fs::File::open(f).with_context(|| format!("opening {}", f.display()))?;
let label = if multi {
Some(f.display().to_string().into_bytes())
} else {
None
};
let extractor = extractor(&cli)?;
grep_raw(BufReader::new(file), extractor, label, &preds, &mut sink)?;
if sink.at_cap() {
break;
}
}
}
sink.finish()
}
fn extractor(cli: &Cli) -> anyhow::Result<Extractor> {
Extractor::new(
cli.timestamp_regex.as_deref(),
cli.timestamp_format.as_deref(),
false,
)
}
fn sibling_timberfs() -> PathBuf {
if let Ok(exe) = std::env::current_exe() {
if let Some(dir) = exe.parent() {
let p = dir.join("timberfs");
if p.is_file() {
return p;
}
}
}
PathBuf::from("timberfs")
}
struct Sink<W: Write> {
count: bool,
null_sep: bool,
no_filename: bool,
records: bool,
started: bool,
sources: usize,
matched: u64,
filtered: u64,
max: Option<u64>,
end_extra: String,
out: W,
}
fn stage_echo() -> String {
let mut args = std::env::args();
let bin = args
.next()
.map(|a| {
std::path::Path::new(&a)
.file_name()
.map(|f| f.to_string_lossy().into_owned())
.unwrap_or(a.clone())
})
.unwrap_or_else(|| "timber-filter".into());
let mut echo = bin;
for a in args {
echo.push(' ');
echo.push_str(&a);
}
echo.replace(['\x1f', '\0'], "?")
}
impl<W: Write> Sink<W> {
fn at_cap(&self) -> bool {
self.max.is_some_and(|m| self.matched >= m)
}
fn stream_start(&mut self, upstream_fields: Option<&[u8]>) -> io::Result<()> {
if !self.records || self.started {
return Ok(());
}
self.started = true;
self.out.write_all(b"\x1estream-start")?;
match upstream_fields {
Some(fields) => self.out.write_all(fields)?,
None => write!(self.out, "\x1fv=1\x1fsources={}", self.sources)?,
}
write!(self.out, "\x1fstage={}", stage_echo())?;
self.out.write_all(b"\0")?;
Ok(())
}
fn passthrough(&mut self, body: &[u8]) -> io::Result<()> {
if !self.records {
return Ok(());
}
self.out.write_all(b"\x1e")?;
self.out.write_all(body)?;
self.out.write_all(b"\0")
}
fn emit_meta(
&mut self,
entry: &[u8],
label: Option<&[u8]>,
meta: Option<&[u8]>,
ts: Option<u64>,
) -> io::Result<()> {
self.matched += 1;
if self.count {
return Ok(());
}
if self.records {
self.stream_start(None)?;
match meta {
Some(body) => {
self.out.write_all(b"\x1e")?;
self.out.write_all(body)?;
}
None => {
write!(self.out, "\x1eentry\x1flen={}", entry.len())?;
if let Some(t) = ts {
write!(self.out, "\x1fts={t}")?;
}
if let Some(l) = label {
self.out.write_all(b"\x1fsrc=")?;
self.out.write_all(l)?;
}
}
}
self.out.write_all(b"\0")?;
self.out.write_all(entry)?;
self.out.write_all(b"\0")?;
return Ok(());
}
let label = if self.no_filename { None } else { label };
if self.null_sep {
if let Some(l) = label {
self.out.write_all(l)?;
self.out.write_all(b":")?;
}
let body = entry.strip_suffix(b"\n").unwrap_or(entry);
self.out.write_all(body)?;
self.out.write_all(b"\0")?;
} else {
for line in entry.split_inclusive(|&b| b == b'\n') {
if let Some(l) = label {
self.out.write_all(l)?;
self.out.write_all(b":")?;
}
self.out.write_all(line)?;
}
if !entry.ends_with(b"\n") {
self.out.write_all(b"\n")?;
}
}
Ok(())
}
fn finish(&mut self) -> anyhow::Result<()> {
if self.count {
writeln!(self.out, "{}", self.matched)?;
}
if self.records {
self.stream_start(None)?;
write!(
self.out,
"\x1estream-end\x1fentries={}\x1fdropped={}{}",
self.matched, self.filtered, self.end_extra
)?;
self.out.write_all(b"\0")?;
}
self.out.flush()?;
Ok(())
}
}
struct Preds {
select: Vec<Regex>,
any: Option<Regex>,
exclude: Vec<Regex>,
}
fn keep(preds: &Preds, entry: &[u8]) -> bool {
preds.select.iter().all(|r| r.is_match(entry))
&& preds.any.as_ref().is_none_or(|r| r.is_match(entry))
&& !preds.exclude.iter().any(|r| r.is_match(entry))
}
fn word_rx(t: &str, caseless: bool) -> anyhow::Result<Regex> {
RegexBuilder::new(&word_pattern(t))
.case_insensitive(caseless)
.multi_line(true)
.build()
.with_context(|| format!("bad phrase {t:?}"))
}
fn sub_rx(t: &str, caseless: bool) -> anyhow::Result<Regex> {
RegexBuilder::new(®ex::escape(t))
.case_insensitive(caseless)
.multi_line(true)
.build()
.with_context(|| format!("bad literal {t:?}"))
}
fn user_rx(p: &str, flag: &str) -> anyhow::Result<Regex> {
RegexBuilder::new(p)
.multi_line(true)
.build()
.with_context(|| format!("bad {flag} pattern {p:?}"))
}
fn grep_raw<R: BufRead, W: Write>(
reader: R,
extractor: Extractor,
label: Option<Vec<u8>>,
preds: &Preds,
sink: &mut Sink<W>,
) -> anyhow::Result<()> {
let mut entries = Entries {
reader,
extractor,
pending: None,
warned_cap: false,
};
while let Some(entry) = entries.next_entry()? {
if keep(preds, &entry) {
let ts = if sink.records {
let head = String::from_utf8_lossy(&entry[..entry.len().min(256)]);
entries.extractor.extract(&head)
} else {
None
};
sink.emit_meta(&entry, label.as_deref(), None, ts)?;
if sink.at_cap() {
break;
}
} else {
sink.filtered += 1;
}
}
Ok(())
}
fn grep_records<R: BufRead, W: Write>(
mut reader: R,
preds: &Preds,
note_unselected: bool,
sink: &mut Sink<W>,
) -> anyhow::Result<()> {
let mut complete = false;
let mut noted = false;
let (mut kept_sum, mut total_sum) = (0u64, 0u64);
let mut hdr: Vec<u8> = Vec::new();
loop {
hdr.clear();
if reader.read_until(0, &mut hdr)? == 0 {
break;
}
if hdr.pop() != Some(0) {
bail!("record stream truncated mid-record");
}
let Some(body) = hdr.strip_prefix(b"\x1e") else {
bail!("malformed record stream: unmarked record (raw text? omit --records upstream)");
};
let kind = body
.split(|&b| b == 0x1f)
.next()
.unwrap_or_default()
.to_vec();
let kv = |key: &[u8]| -> Option<String> {
body.split(|&b| b == 0x1f).skip(1).find_map(|p| {
p.strip_prefix(key)
.and_then(|r| r.strip_prefix(b"="))
.map(|v| String::from_utf8_lossy(v).into_owned())
})
};
match kind.as_slice() {
b"stream-start" => {
let v = kv(b"v").unwrap_or_default();
if v != "1" {
bail!(
"record stream version {v:?} is newer than this timber-filter — upgrade it"
);
}
sink.stream_start(Some(&body["stream-start".len()..]))?;
}
b"source" => {
kept_sum += kv(b"kept").and_then(|v| v.parse().ok()).unwrap_or(0);
total_sum += kv(b"total").and_then(|v| v.parse().ok()).unwrap_or(0);
sink.passthrough(body)?;
}
b"entry" => {
if note_unselected && !noted && total_sum > 64 && kept_sum == total_sum {
note!(
"timber-filter: nothing narrows this search — matching all \
{total_sum} chunks (-w patterns, --has and --from/--to \
ride the indexes)"
);
}
noted = true;
let len: usize = kv(b"len")
.and_then(|v| v.parse().ok())
.context("entry record without len")?;
let mut payload = vec![0u8; len];
reader
.read_exact(&mut payload)
.context("record stream truncated mid-entry (producer died or pipe broke)")?;
let mut nul = [0u8; 1];
reader.read_exact(&mut nul)?;
if nul[0] != 0 {
bail!("record stream framing error: payload not NUL-terminated");
}
if keep(preds, &payload) {
let src = kv(b"src");
sink.emit_meta(
&payload,
src.as_deref().map(str::as_bytes),
Some(body),
None,
)?;
if sink.at_cap() {
break;
}
} else {
sink.filtered += 1;
}
}
b"stream-end" => {
complete = true;
if let (Some(r), Some(t)) = (kv(b"chunks_read"), kv(b"chunks_total")) {
sink.end_extra = format!("\x1fchunks_read={r}\x1fchunks_total={t}");
}
}
_ => {} }
}
if !complete && !sink.at_cap() {
bail!(
"record stream truncated — no stream-end (producer died or pipe \
broke); the result above is incomplete"
);
}
Ok(())
}