//! 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`, `<` | //! | `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; /// Output (completion) token count. /// /// Returns `None` when the token count is not available for this row type. fn output_tokens(&self) -> Option; /// Total token count (input + output). /// /// Returns `None` when token counts are not available for this row type. fn tokens(&self) -> Option; /// Session date. /// /// Returns `None` when the date cannot be parsed from the row. fn date(&self) -> Option; } 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 { Some(self.input_tokens) } fn output_tokens(&self) -> Option { Some(self.output_tokens) } fn tokens(&self) -> Option { Some(self.input_tokens + self.output_tokens) } fn date(&self) -> Option { // `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 { 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, Box), Or(Box, Box), } // --------------------------------------------------------------------------- // 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`, 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 { 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 { // 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, pos: usize, } impl Parser { fn new(tokens: Vec) -> Self { Parser { tokens, pos: 0 } } fn peek(&self) -> Option<&Token> { self.tokens.get(self.pos) } fn advance(&mut self) -> Option { let tok = self.tokens.get(self.pos).cloned(); self.pos += 1; tok } /// Parse an `or_expr`. fn parse_or(&mut self) -> Result { 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 { 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 { 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 { 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 { 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(&self, row: &R) -> bool { eval_expr(&self.expr, row) } } // --------------------------------------------------------------------------- // Expression evaluator // --------------------------------------------------------------------------- fn eval_expr(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(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::() 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)); } }