diff --git a/.beans/claudbg-2vwx--filter-queries.md b/.beans/claudbg-2vwx--filter-queries.md new file mode 100644 index 0000000..ef0aeaa --- /dev/null +++ b/.beans/claudbg-2vwx--filter-queries.md @@ -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. diff --git a/.beans/claudbg-4bms--filter-query-parser.md b/.beans/claudbg-4bms--filter-query-parser.md new file mode 100644 index 0000000..1ed7488 --- /dev/null +++ b/.beans/claudbg-4bms--filter-query-parser.md @@ -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 Result` — 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. diff --git a/src/filter.rs b/src/filter.rs new file mode 100644 index 0000000..cea6cad --- /dev/null +++ b/src/filter.rs @@ -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`, `<` | +//! | `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; + /// 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 + } + /// Token count is not yet tracked in `SessionListItem`; always returns `None`. + fn tokens(&self) -> Option { + None + } + 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, + 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" => 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, 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, 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::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, + } + } + + // ── 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)); + } +} diff --git a/src/lib.rs b/src/lib.rs index 909ccbc..108906d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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.