feat(claudbg-4bms): filter query parser with AND/OR and key:value syntax
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>main
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue