You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1100 lines
34 KiB
Rust

//! Filter query language for session filtering.
//!
//! Implements the filter query syntax described in `specs/FILTER.md`.
//!
//! # Syntax
//!
//! - `key:value` — case-insensitive substring match
//! - `key:*` — field is non-empty (any value)
//! - `key>value` — greater-than comparison (numeric / date fields)
//! - `key<value` — less-than comparison (numeric / date fields)
//! - `expr AND expr` — logical AND (binds tighter than OR)
//! - `expr OR expr` — logical OR
//!
//! # Supported keys
//!
//! | Key | Type | Operations |
//! |------------|---------|--------------------|
//! | `model` | string | `:` (substring) |
//! | `project` | string | `:` (substring) |
//! | `id` | string | `:` (substring) |
//! | `agents` | numeric | `:`, `>`, `<` |
//! | `messages` | numeric | `:`, `>`, `<` |
//! | `in` | numeric | `:`, `>`, `<` |
//! | `out` | numeric | `:`, `>`, `<` |
//! | `tokens` | numeric | `:`, `>`, `<` |
//! | `date` | date | `:`, `>`, `<` |
use chrono::NaiveDate;
use crate::error::{AppError, Result};
// ---------------------------------------------------------------------------
// Public data type consumed by Filter::matches
// ---------------------------------------------------------------------------
/// A view of a session row that a [`Filter`] can be evaluated against.
///
/// Implement this trait for any type that represents a session list row.
/// A blanket implementation is provided for [`crate::tui::state::SessionListItem`].
pub trait SessionRow {
/// Model identifier string (e.g. `"claude-haiku-4-5"`).
fn model(&self) -> &str;
/// Project path string.
fn project(&self) -> &str;
/// Session ID (full UUID or short prefix).
fn id(&self) -> &str;
/// Number of sub-agent runs attached to this session.
fn agents(&self) -> u64;
/// Total message count (all roles).
fn messages(&self) -> u64;
/// Input (prompt) token count.
///
/// Returns `None` when the token count is not available for this row type.
fn input_tokens(&self) -> Option<u64>;
/// Output (completion) token count.
///
/// Returns `None` when the token count is not available for this row type.
fn output_tokens(&self) -> Option<u64>;
/// Total token count (input + output).
///
/// Returns `None` when token counts are not available for this row type.
fn tokens(&self) -> Option<u64>;
/// Session date.
///
/// Returns `None` when the date cannot be parsed from the row.
fn date(&self) -> Option<NaiveDate>;
}
impl SessionRow for crate::tui::state::SessionListItem {
fn model(&self) -> &str {
&self.model
}
fn project(&self) -> &str {
&self.project
}
fn id(&self) -> &str {
&self.full_id
}
fn agents(&self) -> u64 {
self.agent_count as u64
}
fn messages(&self) -> u64 {
self.msg_count as u64
}
fn input_tokens(&self) -> Option<u64> {
Some(self.input_tokens)
}
fn output_tokens(&self) -> Option<u64> {
Some(self.output_tokens)
}
fn tokens(&self) -> Option<u64> {
Some(self.input_tokens + self.output_tokens)
}
fn date(&self) -> Option<NaiveDate> {
// `date` field is e.g. "2026-03-30 14:22:01"; parse just the date portion.
self.date
.get(..10)
.and_then(|s| NaiveDate::parse_from_str(s, "%Y-%m-%d").ok())
}
}
// ---------------------------------------------------------------------------
// Internal AST
// ---------------------------------------------------------------------------
/// A comparison operator.
#[derive(Debug, Clone, PartialEq, Eq)]
enum Op {
/// `:` — substring match (strings) or equality (numeric/date).
Colon,
/// `>` — greater-than.
Gt,
/// `<` — less-than.
Lt,
}
/// A supported filter key.
#[derive(Debug, Clone, PartialEq, Eq)]
enum Key {
Model,
Project,
Id,
Agents,
Messages,
/// Input (prompt) token count — `in:`, `in>`, `in<`.
In,
/// Output (completion) token count — `out:`, `out>`, `out<`.
Out,
/// Total token count (input + output) — `tokens:`, `tokens>`, `tokens<`.
Tokens,
Date,
}
impl Key {
fn parse(s: &str) -> Option<Self> {
match s {
"model" => Some(Key::Model),
"project" => Some(Key::Project),
"id" => Some(Key::Id),
"agents" => Some(Key::Agents),
"messages" | "msgs" => Some(Key::Messages),
"in" => Some(Key::In),
"out" => Some(Key::Out),
"tokens" => Some(Key::Tokens),
"date" => Some(Key::Date),
_ => None,
}
}
}
/// A single key-op-value predicate node.
#[derive(Debug, Clone, PartialEq)]
struct Predicate {
key: Key,
op: Op,
value: String,
}
/// Filter AST node — either a leaf predicate or a logical combination.
#[derive(Debug, Clone, PartialEq)]
enum Expr {
Pred(Predicate),
And(Box<Expr>, Box<Expr>),
Or(Box<Expr>, Box<Expr>),
}
// ---------------------------------------------------------------------------
// Tokenizer
// ---------------------------------------------------------------------------
/// Tokens produced by the lexer.
#[derive(Debug, Clone, PartialEq)]
enum Token {
/// A bare word (key name or value) with no operators embedded.
Word(String),
/// `AND` keyword.
And,
/// `OR` keyword.
Or,
/// A complete `key:value`, `key>value`, or `key<value` atom (as a raw string).
Atom(String),
}
/// Lex `input` into a sequence of [`Token`]s.
///
/// Tokenization rules:
/// - Split on whitespace.
/// - Tokens that contain `:`, `>`, or `<` are emitted as `Token::Atom`.
/// - `AND` / `OR` (exact case) are emitted as the corresponding keyword tokens.
/// - Everything else is a `Token::Word`.
fn tokenize(input: &str) -> Vec<Token> {
input
.split_whitespace()
.map(|chunk| match chunk {
"AND" => Token::And,
"OR" => Token::Or,
s if s.contains(':') || s.contains('>') || s.contains('<') => {
Token::Atom(s.to_string())
}
s => Token::Word(s.to_string()),
})
.collect()
}
// ---------------------------------------------------------------------------
// Atom parser
// ---------------------------------------------------------------------------
/// Parse a raw atom string (e.g. `"model:haiku"`) into a [`Predicate`].
fn parse_atom(atom: &str) -> Result<Predicate> {
// Find the first occurrence of `:`, `>`, or `<`.
let (op_pos, op) = atom
.char_indices()
.find_map(|(i, c)| match c {
':' => Some((i, Op::Colon)),
'>' => Some((i, Op::Gt)),
'<' => Some((i, Op::Lt)),
_ => None,
})
.ok_or_else(|| AppError::Parse(format!("expected operator in '{atom}'")))?;
let key_str = &atom[..op_pos];
let value_str = &atom[op_pos + 1..];
if key_str.is_empty() {
return Err(AppError::Parse(format!(
"missing key before operator in '{atom}'"
)));
}
let key = Key::parse(key_str).ok_or_else(|| {
AppError::Parse(format!(
"unknown filter key '{key_str}' — valid keys: model, project, id, agents, messages, msgs, in, out, tokens, date"
))
})?;
// For string keys (model/project/id) only `:` is allowed.
if matches!(key, Key::Model | Key::Project | Key::Id) && !matches!(op, Op::Colon) {
return Err(AppError::Parse(format!(
"key '{key_str}' only supports ':' operator"
)));
}
Ok(Predicate {
key,
op,
value: value_str.to_string(),
})
}
// ---------------------------------------------------------------------------
// Recursive-descent expression parser
// ---------------------------------------------------------------------------
//
// Grammar (precedence: AND binds tighter than OR):
//
// expr = or_expr
// or_expr = and_expr ( OR and_expr )*
// and_expr = primary ( AND primary )*
// primary = Atom
//
// A `primary` is any token that is *not* `AND` or `OR` (i.e. an atom).
struct Parser {
tokens: Vec<Token>,
pos: usize,
}
impl Parser {
fn new(tokens: Vec<Token>) -> Self {
Parser { tokens, pos: 0 }
}
fn peek(&self) -> Option<&Token> {
self.tokens.get(self.pos)
}
fn advance(&mut self) -> Option<Token> {
let tok = self.tokens.get(self.pos).cloned();
self.pos += 1;
tok
}
/// Parse an `or_expr`.
fn parse_or(&mut self) -> Result<Expr> {
let mut left = self.parse_and()?;
while self.peek() == Some(&Token::Or) {
self.advance(); // consume OR
let right = self.parse_and()?;
left = Expr::Or(Box::new(left), Box::new(right));
}
Ok(left)
}
/// Parse an `and_expr`.
fn parse_and(&mut self) -> Result<Expr> {
let mut left = self.parse_primary()?;
while self.peek() == Some(&Token::And) {
self.advance(); // consume AND
let right = self.parse_primary()?;
left = Expr::And(Box::new(left), Box::new(right));
}
Ok(left)
}
/// Parse a primary (atom).
fn parse_primary(&mut self) -> Result<Expr> {
match self.advance() {
Some(Token::Atom(s)) => Ok(Expr::Pred(parse_atom(&s)?)),
Some(Token::Word(w)) => {
// A bare word without operator is a syntax error.
Err(AppError::Parse(format!(
"unexpected token '{w}' — expected a filter expression like 'key:value'"
)))
}
Some(Token::And) => Err(AppError::Parse(
"unexpected 'AND' — expected a filter expression".to_string(),
)),
Some(Token::Or) => Err(AppError::Parse(
"unexpected 'OR' — expected a filter expression".to_string(),
)),
None => Err(AppError::Parse(
"unexpected end of filter — expected a filter expression".to_string(),
)),
}
}
fn parse(mut self) -> Result<Expr> {
if self.tokens.is_empty() {
return Err(AppError::Parse("filter expression is empty".to_string()));
}
let expr = self.parse_or()?;
// If there are leftover tokens that were not consumed, that is a syntax error.
if self.pos < self.tokens.len() {
let leftover: Vec<_> = self.tokens[self.pos..]
.iter()
.map(|t| match t {
Token::Atom(s) | Token::Word(s) => s.clone(),
Token::And => "AND".to_string(),
Token::Or => "OR".to_string(),
})
.collect();
return Err(AppError::Parse(format!(
"unexpected tokens after expression: {}",
leftover.join(" ")
)));
}
Ok(expr)
}
}
// ---------------------------------------------------------------------------
// Filter — public API
// ---------------------------------------------------------------------------
/// A parsed filter expression that can be evaluated against session rows.
///
/// # Examples
///
/// ```
/// # use claudbg::filter::Filter;
/// let f = Filter::parse("model:haiku AND messages>10").unwrap();
/// ```
#[derive(Debug, Clone)]
pub struct Filter {
expr: Expr,
/// The original query string, preserved for display / debugging.
pub raw: String,
}
impl Filter {
/// Parse a filter query string into a [`Filter`].
///
/// Returns an [`crate::error::AppError::Parse`] error with a user-readable
/// message if the query is malformed or references an unknown key.
pub fn parse(s: &str) -> Result<Self> {
let tokens = tokenize(s);
let expr = Parser::new(tokens).parse()?;
Ok(Filter {
expr,
raw: s.to_string(),
})
}
/// Evaluate the filter against a session row.
///
/// Returns `true` if the row matches the filter expression.
pub fn matches<R: SessionRow>(&self, row: &R) -> bool {
eval_expr(&self.expr, row)
}
}
// ---------------------------------------------------------------------------
// Expression evaluator
// ---------------------------------------------------------------------------
fn eval_expr<R: SessionRow>(expr: &Expr, row: &R) -> bool {
match expr {
Expr::Pred(p) => eval_pred(p, row),
Expr::And(l, r) => eval_expr(l, row) && eval_expr(r, row),
Expr::Or(l, r) => eval_expr(l, row) || eval_expr(r, row),
}
}
fn eval_pred<R: SessionRow>(pred: &Predicate, row: &R) -> bool {
match &pred.key {
// ── String keys ─────────────────────────────────────────────────────
Key::Model => eval_string(row.model(), &pred.value),
Key::Project => eval_string(row.project(), &pred.value),
Key::Id => eval_string(row.id(), &pred.value),
// ── Numeric keys ────────────────────────────────────────────────────
Key::Agents => eval_numeric(row.agents(), &pred.op, &pred.value),
Key::Messages => eval_numeric(row.messages(), &pred.op, &pred.value),
Key::In => match row.input_tokens() {
Some(v) => eval_numeric(v, &pred.op, &pred.value),
None => false,
},
Key::Out => match row.output_tokens() {
Some(v) => eval_numeric(v, &pred.op, &pred.value),
None => false,
},
Key::Tokens => match row.tokens() {
Some(v) => eval_numeric(v, &pred.op, &pred.value),
// Token count not available for this row type — filter does not match.
None => false,
},
// ── Date key ────────────────────────────────────────────────────────
Key::Date => match row.date() {
Some(d) => eval_date(d, &pred.op, &pred.value),
None => false,
},
}
}
/// Evaluate a string predicate.
///
/// `*` matches any non-empty string.
/// Otherwise performs a case-insensitive substring match.
fn eval_string(field: &str, value: &str) -> bool {
if value == "*" {
return !field.is_empty();
}
field.to_lowercase().contains(&value.to_lowercase())
}
/// Evaluate a numeric predicate.
///
/// For `:` the value is compared for equality.
/// For `>` / `<` the value is parsed and compared.
/// Returns `false` on parse failure.
fn eval_numeric(field: u64, op: &Op, value: &str) -> bool {
let Ok(v) = value.parse::<u64>() else {
return false;
};
match op {
Op::Colon => field == v,
Op::Gt => field > v,
Op::Lt => field < v,
}
}
/// Evaluate a date predicate.
///
/// `value` must be an ISO 8601 date string (`YYYY-MM-DD`).
/// Returns `false` on parse failure.
fn eval_date(field: NaiveDate, op: &Op, value: &str) -> bool {
let Ok(v) = NaiveDate::parse_from_str(value, "%Y-%m-%d") else {
return false;
};
match op {
Op::Colon => field == v,
Op::Gt => field > v,
Op::Lt => field < v,
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::tui::state::SessionListItem;
// ── Helper ───────────────────────────────────────────────────────────────
fn make_row(
model: &str,
project: &str,
full_id: &str,
msg_count: usize,
agent_count: usize,
date: &str,
) -> SessionListItem {
SessionListItem {
short_id: full_id.get(..8).unwrap_or(full_id).to_string(),
full_id: full_id.to_string(),
date: date.to_string(),
project: project.to_string(),
model: model.to_string(),
msg_count,
agent_count,
input_tokens: 0,
output_tokens: 0,
}
}
/// Build a row with the given token counts; all other fields use fixed defaults.
fn make_row_with_tokens(
full_id: &str,
input_tokens: u64,
output_tokens: u64,
) -> SessionListItem {
SessionListItem {
short_id: full_id.get(..8).unwrap_or(full_id).to_string(),
full_id: full_id.to_string(),
date: "2026-03-30 10:00:00".to_string(),
project: "/proj".to_string(),
model: "model".to_string(),
msg_count: 5,
agent_count: 0,
input_tokens,
output_tokens,
}
}
// ── Tokenizer ────────────────────────────────────────────────────────────
#[test]
fn tokenize_single_atom() {
let tokens = tokenize("model:haiku");
assert_eq!(tokens, vec![Token::Atom("model:haiku".to_string())]);
}
#[test]
fn tokenize_and_or_keywords() {
let tokens = tokenize("model:haiku AND agents>0 OR date<2026-01-01");
assert_eq!(
tokens,
vec![
Token::Atom("model:haiku".to_string()),
Token::And,
Token::Atom("agents>0".to_string()),
Token::Or,
Token::Atom("date<2026-01-01".to_string()),
]
);
}
#[test]
fn tokenize_bare_and_or() {
// AND/OR without surrounding atoms are still tokenized correctly.
let tokens = tokenize("AND OR");
assert_eq!(tokens, vec![Token::And, Token::Or]);
}
// ── parse_atom ───────────────────────────────────────────────────────────
#[test]
fn parse_atom_colon() {
let p = parse_atom("model:haiku").unwrap();
assert_eq!(p.key, Key::Model);
assert_eq!(p.op, Op::Colon);
assert_eq!(p.value, "haiku");
}
#[test]
fn parse_atom_gt() {
let p = parse_atom("agents>0").unwrap();
assert_eq!(p.key, Key::Agents);
assert_eq!(p.op, Op::Gt);
assert_eq!(p.value, "0");
}
#[test]
fn parse_atom_lt() {
let p = parse_atom("messages<100").unwrap();
assert_eq!(p.key, Key::Messages);
assert_eq!(p.op, Op::Lt);
assert_eq!(p.value, "100");
}
#[test]
fn parse_atom_unknown_key() {
let err = parse_atom("foo:bar").unwrap_err();
assert!(err.to_string().contains("unknown filter key 'foo'"));
}
#[test]
fn parse_atom_missing_key() {
let err = parse_atom(":value").unwrap_err();
assert!(err.to_string().contains("missing key"));
}
#[test]
fn parse_atom_no_operator() {
let err = parse_atom("justword").unwrap_err();
assert!(err.to_string().contains("expected operator"));
}
#[test]
fn parse_atom_string_key_gt_rejected() {
let err = parse_atom("model>foo").unwrap_err();
assert!(err.to_string().contains("only supports ':' operator"));
}
// ── Filter::parse ────────────────────────────────────────────────────────
#[test]
fn parse_simple_colon() {
assert!(Filter::parse("model:haiku").is_ok());
}
#[test]
fn parse_and_expression() {
assert!(Filter::parse("model:haiku AND agents>0").is_ok());
}
#[test]
fn parse_or_expression() {
assert!(Filter::parse("model:haiku OR model:sonnet").is_ok());
}
#[test]
fn parse_complex_expression() {
assert!(Filter::parse("date>2026-01-01 AND date<2026-12-31").is_ok());
}
#[test]
fn parse_empty_string_error() {
let err = Filter::parse("").unwrap_err();
assert!(err.to_string().contains("empty"));
}
#[test]
fn parse_unknown_key_error() {
let err = Filter::parse("bogus:value").unwrap_err();
assert!(err.to_string().contains("unknown filter key"));
}
#[test]
fn parse_bare_word_error() {
let err = Filter::parse("justword").unwrap_err();
assert!(
err.to_string().contains("unexpected token")
|| err.to_string().contains("expected operator")
);
}
#[test]
fn parse_dangling_and_error() {
let err = Filter::parse("model:haiku AND").unwrap_err();
assert!(err.to_string().contains("unexpected end"));
}
#[test]
fn parse_leading_or_error() {
let err = Filter::parse("OR model:haiku").unwrap_err();
assert!(err.to_string().contains("unexpected 'OR'"));
}
// ── Filter::matches — string fields ─────────────────────────────────────
#[test]
fn match_model_substring() {
let f = Filter::parse("model:haiku").unwrap();
let row = make_row(
"claude-haiku-4-5",
"/proj",
"aaaaaaaa-0000-0000-0000-000000000000",
5,
0,
"2026-03-30 10:00:00",
);
assert!(f.matches(&row));
}
#[test]
fn match_model_substring_case_insensitive() {
let f = Filter::parse("model:HAIKU").unwrap();
let row = make_row(
"claude-haiku-4-5",
"/proj",
"aaaaaaaa-0000-0000-0000-000000000000",
5,
0,
"2026-03-30 10:00:00",
);
assert!(f.matches(&row));
}
#[test]
fn no_match_model_substring() {
let f = Filter::parse("model:sonnet").unwrap();
let row = make_row(
"claude-haiku-4-5",
"/proj",
"aaaaaaaa-0000-0000-0000-000000000000",
5,
0,
"2026-03-30 10:00:00",
);
assert!(!f.matches(&row));
}
#[test]
fn match_model_wildcard() {
let f = Filter::parse("model:*").unwrap();
let row_with_model = make_row(
"claude-haiku-4-5",
"/proj",
"aaaaaaaa-0000-0000-0000-000000000000",
5,
0,
"2026-03-30 10:00:00",
);
let row_empty_model = make_row(
"",
"/proj",
"bbbbbbbb-0000-0000-0000-000000000000",
5,
0,
"2026-03-30 10:00:00",
);
assert!(f.matches(&row_with_model));
assert!(!f.matches(&row_empty_model));
}
#[test]
fn match_project_substring() {
let f = Filter::parse("project:my-org").unwrap();
let row = make_row(
"claude-sonnet-4-6",
"/home/pop/my-org/repo",
"aaaaaaaa-0000-0000-0000-000000000000",
5,
0,
"2026-03-30 10:00:00",
);
assert!(f.matches(&row));
}
#[test]
fn match_id_substring() {
let f = Filter::parse("id:aaaaaaaa").unwrap();
let row = make_row(
"claude-sonnet-4-6",
"/proj",
"aaaaaaaa-0000-0000-0000-000000000000",
5,
0,
"2026-03-30 10:00:00",
);
assert!(f.matches(&row));
}
// ── Filter::matches — numeric fields ────────────────────────────────────
#[test]
fn match_agents_gt() {
let f = Filter::parse("agents>0").unwrap();
let with_agents = make_row(
"model",
"/proj",
"aaaaaaaa-0000-0000-0000-000000000000",
5,
2,
"2026-03-30 10:00:00",
);
let no_agents = make_row(
"model",
"/proj",
"bbbbbbbb-0000-0000-0000-000000000000",
5,
0,
"2026-03-30 10:00:00",
);
assert!(f.matches(&with_agents));
assert!(!f.matches(&no_agents));
}
#[test]
fn match_messages_lt() {
let f = Filter::parse("messages<10").unwrap();
let few = make_row(
"model",
"/proj",
"aaaaaaaa-0000-0000-0000-000000000000",
5,
0,
"2026-03-30 10:00:00",
);
let many = make_row(
"model",
"/proj",
"bbbbbbbb-0000-0000-0000-000000000000",
20,
0,
"2026-03-30 10:00:00",
);
assert!(f.matches(&few));
assert!(!f.matches(&many));
}
#[test]
fn match_messages_colon_equality() {
let f = Filter::parse("messages:5").unwrap();
let five = make_row(
"model",
"/proj",
"aaaaaaaa-0000-0000-0000-000000000000",
5,
0,
"2026-03-30 10:00:00",
);
let six = make_row(
"model",
"/proj",
"bbbbbbbb-0000-0000-0000-000000000000",
6,
0,
"2026-03-30 10:00:00",
);
assert!(f.matches(&five));
assert!(!f.matches(&six));
}
// ── Filter::matches — date field ─────────────────────────────────────────
#[test]
fn match_date_gt() {
let f = Filter::parse("date>2026-03-15").unwrap();
let after = make_row(
"model",
"/proj",
"aaaaaaaa-0000-0000-0000-000000000000",
5,
0,
"2026-03-20 10:00:00",
);
let before = make_row(
"model",
"/proj",
"bbbbbbbb-0000-0000-0000-000000000000",
5,
0,
"2026-03-10 10:00:00",
);
assert!(f.matches(&after));
assert!(!f.matches(&before));
}
#[test]
fn match_date_lt() {
let f = Filter::parse("date<2026-03-20").unwrap();
let before = make_row(
"model",
"/proj",
"aaaaaaaa-0000-0000-0000-000000000000",
5,
0,
"2026-03-15 10:00:00",
);
let after = make_row(
"model",
"/proj",
"bbbbbbbb-0000-0000-0000-000000000000",
5,
0,
"2026-03-25 10:00:00",
);
assert!(f.matches(&before));
assert!(!f.matches(&after));
}
#[test]
fn match_date_colon_equality() {
let f = Filter::parse("date:2026-03-20").unwrap();
let exact = make_row(
"model",
"/proj",
"aaaaaaaa-0000-0000-0000-000000000000",
5,
0,
"2026-03-20 10:00:00",
);
let other = make_row(
"model",
"/proj",
"bbbbbbbb-0000-0000-0000-000000000000",
5,
0,
"2026-03-21 10:00:00",
);
assert!(f.matches(&exact));
assert!(!f.matches(&other));
}
#[test]
fn match_date_range_and() {
let f = Filter::parse("date>2026-03-15 AND date<2026-03-20").unwrap();
let inside = make_row(
"model",
"/proj",
"aaaaaaaa-0000-0000-0000-000000000000",
5,
0,
"2026-03-17 10:00:00",
);
let outside = make_row(
"model",
"/proj",
"bbbbbbbb-0000-0000-0000-000000000000",
5,
0,
"2026-03-22 10:00:00",
);
assert!(f.matches(&inside));
assert!(!f.matches(&outside));
}
// ── Filter::matches — logical operators ─────────────────────────────────
#[test]
fn match_and_both_must_match() {
let f = Filter::parse("model:haiku AND agents>0").unwrap();
let haiku_agents = make_row(
"claude-haiku-4-5",
"/proj",
"aaaaaaaa-0000-0000-0000-000000000000",
5,
2,
"2026-03-30 10:00:00",
);
let haiku_no_agents = make_row(
"claude-haiku-4-5",
"/proj",
"bbbbbbbb-0000-0000-0000-000000000000",
5,
0,
"2026-03-30 10:00:00",
);
let sonnet_agents = make_row(
"claude-sonnet-4-6",
"/proj",
"cccccccc-0000-0000-0000-000000000000",
5,
2,
"2026-03-30 10:00:00",
);
assert!(f.matches(&haiku_agents));
assert!(!f.matches(&haiku_no_agents));
assert!(!f.matches(&sonnet_agents));
}
#[test]
fn match_or_either_matches() {
let f = Filter::parse("model:haiku OR model:sonnet").unwrap();
let haiku = make_row(
"claude-haiku-4-5",
"/proj",
"aaaaaaaa-0000-0000-0000-000000000000",
5,
0,
"2026-03-30 10:00:00",
);
let sonnet = make_row(
"claude-sonnet-4-6",
"/proj",
"bbbbbbbb-0000-0000-0000-000000000000",
5,
0,
"2026-03-30 10:00:00",
);
let opus = make_row(
"claude-opus-4-5",
"/proj",
"cccccccc-0000-0000-0000-000000000000",
5,
0,
"2026-03-30 10:00:00",
);
assert!(f.matches(&haiku));
assert!(f.matches(&sonnet));
assert!(!f.matches(&opus));
}
#[test]
fn and_binds_tighter_than_or() {
// "A OR B AND C" should parse as "A OR (B AND C)" — AND binds tighter.
let f = Filter::parse("model:opus OR model:haiku AND agents>0").unwrap();
// matches opus (left of OR) regardless of agents
let opus_no_agents = make_row(
"claude-opus-4-5",
"/proj",
"aaaaaaaa-0000-0000-0000-000000000000",
5,
0,
"2026-03-30 10:00:00",
);
// haiku without agents — does NOT match right side (haiku AND agents>0)
let haiku_no_agents = make_row(
"claude-haiku-4-5",
"/proj",
"bbbbbbbb-0000-0000-0000-000000000000",
5,
0,
"2026-03-30 10:00:00",
);
// haiku with agents — matches right side
let haiku_agents = make_row(
"claude-haiku-4-5",
"/proj",
"cccccccc-0000-0000-0000-000000000000",
5,
3,
"2026-03-30 10:00:00",
);
assert!(f.matches(&opus_no_agents));
assert!(!f.matches(&haiku_no_agents));
assert!(f.matches(&haiku_agents));
}
// ── Token filter fields ──────────────────────────────────────────────────
#[test]
fn tokens_gt_matches_when_total_exceeds_threshold() {
let f = Filter::parse("tokens>50000").unwrap();
let big = make_row_with_tokens("aaaaaaaa-0000-0000-0000-000000000000", 40000, 20000);
let small = make_row_with_tokens("bbbbbbbb-0000-0000-0000-000000000000", 1000, 500);
assert!(f.matches(&big)); // 60000 > 50000
assert!(!f.matches(&small)); // 1500 is not > 50000
}
#[test]
fn tokens_lt_matches_when_total_below_threshold() {
let f = Filter::parse("tokens<10000").unwrap();
let small = make_row_with_tokens("aaaaaaaa-0000-0000-0000-000000000000", 3000, 2000);
let big = make_row_with_tokens("bbbbbbbb-0000-0000-0000-000000000000", 20000, 5000);
assert!(f.matches(&small)); // 5000 < 10000
assert!(!f.matches(&big)); // 25000 is not < 10000
}
#[test]
fn tokens_colon_equality() {
let f = Filter::parse("tokens:1000").unwrap();
let exact = make_row_with_tokens("aaaaaaaa-0000-0000-0000-000000000000", 700, 300);
let other = make_row_with_tokens("bbbbbbbb-0000-0000-0000-000000000000", 700, 400);
assert!(f.matches(&exact)); // 700+300 == 1000
assert!(!f.matches(&other)); // 1100 != 1000
}
#[test]
fn in_gt_matches_input_tokens() {
let f = Filter::parse("in>5000").unwrap();
let high = make_row_with_tokens("aaaaaaaa-0000-0000-0000-000000000000", 10000, 1000);
let low = make_row_with_tokens("bbbbbbbb-0000-0000-0000-000000000000", 500, 1000);
assert!(f.matches(&high)); // 10000 > 5000
assert!(!f.matches(&low)); // 500 is not > 5000
}
#[test]
fn out_lt_matches_output_tokens() {
let f = Filter::parse("out<1000").unwrap();
let low_out = make_row_with_tokens("aaaaaaaa-0000-0000-0000-000000000000", 5000, 200);
let high_out = make_row_with_tokens("bbbbbbbb-0000-0000-0000-000000000000", 5000, 5000);
assert!(f.matches(&low_out)); // 200 < 1000
assert!(!f.matches(&high_out)); // 5000 is not < 1000
}
#[test]
fn in_and_out_combined() {
// Large input AND small output — very "reading-heavy" sessions.
let f = Filter::parse("in>10000 AND out<500").unwrap();
let reading_heavy =
make_row_with_tokens("aaaaaaaa-0000-0000-0000-000000000000", 50000, 200);
let balanced = make_row_with_tokens("bbbbbbbb-0000-0000-0000-000000000000", 50000, 2000);
assert!(f.matches(&reading_heavy));
assert!(!f.matches(&balanced));
}
#[test]
fn tokens_zero_default_with_colon_zero() {
// make_row defaults tokens to 0; tokens:0 should match.
let f = Filter::parse("tokens:0").unwrap();
let row = make_row(
"model",
"/proj",
"aaaaaaaa-0000-0000-0000-000000000000",
5,
0,
"2026-03-30 10:00:00",
);
assert!(f.matches(&row));
}
}