diff --git a/crates/lore-core/src/search.rs b/crates/lore-core/src/search.rs index 1b5ebf3..0959f7d 100644 --- a/crates/lore-core/src/search.rs +++ b/crates/lore-core/src/search.rs @@ -6,6 +6,9 @@ //! plus a few structured filters; plain terms are wrapped as FTS5 phrases and //! bound as a parameter, so a malformed or hostile query can never become raw //! SQL or FTS syntax. Ranking is BM25; matches are wrapped in highlight markers. +//! A query of filters alone skips FTS and lists matching sessions newest first; +//! its snippets still come from the redacted projection, and its filter values +//! are bound parameters like every other. use lore_ipc::{SearchHit, SearchPage}; use rusqlite::types::Value; @@ -128,6 +131,22 @@ struct ParsedQuery { git: GitFilters, } +impl ParsedQuery { + /// True when at least one structured filter survived parsing (an empty, + /// unparseable, or unknown-class value does not count). + fn has_filters(&self) -> bool { + self.agent.is_some() + || self.path.is_some() + || self.tool.is_some() + || self.model.is_some() + || self.before.is_some() + || self.after.is_some() + || self.has_error + || self.has_patch + || !self.git.is_empty() + } +} + /// Filters over provenance-labelled Git evidence. /// /// `source_class` is a **constraint on the other three**, not a filter in its @@ -162,8 +181,9 @@ const GIT_SOURCE_CLASSES: &[&str] = &["agent_recorded", "lore_captured", "lore_r /// Search the archive, returning the first page only. `raw` is the user's query /// (plain terms plus optional `agent:`, `path:`, `tool:`, and `has:error` -/// filters); returns up to `limit` hits ranked best-first. An empty term set -/// yields no results. Convenience wrapper over [`search_page`] for callers that +/// filters); returns up to `limit` hits ranked best-first. A query with no terms +/// and no filters yields no results; filters alone list matching sessions (see +/// [`search_page`]). Convenience wrapper over [`search_page`] for callers that /// do not paginate. pub fn search(conn: &Connection, raw: &str, limit: i64) -> Result> { Ok(search_page(conn, raw, limit, None, SortOrder::Relevance)?.hits) @@ -177,6 +197,18 @@ pub fn search(conn: &Connection, raw: &str, limit: i64) -> Result /// to fetch the next page. A cursor is only meaningful for the identical query /// **and sort** that produced it; a malformed cursor degrades to the first page /// rather than erroring. +/// +/// **Filter-only queries.** A query with filters but no plain terms (`has:error`, +/// `agent:codex`, `repo:lore`, …) returns one hit per matching session, newest +/// first, under the same keyset and cursor rules. There is no text match to +/// score, so [`SortOrder::Relevance`] falls back to [`SortOrder::Newest`]; +/// `Oldest` is honoured. Such a hit has `rank` 0 and describes the session by its +/// first redacted projection document: for a session with a native title that is +/// `source_kind` `session`, `field` `title`; otherwise it is the opening message +/// part (`message_part`, its role field). `snippet` is that document's redacted +/// text, whitespace-collapsed and shortened, with no highlight markers. A session +/// with no projected text at all gets `session`/`title` and an empty snippet. A +/// query with neither terms nor filters returns an empty page. pub fn search_page( conn: &Connection, raw: &str, @@ -186,6 +218,11 @@ pub fn search_page( ) -> Result { let query = parse_query(raw); let Some(match_expr) = fts_match(&query.terms) else { + // No usable terms. Filters alone still select sessions; nothing at all + // is an empty page, never a bare FTS MATCH. + if query.has_filters() { + return filter_only_page(conn, &query, limit, cursor, sort); + } return Ok(SearchPage { hits: Vec::new(), next_cursor: None, @@ -224,57 +261,152 @@ pub fn search_page( params.push(Value::Text(HIGHLIGHT_END.to_string())); params.push(Value::Text(match_expr)); - if let Some(agent) = query.agent { - sql.push_str(" AND sd.agent_id = ?"); - params.push(Value::Text(agent)); + append_filters(conn, &mut sql, &mut params, &query, &FTS_COLUMNS)?; + sql.push_str(") m"); + + // Keyset predicate: keep only rows strictly after the cursor in the chosen + // total order. Applied in `p`, before the agent_session join. + let (keyset_sql, keyset_params) = keyset(sort, &cursor); + sql.push_str(&keyset_sql); + params.extend(keyset_params); + + sql.push_str(match sort { + // NULLs sort last in every mode: `sa DESC` already trails NULLs, and the + // explicit `(sa IS NULL)` key does so for the recency sorts. + SortOrder::Relevance => " ORDER BY rank, sa DESC, did LIMIT ?", + SortOrder::Newest => " ORDER BY (sa IS NULL), sa DESC, did LIMIT ?", + SortOrder::Oldest => " ORDER BY (sa IS NULL), sa ASC, did LIMIT ?", + }); + params.push(Value::Integer(limit)); + + // Join the display title for just the page's rows, then re-establish the page + // order (a join does not preserve the subquery's ordering). + sql.push_str(") p JOIN agent_session s ON s.id = p.session_id"); + sql.push_str(match sort { + SortOrder::Relevance => " ORDER BY p.rank, p.sa DESC, p.did", + SortOrder::Newest => " ORDER BY (p.sa IS NULL), p.sa DESC, p.did", + SortOrder::Oldest => " ORDER BY (p.sa IS NULL), p.sa ASC, p.did", + }); + + let mut stmt = conn.prepare(&sql)?; + let rows = stmt + .query_map(rusqlite::params_from_iter(params), |row| { + let hit = SearchHit { + session_id: row.get(0)?, + source_kind: row.get(1)?, + source_id: row.get(2)?, + field: row.get(3)?, + snippet: row.get(4)?, + rank: row.get(5)?, + title: row.get(6)?, + agent_id: row.get(7)?, + started_at: row.get(8)?, + }; + let did: i64 = row.get(9)?; + Ok((hit, did)) + })? + .collect::>>()?; + + // Only advertise a cursor when the page was full: a short page is the end, + // so there is nothing more to fetch. + let next_cursor = (rows.len() as i64 == limit) + .then(|| { + rows.last() + .map(|(hit, did)| Cursor::from_row(hit, *did).encode()) + }) + .flatten(); + let hits = rows.into_iter().map(|(hit, _)| hit).collect(); + Ok(SearchPage { hits, next_cursor }) +} + +/// The column expressions the shared filters bind to. The FTS path filters on +/// `search_document` (`sd`, denormalized keys per migration 0007); the +/// filter-only path filters on `agent_session` (`s`) directly. Both are static +/// strings chosen by this module, never user input. +struct FilterColumns { + session: &'static str, + agent: &'static str, + started: &'static str, +} + +const FTS_COLUMNS: FilterColumns = FilterColumns { + session: "sd.session_id", + agent: "sd.agent_id", + started: "sd.started_at", +}; + +const SESSION_COLUMNS: FilterColumns = FilterColumns { + session: "s.id", + agent: "s.agent_id", + started: "s.started_at", +}; + +/// Append every structured filter in `query` to `sql` as ` AND …` clauses, with +/// its values pushed to `params` in text order. Values are always bound, never +/// spliced, so a hostile filter value cannot become SQL. +fn append_filters( + conn: &Connection, + sql: &mut String, + params: &mut Vec, + query: &ParsedQuery, + cols: &FilterColumns, +) -> Result<()> { + let FilterColumns { + session, + agent, + started, + } = cols; + if let Some(agent_id) = &query.agent { + sql.push_str(&format!(" AND {agent} = ?")); + params.push(Value::Text(agent_id.clone())); } if let Some(before_ts) = query.before { - sql.push_str(" AND sd.started_at < ?"); + sql.push_str(&format!(" AND {started} < ?")); params.push(Value::Integer(before_ts)); } if let Some(after_ts) = query.after { - sql.push_str(" AND sd.started_at >= ?"); + sql.push_str(&format!(" AND {started} >= ?")); params.push(Value::Integer(after_ts)); } - if let Some(model) = query.model { - sql.push_str( + if let Some(model) = &query.model { + sql.push_str(&format!( " AND (EXISTS (SELECT 1 FROM session_segment ss - WHERE ss.session_id = sd.session_id + WHERE ss.session_id = {session} AND (ss.model LIKE ? ESCAPE '\\' OR ss.provider LIKE ? ESCAPE '\\')) OR EXISTS (SELECT 1 FROM agent_session asess - WHERE asess.id = sd.session_id - AND asess.primary_model LIKE ? ESCAPE '\\'))", - ); - let pattern = format!("%{}%", like_escape(&model)); + WHERE asess.id = {session} + AND asess.primary_model LIKE ? ESCAPE '\\'))" + )); + let pattern = format!("%{}%", like_escape(model)); params.push(Value::Text(pattern.clone())); params.push(Value::Text(pattern.clone())); params.push(Value::Text(pattern)); } if query.has_error { - sql.push_str( + sql.push_str(&format!( " AND EXISTS (SELECT 1 FROM tool_call tc - WHERE tc.session_id = sd.session_id AND tc.is_error = 1)", - ); + WHERE tc.session_id = {session} AND tc.is_error = 1)" + )); } if query.has_patch { - sql.push_str( + sql.push_str(&format!( " AND EXISTS (SELECT 1 FROM file_event fe - WHERE fe.session_id = sd.session_id AND fe.patch_blob_id IS NOT NULL)", - ); + WHERE fe.session_id = {session} AND fe.patch_blob_id IS NOT NULL)" + )); } - if let Some(path) = query.path { - sql.push_str( + if let Some(path) = &query.path { + sql.push_str(&format!( " AND EXISTS (SELECT 1 FROM file_event fe - WHERE fe.session_id = sd.session_id AND fe.path LIKE ?)", - ); + WHERE fe.session_id = {session} AND fe.path LIKE ?)" + )); params.push(Value::Text(format!("{path}%"))); } - if let Some(tool) = query.tool { - sql.push_str( + if let Some(tool) = &query.tool { + sql.push_str(&format!( " AND EXISTS (SELECT 1 FROM tool_call tc - WHERE tc.session_id = sd.session_id AND tc.name = ?)", - ); - params.push(Value::Text(tool)); + WHERE tc.session_id = {session} AND tc.name = ?)" + )); + params.push(Value::Text(tool.clone())); } // Git-dimension filters (migration 0011). ONE semi-join carries all of them, // which is the whole point: `branch:billing git-source:agent_recorded` must @@ -285,27 +417,32 @@ pub fn search_page( // conflation GIT_INTEGRATION.md §1 forbids. A semi-join also cannot fan out, // so a session with many observations still counts once. if !query.git.is_empty() { - sql.push_str(" AND EXISTS (SELECT 1 FROM search_git g WHERE g.session_id = sd.session_id"); - if let Some(repo) = query.git.repo { - sql.push_str(" AND g.repository_id = ?"); - params.push(Value::Text(repo)); + sql.push_str(&format!( + " AND EXISTS (SELECT 1 FROM search_git g WHERE g.session_id = {session}" + )); + if let Some(repo) = &query.git.repo { + // The resolved ids travel as one JSON array parameter, so a name + // shared by many repositories is still one bound value. + let ids = resolve_repository_ids(conn, repo)?; + sql.push_str(" AND g.repository_id IN (SELECT value FROM json_each(?))"); + params.push(Value::Text(serde_json::to_string(&ids).unwrap_or_default())); } - if let Some(worktree) = query.git.worktree { + if let Some(worktree) = &query.git.worktree { sql.push_str(" AND g.worktree_id = ?"); - params.push(Value::Text(worktree)); + params.push(Value::Text(worktree.clone())); } - if let Some(branch) = query.git.branch { + if let Some(branch) = &query.git.branch { sql.push_str(" AND g.branch = ?"); - params.push(Value::Text(branch)); + params.push(Value::Text(branch.clone())); } - if let Some(commit) = query.git.commit { + if let Some(commit) = &query.git.commit { // Prefix match so a short sha works, anchored so it cannot wildcard. sql.push_str(" AND g.commit_sha LIKE ? ESCAPE '\\'"); - params.push(Value::Text(format!("{}%", like_escape(&commit)))); + params.push(Value::Text(format!("{}%", like_escape(commit)))); } - if let Some(class) = query.git.source_class { + if let Some(class) = &query.git.source_class { sql.push_str(" AND g.source_class = ?"); - params.push(Value::Text(class)); + params.push(Value::Text(class.clone())); } // No guard is needed against the synthetic `segment_link` rows: they // carry NULL branch and NULL commit_sha, and `NULL = ?` is never true, @@ -313,63 +450,166 @@ pub fn search_page( // they exist for. sql.push(')'); } - sql.push_str(") m"); + Ok(()) +} - // Keyset predicate: keep only rows strictly after the cursor in the chosen - // total order. Applied in `p`, before the agent_session join. - let (keyset_sql, keyset_params) = keyset(sort, &cursor); - sql.push_str(&keyset_sql); - params.extend(keyset_params); +/// Resolve a `repo:` value to the repository ids it names: the value itself as +/// an id (so `repo:` keeps working, even for an id with no `repository` +/// row), plus every repository whose display name, or whose root's last path +/// component, equals it case-insensitively. A name shared by several +/// repositories resolves to all of them. A value that names nothing resolves to +/// itself alone, which no `search_git` row carries — so an unknown name selects +/// no sessions rather than being dropped and matching everything. +/// +/// Matching is plain string equality in Rust, never `LIKE`, so `%`, `_` and `*` +/// in the value are ordinary characters. The `repository` table is one row per +/// repository, so a full read per query is small. +fn resolve_repository_ids(conn: &Connection, value: &str) -> Result> { + let wanted = value.to_lowercase(); + let mut ids = vec![value.to_string()]; + let mut stmt = conn.prepare("SELECT id, display_name, primary_path FROM repository")?; + let rows = stmt.query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, Option>(2)?, + )) + })?; + for row in rows { + let (id, display_name, primary_path) = row?; + let by_name = display_name.to_lowercase() == wanted; + let by_root = primary_path + .as_deref() + .is_some_and(|p| path_basename(p).to_lowercase() == wanted); + if (by_name || by_root) && !ids.contains(&id) { + ids.push(id); + } + } + Ok(ids) +} - sql.push_str(match sort { - // NULLs sort last in every mode: `sa DESC` already trails NULLs, and the - // explicit `(sa IS NULL)` key does so for the recency sorts. - SortOrder::Relevance => " ORDER BY rank, sa DESC, did LIMIT ?", - SortOrder::Newest => " ORDER BY (sa IS NULL), sa DESC, did LIMIT ?", - SortOrder::Oldest => " ORDER BY (sa IS NULL), sa ASC, did LIMIT ?", - }); - params.push(Value::Integer(limit)); +/// The last component of a path, accepting either separator so an archive from +/// another platform resolves the same way. Empty for a bare root. +fn path_basename(path: &str) -> &str { + path.trim_end_matches(['/', '\\']) + .rsplit(['/', '\\']) + .next() + .unwrap_or("") +} - // Join the display title for just the page's rows, then re-establish the page - // order (a join does not preserve the subquery's ordering). - sql.push_str(") p JOIN agent_session s ON s.id = p.session_id"); - sql.push_str(match sort { - SortOrder::Relevance => " ORDER BY p.rank, p.sa DESC, p.did", - SortOrder::Newest => " ORDER BY (p.sa IS NULL), p.sa DESC, p.did", - SortOrder::Oldest => " ORDER BY (p.sa IS NULL), p.sa ASC, p.did", - }); +/// Longest snippet, in characters, a filter-only hit carries. +const FILTER_SNIPPET_CHARS: usize = 160; + +/// One page of sessions matching the filters alone — the no-terms arm of +/// [`search_page`]. See its doc comment for the result shape. +/// +/// The order is `started_at` (NULLs last) then session id, a total order, so +/// keyset paging never drops or repeats a session. The cursor is a different +/// shape from a term-query cursor (it is keyed on the session's text id, not an +/// FTS document row), so one fed to the other decodes to `None` and degrades to +/// the first page, like any malformed cursor. +fn filter_only_page( + conn: &Connection, + query: &ParsedQuery, + limit: i64, + cursor: Option<&str>, + sort: SortOrder, +) -> Result { + let limit = limit.clamp(1, MAX_LIMIT); + let cursor = cursor.and_then(SessionCursor::decode); + let oldest = sort == SortOrder::Oldest; + let (cmp, dir) = if oldest { (">", "ASC") } else { ("<", "DESC") }; + + // Filter, keyset and LIMIT run on agent_session alone; the display document + // is joined only for the page's rows. A session's first projection document + // (lowest id) is its native title if it has one, else its opening message. + let mut sql = String::with_capacity(1024); + sql.push_str( + "SELECT p.id, p.agent_id, p.sa, p.title, d.source_kind, d.source_id, d.field, + substr(d.redacted_text, 1, 512) + FROM ( + SELECT s.id AS id, s.agent_id AS agent_id, s.started_at AS sa, s.title AS title + FROM agent_session s + WHERE 1 = 1", + ); + let mut params: Vec = Vec::with_capacity(16); + append_filters(conn, &mut sql, &mut params, query, &SESSION_COLUMNS)?; + match &cursor { + Some(SessionCursor { + started_at: Some(sa), + id, + }) => { + sql.push_str(&format!( + " AND (s.started_at IS NULL + OR s.started_at {cmp} ? + OR (s.started_at = ? AND s.id > ?))" + )); + params.push(Value::Integer(*sa)); + params.push(Value::Integer(*sa)); + params.push(Value::Text(id.clone())); + } + Some(SessionCursor { + started_at: None, + id, + }) => { + sql.push_str(" AND s.started_at IS NULL AND s.id > ?"); + params.push(Value::Text(id.clone())); + } + None => {} + } + sql.push_str(&format!( + " ORDER BY (s.started_at IS NULL), s.started_at {dir}, s.id LIMIT ? + ) p + LEFT JOIN search_document d + ON d.id = (SELECT MIN(x.id) FROM search_document x WHERE x.session_id = p.id) + ORDER BY (p.sa IS NULL), p.sa {dir}, p.id" + )); + params.push(Value::Integer(limit)); let mut stmt = conn.prepare(&sql)?; - let rows = stmt + let hits = stmt .query_map(rusqlite::params_from_iter(params), |row| { - let hit = SearchHit { - session_id: row.get(0)?, - source_kind: row.get(1)?, - source_id: row.get(2)?, - field: row.get(3)?, - snippet: row.get(4)?, - rank: row.get(5)?, - title: row.get(6)?, - agent_id: row.get(7)?, - started_at: row.get(8)?, - }; - let did: i64 = row.get(9)?; - Ok((hit, did)) + let session_id: String = row.get(0)?; + let text: Option = row.get(7)?; + Ok(SearchHit { + source_kind: row + .get::<_, Option>(4)? + .unwrap_or_else(|| "session".to_string()), + source_id: row + .get::<_, Option>(5)? + .unwrap_or_else(|| session_id.clone()), + field: row + .get::<_, Option>(6)? + .unwrap_or_else(|| "title".to_string()), + snippet: text.as_deref().map(filter_snippet).unwrap_or_default(), + rank: 0.0, + title: row.get(3)?, + agent_id: row.get(1)?, + started_at: row.get(2)?, + session_id, + }) })? .collect::>>()?; - // Only advertise a cursor when the page was full: a short page is the end, - // so there is nothing more to fetch. - let next_cursor = (rows.len() as i64 == limit) - .then(|| { - rows.last() - .map(|(hit, did)| Cursor::from_row(hit, *did).encode()) - }) + let next_cursor = (hits.len() as i64 == limit) + .then(|| hits.last().map(|h| SessionCursor::from_hit(h).encode())) .flatten(); - let hits = rows.into_iter().map(|(hit, _)| hit).collect(); Ok(SearchPage { hits, next_cursor }) } +/// Collapse whitespace runs to single spaces and cut to +/// [`FILTER_SNIPPET_CHARS`] characters, marking a cut with an ellipsis. +fn filter_snippet(text: &str) -> String { + let collapsed = text.split_whitespace().collect::>().join(" "); + if collapsed.chars().count() <= FILTER_SNIPPET_CHARS { + return collapsed; + } + let mut cut: String = collapsed.chars().take(FILTER_SNIPPET_CHARS).collect(); + cut.truncate(cut.trim_end().len()); + cut.push('…'); + cut +} + /// One row of a session's redacted projection, as [`session_documents_page`] /// returns it. #[derive(Debug, Clone, PartialEq, Eq)] @@ -540,6 +780,55 @@ impl Cursor { } } +/// Keyset cursor for a filter-only page: the last session's start time and id. +/// Encoded `s::`; the id is last so it may contain `:`. The +/// `s:` tag cannot parse as a term cursor's hex rank prefix followed by a valid +/// timestamp and id, so a cursor from the wrong kind of query decodes to `None`. +struct SessionCursor { + started_at: Option, + id: String, +} + +impl SessionCursor { + fn from_hit(hit: &SearchHit) -> Self { + SessionCursor { + started_at: hit.started_at, + id: hit.session_id.clone(), + } + } + + fn encode(&self) -> String { + match self.started_at { + Some(sa) => format!("s:{sa}:{}", self.id), + None => format!("s:n:{}", self.id), + } + } + + /// Parse a cursor produced by [`SessionCursor::encode`]; anything else is + /// `None` (treated as "no cursor"), never a panic on untrusted input. + fn decode(s: &str) -> Option { + if s.len() > 2_048 { + return None; + } + let mut parts = s.splitn(3, ':'); + if parts.next()? != "s" { + return None; + } + let started_at = match parts.next()? { + "n" => None, + v => Some(v.parse::().ok()?), + }; + let id = parts.next()?; + if id.is_empty() { + return None; + } + Some(SessionCursor { + started_at, + id: id.to_string(), + }) + } +} + fn truncate_to_char_boundary(s: &str, max_bytes: usize) -> &str { if s.len() <= max_bytes { s @@ -1113,6 +1402,72 @@ mod tests { assert!(params_old.is_empty()); } + #[test] + fn has_filters_counts_only_filters_that_survive_parsing() { + assert!(parse_query("has:error").has_filters()); + assert!(parse_query("agent:codex").has_filters()); + assert!(parse_query("repo:lore").has_filters()); + assert!(parse_query("before:2026-08-01").has_filters()); + assert!(parse_query("git-source:agent_recorded").has_filters()); + for none in [ + "", + "plain terms only", + "agent:", + "before:nonsense", + "git-source:bogus", + ] { + assert!(!parse_query(none).has_filters(), "{none:?}"); + } + } + + #[test] + fn path_basename_takes_the_last_component_with_either_separator() { + assert_eq!(path_basename("/work/Lore-Core"), "Lore-Core"); + assert_eq!(path_basename("/work/Lore-Core/"), "Lore-Core"); + assert_eq!(path_basename("C:\\src\\lore"), "lore"); + assert_eq!(path_basename("lore"), "lore"); + assert_eq!(path_basename("/"), ""); + assert_eq!(path_basename(""), ""); + } + + #[test] + fn session_cursor_round_trips_and_rejects_garbage() { + for started_at in [Some(1_786_442_402_000_i64), None, Some(-5)] { + let c = SessionCursor { + started_at, + id: "ses_ab:cd".to_string(), // the id is last, so it may hold ':' + }; + let back = SessionCursor::decode(&c.encode()).expect("valid cursor decodes"); + assert_eq!(back.started_at, started_at); + assert_eq!(back.id, "ses_ab:cd"); + } + for bad in ["", "s", "s:", "s:n:", "s:x:abc", "s:5", "0:1:2", "garbage"] { + assert!(SessionCursor::decode(bad).is_none(), "{bad:?}"); + } + assert!(SessionCursor::decode(&format!("s:n:{}", "x".repeat(3_000))).is_none()); + // A term cursor is not a session cursor, and vice versa. + assert!(SessionCursor::decode( + &Cursor { + rank: -1.5, + started_at: Some(1), + id: 7, + } + .encode() + ) + .is_none()); + assert!(Cursor::decode("s:n:ses_1").is_none()); + } + + #[test] + fn filter_snippet_collapses_whitespace_and_marks_a_cut() { + assert_eq!(filter_snippet(" a \n\t b "), "a b"); + assert_eq!(filter_snippet(""), ""); + let long = "é".repeat(FILTER_SNIPPET_CHARS + 40); + let cut = filter_snippet(&long); + assert_eq!(cut.chars().count(), FILTER_SNIPPET_CHARS + 1); + assert!(cut.ends_with('…')); + } + #[test] fn parse_query_empty_and_whitespace_only() { let q_empty = parse_query(""); diff --git a/crates/lore-core/tests/search.rs b/crates/lore-core/tests/search.rs index 37ef08e..70b027e 100644 --- a/crates/lore-core/tests/search.rs +++ b/crates/lore-core/tests/search.rs @@ -945,3 +945,529 @@ fn has_patch_filter_scopes_search_results() { assert_eq!(hits_patch.len(), 1); assert_eq!(hits_patch[0].session_id, sid1); } + +// ── Filter-only queries and `repo:` by name ────────────────────────────────── + +/// A Claude session whose only tool call failed. +fn claude_with_error(session: &str, text: &str) -> String { + let call = format!("{{\"type\":\"assistant\",\"uuid\":\"a_{session}\",\"sessionId\":\"{session}\",\"cwd\":\"/p\",\"message\":{{\"role\":\"assistant\",\"content\":[{{\"type\":\"tool_use\",\"id\":\"t_{session}\",\"name\":\"Bash\",\"input\":{{\"command\":\"npm test\"}}}}]}}}}\n"); + let result = format!("{{\"type\":\"user\",\"uuid\":\"r_{session}\",\"sessionId\":\"{session}\",\"cwd\":\"/p\",\"message\":{{\"role\":\"user\",\"content\":[{{\"type\":\"tool_result\",\"tool_use_id\":\"t_{session}\",\"is_error\":true,\"content\":\"1 failing\"}}]}}}}\n"); + format!("{}{call}{result}", user_message(session, "/p", text)) +} + +/// Insert a repository row and point every segment of `sessions` at it. +fn link_repo( + conn: &Connection, + repo_id: &str, + display_name: &str, + primary_path: Option<&str>, + sessions: &[&str], +) { + conn.execute( + "INSERT INTO repository (id, identity_key, display_name, primary_path, + identity_confidence, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, 'high', 0, 0)", + rusqlite::params![ + repo_id, + format!("gcd:{repo_id}"), + display_name, + primary_path + ], + ) + .unwrap(); + for sid in sessions { + conn.execute( + "UPDATE session_segment SET repository_id = ?1 WHERE session_id = ?2", + rusqlite::params![repo_id, sid], + ) + .unwrap(); + lore_core::search::reproject_for_test(conn, sid).unwrap(); + } +} + +fn sorted(mut ids: Vec) -> Vec { + ids.sort(); + ids +} + +fn session_ids(hits: &[lore_ipc::SearchHit]) -> Vec { + sorted(hits.iter().map(|h| h.session_id.clone()).collect()) +} + +#[test] +fn has_error_alone_returns_exactly_the_sessions_with_an_error() { + let conn = lore_core::storage::open_in_memory().unwrap(); + let (_bd, blobs) = store(); + let e1 = persist_claude( + &conn, + &blobs, + &claude_with_error("e1", "run the suite"), + "e1", + ); + let e2 = persist_claude( + &conn, + &blobs, + &claude_with_error("e2", "build the app"), + "e2", + ); + persist_claude( + &conn, + &blobs, + &user_message("ok1", "/p", "no failure here"), + "ok1", + ); + codex_at( + &conn, + &blobs, + "ok2", + "2026-08-11T10:00:00.000Z", + "also fine", + ); + + let hits = search(&conn, "has:error", 20).unwrap(); + assert_eq!(session_ids(&hits), sorted(vec![e1, e2])); + + // One hit per session, shaped like a hit: no highlight markers, rank 0, and + // a snippet taken from the session's opening text. + assert_eq!(hits.len(), 2); + for hit in &hits { + assert_eq!(hit.source_kind, "message_part"); + assert_eq!(hit.rank, 0.0); + assert!(!hit.snippet.contains(HIGHLIGHT_START)); + assert!(!hit.snippet.contains('\u{e001}')); + } + let snippets = sorted(hits.iter().map(|h| h.snippet.clone()).collect()); + assert_eq!(snippets, vec!["build the app", "run the suite"]); +} + +#[test] +fn agent_codex_alone_returns_only_codex_sessions() { + let conn = lore_core::storage::open_in_memory().unwrap(); + let (_bd, blobs) = store(); + persist_claude(&conn, &blobs, &user_message("c1", "/p", "claude one"), "c1"); + persist_claude(&conn, &blobs, &user_message("c2", "/p", "claude two"), "c2"); + codex_at(&conn, &blobs, "x1", "2026-08-11T10:00:01.000Z", "codex one"); + codex_at(&conn, &blobs, "x2", "2026-08-11T10:00:02.000Z", "codex two"); + + let hits = search(&conn, "agent:codex", 20).unwrap(); + assert_eq!(hits.len(), 2); + assert!(hits.iter().all(|h| h.agent_id == "codex")); + // Newest first, and the snippet is the session's opening text. + assert_eq!(hits[0].snippet, "codex two"); + assert_eq!(hits[1].snippet, "codex one"); + assert_eq!(hits[0].started_at, Some(1_786_442_402_000)); + + assert_eq!(search(&conn, "agent:claude-code", 20).unwrap().len(), 2); + assert!(search(&conn, "agent:nobody", 20).unwrap().is_empty()); +} + +#[test] +fn a_native_title_is_the_snippet_when_the_session_has_one() { + let conn = lore_core::storage::open_in_memory().unwrap(); + let (_bd, blobs) = store(); + let jsonl = format!( + "{}{}", + "{\"type\":\"custom-title\",\"sessionId\":\"t1\",\"customTitle\":\"Fix billing webhook signature\"}\n", + user_message("t1", "/p", "hello there") + ); + let sid = persist_claude(&conn, &blobs, &jsonl, "t1"); + + let hits = search(&conn, "agent:claude-code", 20).unwrap(); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].session_id, sid); + assert_eq!(hits[0].source_kind, "session"); + assert_eq!(hits[0].source_id, sid); + assert_eq!(hits[0].field, "title"); + assert_eq!(hits[0].snippet, "Fix billing webhook signature"); +} + +#[test] +fn a_filter_only_snippet_never_surfaces_a_secret() { + let conn = lore_core::storage::open_in_memory().unwrap(); + let (_bd, blobs) = store(); + let secret = format!("ghp{}", "_0123456789abcdefghijklmnopqrstuvwxyz"); + persist_claude( + &conn, + &blobs, + &user_message("k1", "/p", &format!("deploy with token {secret} now")), + "k1", + ); + + let hits = search(&conn, "agent:claude-code", 20).unwrap(); + assert_eq!(hits.len(), 1); + assert!(hits[0].snippet.starts_with("deploy with token ")); + assert!(hits[0].snippet.ends_with(" now")); + assert!(!hits[0].snippet.contains(&secret)); + assert!(!hits[0] + .snippet + .contains("0123456789abcdefghijklmnopqrstuvwxyz")); +} + +#[test] +fn a_long_multiline_opening_message_is_collapsed_and_shortened() { + let conn = lore_core::storage::open_in_memory().unwrap(); + let (_bd, blobs) = store(); + let long = format!("first line\\n\\n second line {}", "word ".repeat(80)); + persist_claude(&conn, &blobs, &user_message("l1", "/p", &long), "l1"); + + let hits = search(&conn, "agent:claude-code", 20).unwrap(); + assert_eq!(hits.len(), 1); + let snippet = &hits[0].snippet; + assert!(snippet.starts_with("first line second line word word")); + assert!(snippet.ends_with('…')); + assert!(!snippet.contains('\n')); + assert_eq!(snippet.chars().count(), 161); +} + +#[test] +fn an_empty_or_filterless_query_returns_nothing() { + let conn = lore_core::storage::open_in_memory().unwrap(); + let (_bd, blobs) = store(); + persist_claude(&conn, &blobs, &user_message("c1", "/p", "something"), "c1"); + codex_at(&conn, &blobs, "x1", "2026-08-11T10:00:00.000Z", "something"); + + for raw in [ + "", + " \t\n ", + "\u{feff}\u{200b}\u{200c}", + // Filters that parse to nothing are not filters. + "agent:", + "repo: branch:", + "before:not-a-date after:also-not", + "git-source:bogus", + "agent:\u{200b}\u{200c}", + ] { + for sort in [SortOrder::Relevance, SortOrder::Newest, SortOrder::Oldest] { + let page = search_page(&conn, raw, 20, None, sort).unwrap(); + assert!(page.hits.is_empty(), "{raw:?} must return nothing"); + assert!(page.next_cursor.is_none()); + } + } +} + +#[test] +fn filter_only_paging_neither_drops_nor_repeats_a_session() { + let conn = lore_core::storage::open_in_memory().unwrap(); + let (_bd, blobs) = store(); + // Codex sessions carry a start time; two share one, so the id tie-break + // alone orders them. Claude sessions have none, so they form the trailing + // NULL block. + for (i, ts) in [ + "2026-08-11T10:00:01.000Z", + "2026-08-11T10:00:02.000Z", + "2026-08-11T10:00:02.000Z", + "2026-08-11T10:00:03.000Z", + "2026-08-11T10:00:04.000Z", + "2026-08-11T10:00:05.000Z", + ] + .iter() + .enumerate() + { + codex_at( + &conn, + &blobs, + &format!("cx{i}"), + ts, + &format!("codex body {i}"), + ); + } + for i in 0..5 { + persist_claude( + &conn, + &blobs, + &user_message(&format!("cl{i}"), "/p", &format!("claude body {i}")), + &format!("cl{i}"), + ); + } + let all: Vec = conn + .prepare("SELECT id FROM agent_session") + .unwrap() + .query_map([], |r| r.get(0)) + .unwrap() + .collect::>() + .unwrap(); + assert_eq!(all.len(), 11); + link_repo( + &conn, + "repo_page", + "pager", + None, + &all.iter().map(String::as_str).collect::>(), + ); + + for sort in [SortOrder::Relevance, SortOrder::Newest, SortOrder::Oldest] { + let truth = search_page(&conn, "repo:pager", 100, None, sort).unwrap(); + assert_eq!(truth.hits.len(), 11, "{sort}: every session once"); + assert!(truth.next_cursor.is_none(), "{sort}: short page is the end"); + + let mut paged: Vec = Vec::new(); + let mut cursor: Option = None; + let mut pages = 0; + loop { + let page = search_page(&conn, "repo:pager", 3, cursor.as_deref(), sort).unwrap(); + pages += 1; + assert!(pages <= 12, "{sort}: pagination must terminate"); + paged.extend(page.hits.iter().map(|h| h.session_id.clone())); + match page.next_cursor { + Some(c) => cursor = Some(c), + None => break, + } + } + let truth_ids: Vec = truth.hits.iter().map(|h| h.session_id.clone()).collect(); + assert_eq!(paged, truth_ids, "{sort}: paged order equals single-shot"); + assert_eq!( + sorted(paged), + sorted(all.clone()), + "{sort}: complete, no repeats" + ); + + // Timestamps lead, in the requested direction; the NULL block trails. + let stamps: Vec> = truth.hits.iter().map(|h| h.started_at).collect(); + assert!(stamps[..6].iter().all(Option::is_some)); + assert!(stamps[6..].iter().all(Option::is_none)); + let dated: Vec = stamps[..6].iter().map(|s| s.unwrap()).collect(); + let mut expected = dated.clone(); + if sort == SortOrder::Oldest { + expected.sort(); + } else { + expected.sort_by(|a, b| b.cmp(a)); + } + assert_eq!(dated, expected, "{sort}: Relevance falls back to newest"); + } +} + +#[test] +fn a_cursor_from_the_other_kind_of_query_degrades_to_the_first_page() { + let conn = lore_core::storage::open_in_memory().unwrap(); + let (_bd, blobs) = store(); + for i in 0..4 { + codex_at( + &conn, + &blobs, + &format!("x{i}"), + &format!("2026-08-11T10:00:0{i}.000Z"), + "shared words", + ); + } + let first_filter = search_page(&conn, "agent:codex", 2, None, SortOrder::Newest).unwrap(); + let first_term = search_page(&conn, "shared", 2, None, SortOrder::Newest).unwrap(); + let filter_cursor = first_filter.next_cursor.clone().unwrap(); + let term_cursor = first_term.next_cursor.clone().unwrap(); + + let fed = search_page( + &conn, + "agent:codex", + 2, + Some(&term_cursor), + SortOrder::Newest, + ) + .unwrap(); + assert_eq!(fed, first_filter); + let fed = search_page(&conn, "shared", 2, Some(&filter_cursor), SortOrder::Newest).unwrap(); + assert_eq!(fed, first_term); + + // A malformed cursor is the first page, never an error. + for bad in ["", "s:", "s:n:", "s:x:abc", "s:5", "garbage", "s::1"] { + let page = search_page(&conn, "agent:codex", 2, Some(bad), SortOrder::Newest).unwrap(); + assert_eq!(page, first_filter, "{bad:?}"); + } +} + +/// Three repositories over five sessions, for the `repo:` tests. +/// repo_alpha display "Lore" root /work/Lore-Core -> a1, a2 +/// repo_beta display "other-name" root /home/me/Beta -> b1 +/// repo_gamma display "lore" root /srv/lore -> g1 (shares a name) +fn repo_fixture() -> (Connection, tempfile::TempDir, [String; 4]) { + let conn = lore_core::storage::open_in_memory().unwrap(); + let (bd, blobs) = store(); + let a1 = persist_claude(&conn, &blobs, &user_message("a1", "/p", "alpha one"), "a1"); + let a2 = persist_claude(&conn, &blobs, &user_message("a2", "/p", "alpha two"), "a2"); + let b1 = persist_claude(&conn, &blobs, &user_message("b1", "/p", "beta one"), "b1"); + let g1 = persist_claude(&conn, &blobs, &user_message("g1", "/p", "gamma one"), "g1"); + persist_claude(&conn, &blobs, &user_message("n1", "/p", "unlinked"), "n1"); + link_repo( + &conn, + "repo_alpha", + "Lore", + Some("/work/Lore-Core"), + &[&a1, &a2], + ); + link_repo( + &conn, + "repo_beta", + "other-name", + Some("/home/me/Beta"), + &[&b1], + ); + link_repo(&conn, "repo_gamma", "lore", Some("/srv/lore"), &[&g1]); + (conn, bd, [a1, a2, b1, g1]) +} + +#[test] +fn repo_by_id_and_by_name_return_the_same_sessions() { + let (conn, _bd, [_a1, _a2, b1, _g1]) = repo_fixture(); + + // Id, display name, and the last component of the root all name repo_beta. + for q in ["repo:repo_beta", "repo:other-name", "repo:Beta"] { + let hits = search(&conn, q, 20).unwrap(); + assert_eq!(session_ids(&hits), vec![b1.clone()], "{q}"); + } + // Terms combine with a name exactly as they do with an id. + for q in [ + "beta repo:repo_beta", + "beta repo:other-name", + "beta repo:beta", + ] { + let hits = search(&conn, q, 20).unwrap(); + assert_eq!(session_ids(&hits), vec![b1.clone()], "{q}"); + } + assert!(search(&conn, "alpha repo:repo_beta", 20) + .unwrap() + .is_empty()); + assert!(search(&conn, "alpha repo:other-name", 20) + .unwrap() + .is_empty()); +} + +#[test] +fn repo_with_an_unknown_name_returns_nothing() { + let (conn, _bd, _ids) = repo_fixture(); + for q in [ + "repo:nonexistent", + "repo:lor", + "repo:Lore-Cor", + "repo:repo_", + "alpha repo:nonexistent", + ] { + assert!(search(&conn, q, 20).unwrap().is_empty(), "{q}"); + } + // An archive with no repository rows at all: still nothing, and no error. + let empty = lore_core::storage::open_in_memory().unwrap(); + assert!(search(&empty, "repo:anything", 20).unwrap().is_empty()); +} + +#[test] +fn repo_name_matching_is_case_insensitive() { + let (conn, _bd, [_a1, _a2, b1, _g1]) = repo_fixture(); + for q in [ + "repo:BETA", + "repo:beta", + "repo:Beta", + "repo:OTHER-NAME", + "repo:Other-Name", + ] { + let hits = search(&conn, q, 20).unwrap(); + assert_eq!(session_ids(&hits), vec![b1.clone()], "{q}"); + } + // The root's last component is matched whole: `LORE-CORE` is repo_alpha's + // root name, so it finds only repo_alpha's two sessions. + let core = search(&conn, "repo:LORE-CORE", 20).unwrap(); + assert_eq!(core.len(), 2); + assert!(core.iter().all(|h| h.snippet.starts_with("alpha"))); +} + +#[test] +fn a_name_shared_by_two_repositories_returns_both() { + let (conn, _bd, [a1, a2, b1, g1]) = repo_fixture(); + // "Lore" (repo_alpha) and "lore" (repo_gamma) are one name, case-folded. + for q in ["repo:lore", "repo:LORE", "repo:Lore"] { + let hits = search(&conn, q, 20).unwrap(); + assert_eq!( + session_ids(&hits), + sorted(vec![a1.clone(), a2.clone(), g1.clone()]), + "{q}" + ); + } + // Two repositories whose roots end in the same component (one with a + // trailing slash) resolve together too; the id form still picks just one. + conn.execute( + "UPDATE repository SET primary_path = '/mnt/Beta/' WHERE id = 'repo_alpha'", + [], + ) + .unwrap(); + let hits = search(&conn, "repo:beta", 20).unwrap(); + assert_eq!( + session_ids(&hits), + sorted(vec![a1, a2, b1.clone()]), + "alpha's two sessions plus beta's" + ); + assert_eq!( + session_ids(&search(&conn, "repo:repo_beta", 20).unwrap()), + vec![b1] + ); +} + +#[test] +fn repo_name_combines_with_other_filters() { + let (conn, _bd, _ids) = repo_fixture(); + assert!(search(&conn, "repo:lore has:error", 20).unwrap().is_empty()); + assert_eq!( + search(&conn, "repo:lore agent:claude-code", 20) + .unwrap() + .len(), + 3 + ); + assert!(search(&conn, "repo:lore agent:codex", 20) + .unwrap() + .is_empty()); +} + +#[test] +fn hostile_filter_values_cannot_break_the_query() { + let (conn, _bd, _ids) = repo_fixture(); + let sessions_before: i64 = conn + .query_row("SELECT COUNT(*) FROM agent_session", [], |r| r.get(0)) + .unwrap(); + + // None contains whitespace, so each stays a single filter value. Each is + // run alone (filter-only path) and beside a term (FTS path). + let match_nothing = [ + "repo:*", + "repo:%", + "repo:_", + "repo:'", + "repo:\"", + "repo:\"'", + "repo:'OR'1'='1", + "repo:');DROP/**/TABLE/**/agent_session;--", + "repo:\\", + "repo:[]", + "repo:\"]", + "agent:\"", + "agent:*", + "agent:%", + "agent:'OR'1'='1", + "tool:*", + "tool:\"", + "branch:*", + "branch:%", + "branch:\"", + "commit:%", + "commit:*", + "worktree:*", + "model:%", + "model:_", + "model:*", + ]; + for q in match_nothing { + for full in [q.to_string(), format!("alpha {q}")] { + for sort in [SortOrder::Relevance, SortOrder::Newest, SortOrder::Oldest] { + let page = search_page(&conn, &full, 20, None, sort) + .unwrap_or_else(|e| panic!("{full:?} errored: {e}")); + assert!(page.hits.is_empty(), "{full:?} must match nothing"); + } + } + } + // Values that sanitize to nothing, and quote-only paths, must not error. + for q in ["repo:\u{0}", "path:\"", "path:'"] { + search_page(&conn, q, 20, None, SortOrder::Newest) + .unwrap_or_else(|e| panic!("{q:?} errored: {e}")); + } + + let sessions_after: i64 = conn + .query_row("SELECT COUNT(*) FROM agent_session", [], |r| r.get(0)) + .unwrap(); + assert_eq!(sessions_before, sessions_after); + // The archive still answers an ordinary query afterwards. + assert_eq!(search(&conn, "repo:repo_beta", 20).unwrap().len(), 1); +}