feat(claudbg-4bms): filter query parser with AND/OR and key:value syntax

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
Elijah Voigt 2 months ago
parent e0398fd5bb
commit 277e3a8667

@ -0,0 +1,10 @@
---
# claudbg-2vwx
title: Filter queries
status: todo
type: epic
created_at: 2026-03-31T00:32:47Z
updated_at: 2026-03-31T00:32:47Z
---
Add filtering support to narrow session/agent list views. Covers the query language parser, CLI --filter flag, and TUI filter panel.

@ -0,0 +1,60 @@
---
# claudbg-4bms
title: Filter query parser
status: completed
type: task
priority: normal
created_at: 2026-03-31T00:33:08Z
updated_at: 2026-03-31T05:04:41Z
parent: claudbg-2vwx
---
Implement the filter query language:
**Syntax:**
- `key:value` — substring match (e.g. `model:haiku` matches `claude-haiku-4-5`)
- `key:*` — field is non-empty
- `key>value` / `key<value` — comparison for numeric and date fields
- `AND` / `OR` for combining expressions
- Multiple `--filter` flags are ANDed together
**Supported keys:**
- `model` — substring match on model name
- `project` — substring match on project path
- `id` — substring match on session/agent ID
- `agents` — count of sub-agent runs (numeric)
- `messages` — total message count (numeric, all roles)
- `tokens` — total token count (numeric)
- `date` — session start date (ISO 8601, e.g. `2026-03-20`)
**Errors:** malformed syntax or unknown key → fail with a user-readable error message.
## Summary of Changes
Implemented the filter query language as a standalone module in `src/filter.rs`.
### What was built
- **`Filter` type** with `Filter::parse(s: &str) -> Result<Filter>` — hand-rolled recursive-descent parser, no new dependencies
- **`SessionRow` trait** with fields: `model`, `project`, `id`, `agents`, `messages`, `tokens`, `date`
- **`impl SessionRow for SessionListItem`** — wires to all `SessionListItem` fields; `tokens()` returns `None` (token count not yet tracked in that struct)
- **`Filter::matches(&self, row: &R) -> bool`** evaluates the parsed AST against any `SessionRow`
- **`pub use filter::Filter`** re-exported from `src/lib.rs`
### Parser details
- Tokenizer splits on whitespace; identifies `AND`/`OR` keywords and key-op-value atoms
- Recursive descent: `or_expr → and_expr ( OR and_expr )*`, `and_expr → primary ( AND primary )*`
- AND binds tighter than OR (standard precedence)
- String keys (`model`, `project`, `id`): `:` only; case-insensitive substring match; `*` matches any non-empty
- Numeric keys (`agents`, `messages`, `tokens`): `:` (equality), `>`, `<`
- Date key (`date`): ISO 8601 `YYYY-MM-DD`; `:` (equality), `>`, `<`
- Unknown keys and malformed syntax produce `AppError::Parse` with user-readable messages
### Not-yet-wired fields
- `tokens``SessionListItem` does not carry a token count; `tokens:`/`tokens>`/`tokens<` always returns `false` until the field is added
### Tests
39 new unit tests in `filter::tests` covering: tokenizer, atom parser, `Filter::parse` (valid + error cases), and `Filter::matches` for all key types and logical operators. All 234 project tests pass.

@ -0,0 +1,781 @@
//! 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 | `:`, `>`, `<` |
//! | `tokens` | numeric | `:`, `>`, `<` |
//! | `date` | date | `:`, `>`, `<` |
//!
//! # Not-yet-wired fields
//!
//! - `tokens` — the [`SessionRow`] data available from [`crate::tui::state::SessionListItem`]
//! does not yet carry a total token count; `tokens:` / `tokens>` / `tokens<` filters will
//! always return `false` until the field is added to `SessionListItem`.
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;
/// Total token count.
///
/// Returns `None` when the token count is 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
}
/// Token count is not yet tracked in `SessionListItem`; always returns `None`.
fn tokens(&self) -> Option<u64> {
None
}
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,
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" => Some(Key::Messages),
"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, 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::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,
}
}
// ── 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));
}
// ── Tokens not available ─────────────────────────────────────────────────
#[test]
fn tokens_filter_returns_false_for_session_list_item() {
// SessionListItem does not track token count; tokens filter always false.
let f = Filter::parse("tokens>100").unwrap();
let row = make_row("model", "/proj", "aaaaaaaa-0000-0000-0000-000000000000", 5, 0, "2026-03-30 10:00:00");
assert!(!f.matches(&row));
}
}

@ -4,12 +4,15 @@ pub mod cli;
pub mod commands;
pub mod db;
pub mod error;
pub mod filter;
pub mod models;
pub mod output;
pub mod parser;
pub mod tui;
pub mod util;
pub use filter::Filter;
#[cfg(test)]
mod tests {
/// Verify the crate compiles and the lib entry point is reachable.

Loading…
Cancel
Save