Skip to main content

polyc_eventlog_model/
nav.rs

1//! Storage-agnostic lexical navigation over a conversation's own history.
2//!
3//! When compaction folds older turns out of the model's window, they remain in
4//! the journal. This module is the pure, storage-agnostic core that lets a turn
5//! reach back into that folded-out history on demand: given already-decoded
6//! [`HistoryEntry`] slices of *the caller's own* conversation, it ranks them
7//! against a query and returns [`HistoryHit`]s. Decoding events into entries and
8//! enforcing the caller-boundary scope are the caller's job (control plane); this
9//! module never touches storage and never crosses a conversation.
10//!
11//! Ranking is deliberately lexical (term overlap / frequency), not semantic — no
12//! embedding service, no vector index.
13
14/// One decoded, human-readable slice of the caller's own history.
15///
16/// Projected from the event journal for navigation. `position` is the journal
17/// position the source event was assigned (a strictly increasing ordinal within
18/// the conversation); `turn_id` identifies the turn it belongs to.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct HistoryEntry {
21    /// The turn this slice belongs to.
22    pub turn_id: String,
23    /// Journal position of the source event (monotonic within the conversation).
24    pub position: u64,
25    /// The decoded, human-readable text of the slice.
26    pub text: String,
27}
28
29/// A lexical match against the caller's own history, returned newest-relevant
30/// first. Carries enough to let the caller then fetch the full turn.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct HistoryHit {
33    /// The turn the match was found in.
34    pub turn_id: String,
35    /// Journal position of the matched entry.
36    pub position: u64,
37    /// A short excerpt of the matched text for the model to judge relevance.
38    pub snippet: String,
39}
40
41/// Returns whether `query` contains at least one searchable term.
42///
43/// A query made only of punctuation, whitespace, or symbols tokenizes to
44/// nothing and can only ever produce an empty result — indistinguishable from
45/// a genuine no-match. Callers check this up front and surface an explicit
46/// error instead of silently searching nothing.
47#[must_use]
48pub fn has_searchable_terms(query: &str) -> bool {
49    !terms_of(query).is_empty()
50}
51
52/// Rank `entries` against a free-text `query`, returning at most `limit` hits,
53/// one per turn.
54///
55/// Lexical only: an entry is a candidate when it shares at least one query
56/// term. Each turn surfaces at most once (its best-scoring entry), so a single
57/// verbose turn whose every message matches cannot crowd other matching turns
58/// out of the `limit` slots.
59#[must_use]
60pub fn search(entries: &[HistoryEntry], query: &str, limit: usize) -> Vec<HistoryHit> {
61    let terms = distinct_terms_of(query);
62    if terms.is_empty() {
63        return Vec::new();
64    }
65    // Whole-token matching works for space-separated scripts, but an
66    // unsegmented script (Chinese, Japanese, Thai) tokenizes to one run per
67    // sentence, so no query could ever whole-token-match it. Any non-ASCII
68    // term therefore also matches as a case-insensitive substring; ASCII terms
69    // stay whole-token only, so "cat" never matches inside "concatenation".
70    let use_substring = terms.iter().any(|t| !t.is_ascii());
71    // Best-scoring entry per turn; a same-score tie keeps the newer entry.
72    let mut best: std::collections::HashMap<&str, (usize, &HistoryEntry)> =
73        std::collections::HashMap::new();
74    for e in entries {
75        let entry_terms = terms_of(&e.text);
76        let lowered = use_substring.then(|| e.text.to_lowercase());
77        let overlap = terms
78            .iter()
79            .filter(|t| {
80                entry_terms.contains(t)
81                    || (!t.is_ascii() && lowered.as_deref().is_some_and(|l| l.contains(t.as_str())))
82            })
83            .count();
84        if overlap == 0 {
85            continue;
86        }
87        match best.entry(e.turn_id.as_str()) {
88            std::collections::hash_map::Entry::Occupied(mut slot) => {
89                let (score, prev) = *slot.get();
90                if (overlap, e.position) > (score, prev.position) {
91                    slot.insert((overlap, e));
92                }
93            }
94            std::collections::hash_map::Entry::Vacant(slot) => {
95                slot.insert((overlap, e));
96            }
97        }
98    }
99    let mut scored: Vec<(usize, &HistoryEntry)> = best.into_values().collect();
100    // Most query-term overlap first; ties broken newest-first (higher position)
101    // so a later mention of the same terms surfaces above an older one.
102    scored.sort_by(|(a_score, a), (b_score, b)| {
103        b_score
104            .cmp(a_score)
105            .then_with(|| b.position.cmp(&a.position))
106    });
107    scored
108        .into_iter()
109        .take(limit)
110        .map(|(_, e)| HistoryHit {
111            turn_id: e.turn_id.clone(),
112            position: e.position,
113            snippet: snippet_of(&e.text, &terms),
114        })
115        .collect()
116}
117
118/// Longest excerpt returned per hit, in characters — enough context for the
119/// model to judge relevance without pulling the whole (possibly huge) turn back
120/// into the window; it can then fetch the full turn if it wants more.
121const MAX_SNIPPET_CHARS: usize = 200;
122
123/// The verbatim text of one past turn, assembled for `conversation_read_turn`.
124#[derive(Debug, Clone, PartialEq, Eq)]
125pub struct TurnText {
126    /// The turn whose text this is.
127    pub turn_id: String,
128    /// The turn's decoded messages, joined in journal order, middle-elided to
129    /// [`MAX_PEEK_CHARS`] when the turn is very long.
130    pub text: String,
131    /// Whether the text was elided to fit the cap (so the model knows it is
132    /// seeing a window, not the whole turn).
133    pub truncated: bool,
134}
135
136/// Longest peeked turn text returned, in characters.
137///
138/// Peek pulls a specific past turn back into the window in full, so this is far
139/// larger than a search snippet — but still bounded, because a single turn can
140/// carry a pasted document that would otherwise blow the window it is meant to
141/// conserve. A turn over the cap comes back middle-elided (head + tail, marker
142/// between), which keeps both the turn's opening and its conclusion.
143pub const MAX_PEEK_CHARS: usize = 8_000;
144
145/// Assemble the verbatim text of the entries belonging to `turn_id`, in journal
146/// order, middle-elided to [`MAX_PEEK_CHARS`].
147///
148/// Returns `None` when no committed entry carries that turn id — the caller
149/// surfaces that as an explicit error rather than an empty success, so a peek
150/// at a turn that was never committed (or a hallucinated id) can't read as "the
151/// turn was empty".
152#[must_use]
153pub fn peek(entries: &[HistoryEntry], turn_id: &str) -> Option<TurnText> {
154    let joined = entries
155        .iter()
156        .filter(|e| e.turn_id == turn_id)
157        .map(|e| e.text.as_str())
158        .collect::<Vec<_>>();
159    if joined.is_empty() {
160        return None;
161    }
162    let full = joined.join("\n");
163    let (text, truncated) = middle_elide(&full, MAX_PEEK_CHARS);
164    Some(TurnText {
165        turn_id: turn_id.to_owned(),
166        text,
167        truncated,
168    })
169}
170
171/// Longest accepted [`grep`] pattern, in characters.
172///
173/// The pattern is model-authored input; an unbounded one is a cheap way to
174/// balloon compile time and the compiled program. Anything a model
175/// legitimately greps for — exact wording, an id, a URL — fits well under
176/// this.
177pub const MAX_PATTERN_CHARS: usize = 512;
178
179/// Compiled-program size cap for [`grep`] patterns, in bytes.
180///
181/// The regex engine is linear-time in the haystack, so backtracking blowup is
182/// not the risk — a pathological pattern (nested counted repetition, huge
183/// alternation) ballooning the compiled program is. This is orders of
184/// magnitude above any legitimate recall pattern.
185const REGEX_SIZE_LIMIT: usize = 1 << 18;
186
187/// Why [`grep`] rejected a pattern.
188///
189/// Callers surface each of these as a loud tool error — never a silent empty
190/// hit list, which the model would read as "never said" — matching the
191/// search/peek discipline.
192#[derive(Debug, thiserror::Error)]
193pub enum GrepError {
194    /// The pattern was empty; it would match every entry, which can only
195    /// mislead.
196    #[error("pattern is empty")]
197    EmptyPattern,
198    /// The pattern exceeded [`MAX_PATTERN_CHARS`].
199    #[error("pattern is longer than {MAX_PATTERN_CHARS} characters")]
200    PatternTooLong,
201    /// The pattern failed to compile — bad syntax, or a compiled program over
202    /// the size cap. Carries the engine's own message so the model can fix
203    /// the pattern.
204    #[error("pattern does not compile: {0}")]
205    InvalidPattern(String),
206}
207
208/// Match `entries` against the regular expression `pattern`, returning at most
209/// `limit` hits, one per turn, newest first.
210///
211/// The complement of [`search`]: keyword ranking finds a topic, grep finds
212/// exact wording or a shape (an id, a URL, a phrase). Matching is
213/// case-insensitive by default; the pattern can opt back out with an inline
214/// `(?-i)`. Each turn surfaces at most once — its newest matching entry, with
215/// the snippet windowed around that entry's first match — mirroring
216/// [`search`]'s newer-wins discipline so a verbose turn cannot crowd others
217/// out of the `limit` slots.
218///
219/// # Errors
220///
221/// Returns a [`GrepError`] when the pattern is empty, longer than
222/// [`MAX_PATTERN_CHARS`], or fails to compile (including a compiled program
223/// over the internal size cap). A bad pattern never reads as an empty result.
224pub fn grep(
225    entries: &[HistoryEntry],
226    pattern: &str,
227    limit: usize,
228) -> Result<Vec<HistoryHit>, GrepError> {
229    if pattern.is_empty() {
230        return Err(GrepError::EmptyPattern);
231    }
232    if pattern.chars().count() > MAX_PATTERN_CHARS {
233        return Err(GrepError::PatternTooLong);
234    }
235    let re = regex::RegexBuilder::new(pattern)
236        .case_insensitive(true)
237        .size_limit(REGEX_SIZE_LIMIT)
238        .build()
239        .map_err(|e| GrepError::InvalidPattern(e.to_string()))?;
240    // Newest matching entry per turn, keyed by turn id; the value keeps the
241    // byte offset of that entry's first match for snippet centering.
242    let mut best: std::collections::HashMap<&str, (&HistoryEntry, usize)> =
243        std::collections::HashMap::new();
244    for e in entries {
245        let Some(m) = re.find(&e.text) else {
246            continue;
247        };
248        match best.entry(e.turn_id.as_str()) {
249            std::collections::hash_map::Entry::Occupied(mut slot) => {
250                if e.position > slot.get().0.position {
251                    slot.insert((e, m.start()));
252                }
253            }
254            std::collections::hash_map::Entry::Vacant(slot) => {
255                slot.insert((e, m.start()));
256            }
257        }
258    }
259    let mut hits: Vec<(&HistoryEntry, usize)> = best.into_values().collect();
260    // Newest first: recall usually wants the latest place the wording
261    // appeared, and there is no overlap score to rank by.
262    hits.sort_by_key(|(e, _)| std::cmp::Reverse(e.position));
263    Ok(hits
264        .into_iter()
265        .take(limit)
266        .map(|(e, match_start)| HistoryHit {
267            turn_id: e.turn_id.clone(),
268            position: e.position,
269            snippet: snippet_around_byte(&e.text, match_start),
270        })
271        .collect())
272}
273
274/// Marker inserted where [`middle_elide`] removed the middle of an oversized
275/// text, so the model can see the cut is deliberate rather than the turn's own
276/// content.
277const ELISION_MARKER: &str = "\n…[middle elided]…\n";
278
279/// Clamp `text` to at most `max_chars` characters, keeping the head and tail
280/// and replacing the middle with [`ELISION_MARKER`] when it overflows. Operates
281/// on the char vector, so it never splits a multi-byte boundary. Returns the
282/// (possibly shortened) text and whether anything was removed.
283fn middle_elide(text: &str, max_chars: usize) -> (String, bool) {
284    let chars: Vec<char> = text.chars().collect();
285    if chars.len() <= max_chars {
286        return (text.to_owned(), false);
287    }
288    let marker_len = ELISION_MARKER.chars().count();
289    // A cap too small to fit the marker plus any content can't show a
290    // head-and-tail window, so hard-truncate the head to honor the cap rather
291    // than emit a marker that would itself overflow it. (Not reachable at the
292    // caller's `MAX_PEEK_CHARS`; this keeps the helper honest for a smaller cap.)
293    if max_chars <= marker_len {
294        return (chars[..max_chars].iter().collect(), true);
295    }
296    let budget = max_chars - marker_len;
297    let head = budget / 2;
298    let tail = budget - head;
299    let head_text: String = chars[..head].iter().collect();
300    let tail_text: String = chars[chars.len() - tail..].iter().collect();
301    (format!("{head_text}{ELISION_MARKER}{tail_text}"), true)
302}
303
304/// A window of at most [`MAX_SNIPPET_CHARS`] characters around the first
305/// whole-token `terms` match in `text` (or the head of `text` when nothing
306/// matches). Operates purely on the char vector, so it never splits a multi-byte
307/// boundary and there is no byte/char offset drift when case-folding changes the
308/// character count (e.g. 'İ' lowercasing to two chars).
309fn snippet_of(text: &str, terms: &[String]) -> String {
310    let chars: Vec<char> = text.chars().collect();
311    if chars.len() <= MAX_SNIPPET_CHARS {
312        return text.to_owned();
313    }
314    // Center on the first WHOLE-TOKEN match so a query term can't land inside a
315    // longer word (e.g. "cat" inside "concatenation") and produce a window that
316    // misses the real token. Unsegmented scripts never token-match, so fall
317    // back to the substring hit that made the entry match; then to the head.
318    let match_char = first_token_match_char(&chars, terms)
319        .or_else(|| first_substring_match_char(&chars, terms))
320        .unwrap_or(0);
321    window_around(&chars, match_char)
322}
323
324/// A window of at most [`MAX_SNIPPET_CHARS`] characters around the match
325/// beginning at byte `match_start` of `text` — the [`grep`] counterpart of
326/// [`snippet_of`]. Converts the engine's byte offset to a char index first,
327/// so the window math shares the never-splits-a-boundary discipline.
328fn snippet_around_byte(text: &str, match_start: usize) -> String {
329    let chars: Vec<char> = text.chars().collect();
330    if chars.len() <= MAX_SNIPPET_CHARS {
331        return text.to_owned();
332    }
333    let match_char = text[..match_start].chars().count();
334    window_around(&chars, match_char)
335}
336
337/// The [`MAX_SNIPPET_CHARS`]-wide char window centered on `match_char`,
338/// clamped to the ends of `chars`.
339fn window_around(chars: &[char], match_char: usize) -> String {
340    let half = MAX_SNIPPET_CHARS / 2;
341    let end = (match_char + half).min(chars.len());
342    let start = end.saturating_sub(MAX_SNIPPET_CHARS);
343    chars[start..end].iter().collect()
344}
345
346/// Char index at which the first whole-token occurrence of any query term begins
347/// in `chars`. Uses the same tokenization discipline as [`terms_of`] (split on
348/// non-alphanumeric, case-insensitive compare) while tracking each token's start
349/// index in the original char vector, so the returned offset maps back onto
350/// `chars` with no byte/char mismatch.
351fn first_token_match_char(chars: &[char], terms: &[String]) -> Option<usize> {
352    let mut i = 0;
353    while i < chars.len() {
354        if !chars[i].is_alphanumeric() {
355            i += 1;
356            continue;
357        }
358        let start = i;
359        let mut token = String::new();
360        while i < chars.len() && chars[i].is_alphanumeric() {
361            token.extend(chars[i].to_lowercase());
362            i += 1;
363        }
364        if terms.contains(&token) {
365            return Some(start);
366        }
367    }
368    None
369}
370
371/// Char index of the first case-insensitive substring occurrence of any
372/// non-ASCII query term in `chars`. This is the snippet-centering counterpart
373/// of the substring match in [`search`]: an unsegmented script never
374/// whole-token-matches (the whole sentence is one token), so the window
375/// centers on the substring hit instead. ASCII terms are excluded so a short
376/// word can't center the window inside a longer one.
377fn first_substring_match_char(chars: &[char], terms: &[String]) -> Option<usize> {
378    let terms: Vec<Vec<char>> = terms
379        .iter()
380        .filter(|t| !t.is_ascii())
381        .map(|t| t.chars().collect())
382        .collect();
383    if terms.is_empty() {
384        return None;
385    }
386    (0..chars.len()).find(|&start| {
387        terms.iter().any(|term| {
388            chars[start..]
389                .iter()
390                .flat_map(|c| c.to_lowercase())
391                .take(term.len())
392                .eq(term.iter().copied())
393        })
394    })
395}
396
397/// Lowercase, split on non-alphanumeric runs — the shared lexical tokenizer for
398/// both the query and entry text so matching is case- and punctuation-insensitive.
399fn terms_of(s: &str) -> Vec<String> {
400    s.split(|c: char| !c.is_alphanumeric())
401        .filter(|t| !t.is_empty())
402        .map(str::to_lowercase)
403        .collect()
404}
405
406/// The distinct lexical terms of `query`: lowercased, split on non-alphanumeric
407/// runs, sorted, and deduplicated.
408///
409/// Duplicates are removed because a word repeated in the query must count once
410/// toward an entry's overlap score, or repetition (of, say, a stop word) would
411/// outrank a distinct informative match.
412///
413/// Public so the participation-scoped search index tokenizes text exactly the
414/// way this module does. A maintained index and a live replay that disagreed
415/// on what a word is would return different results for the same query, and
416/// the difference would surface as missing hits rather than as an error — so
417/// there is one tokenizer, here, rather than a second one that starts
418/// identical and drifts.
419#[must_use]
420pub fn distinct_terms_of(query: &str) -> Vec<String> {
421    let mut terms = terms_of(query);
422    terms.sort_unstable();
423    terms.dedup();
424    terms
425}
426
427#[cfg(test)]
428mod tests {
429    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
430
431    use super::*;
432
433    fn entry(turn_id: &str, position: u64, text: &str) -> HistoryEntry {
434        HistoryEntry {
435            turn_id: turn_id.to_owned(),
436            position,
437            text: text.to_owned(),
438        }
439    }
440
441    #[test]
442    fn search_returns_only_entries_sharing_a_query_term() {
443        let entries = vec![
444            entry("t1", 1, "we decided to use BM25 ranking for history"),
445            entry("t2", 2, "lunch plans for friday afternoon"),
446        ];
447        let hits = search(&entries, "BM25", 10);
448        assert_eq!(hits.len(), 1);
449        assert_eq!(hits[0].turn_id, "t1");
450        assert_eq!(hits[0].position, 1);
451    }
452
453    #[test]
454    fn search_ranks_more_query_term_overlap_first() {
455        let entries = vec![
456            entry("t1", 1, "the deploy pipeline runs on cloud build"),
457            entry(
458                "t2",
459                2,
460                "the deploy pipeline and the release pipeline both matter",
461            ),
462        ];
463        // t2 shares both "deploy" and "pipeline"; t1 shares only "pipeline".
464        let hits = search(&entries, "deploy pipeline", 10);
465        assert_eq!(hits.len(), 2);
466        assert_eq!(hits[0].turn_id, "t2", "more overlap ranks first");
467        assert_eq!(hits[1].turn_id, "t1");
468    }
469
470    #[test]
471    fn snippet_is_bounded_and_contains_the_match() {
472        let filler = "padding ".repeat(200); // ~1600 chars of noise
473        let text = format!("{filler} the keyword quantum appears here {filler}");
474        let hits = search(&[entry("t1", 1, &text)], "quantum", 10);
475        assert_eq!(hits.len(), 1);
476        assert!(
477            hits[0].snippet.len() <= MAX_SNIPPET_CHARS,
478            "snippet {} chars exceeds cap",
479            hits[0].snippet.len()
480        );
481        assert!(
482            hits[0].snippet.to_lowercase().contains("quantum"),
483            "snippet must show the match: {:?}",
484            hits[0].snippet
485        );
486    }
487
488    #[test]
489    fn snippet_centers_on_whole_token_not_substring() {
490        // "concatenation" contains "cat" as a SUBSTRING near the head; the real
491        // whole-token "cat" is a standalone word near the end. The window must
492        // land on the standalone token, not the head substring.
493        let head = "concatenation ".repeat(30); // > MAX_SNIPPET_CHARS of substring noise
494        let tail = "padding ".repeat(30);
495        let text = format!("{head}and then a cat sat over there {tail}");
496        let hits = search(&[entry("t1", 1, &text)], "cat", 10);
497        assert_eq!(hits.len(), 1);
498        assert!(
499            hits[0].snippet.contains(" cat ")
500                || terms_of(&hits[0].snippet).contains(&"cat".to_owned()),
501            "snippet must contain the whole-token match, not just the \
502             'concatenation' region: {:?}",
503            hits[0].snippet
504        );
505    }
506
507    #[test]
508    fn search_returns_one_hit_per_turn() {
509        // A verbose turn with several matching entries must not crowd an older
510        // matching turn out of the limit: one hit per turn, best entry wins.
511        let entries = vec![
512            entry("old", 1, "the deploy decision: ship behind a flag"),
513            entry("noisy", 2, "kicking off the deploy now"),
514            entry("noisy", 3, "deploy is in progress"),
515            entry("noisy", 4, "deploy went fine"),
516        ];
517        let hits = search(&entries, "deploy", 2);
518        let turn_ids: Vec<&str> = hits.iter().map(|h| h.turn_id.as_str()).collect();
519        assert_eq!(hits.len(), 2);
520        assert!(
521            turn_ids.contains(&"old"),
522            "old turn crowded out: {turn_ids:?}"
523        );
524        assert!(turn_ids.contains(&"noisy"), "{turn_ids:?}");
525    }
526
527    #[test]
528    fn repeated_query_words_do_not_inflate_rank() {
529        // "the" repeated in the query must count once: the entry matching the
530        // informative term ranks at least as well as a stop-word-only entry.
531        let entries = vec![
532            entry("stopword", 1, "the the the"),
533            entry("real", 2, "we agreed on friday"),
534        ];
535        let hits = search(&entries, "the plan the agreed", 10);
536        assert_eq!(hits[0].turn_id, "real", "{hits:?}");
537    }
538
539    #[test]
540    fn search_matches_unsegmented_scripts_by_substring() {
541        // Chinese has no token boundaries for terms_of to split on, so the
542        // whole sentence is one token; the non-ASCII substring fallback is
543        // what makes the entry findable at all.
544        let entries = vec![
545            entry("t1", 1, "我们决定了部署计划"),
546            entry("t2", 2, "lunch plans for friday"),
547        ];
548        let hits = search(&entries, "部署计划", 10);
549        assert_eq!(hits.len(), 1);
550        assert_eq!(hits[0].turn_id, "t1");
551    }
552
553    #[test]
554    fn ascii_terms_never_match_as_substrings() {
555        // The substring fallback is scoped to non-ASCII terms: "cat" inside
556        // "concatenation" must stay a non-match.
557        let entries = vec![entry("t1", 1, "string concatenation details")];
558        assert!(search(&entries, "cat", 10).is_empty());
559    }
560
561    #[test]
562    fn snippet_centers_on_substring_match_for_unsegmented_scripts() {
563        let head = "padding ".repeat(40); // push well past MAX_SNIPPET_CHARS
564        let text = format!("{head}我们决定了部署计划就这样");
565        let hits = search(&[entry("t1", 1, &text)], "部署计划", 10);
566        assert_eq!(hits.len(), 1);
567        assert!(
568            hits[0].snippet.contains("部署计划"),
569            "snippet must contain the substring match: {:?}",
570            hits[0].snippet
571        );
572    }
573
574    #[test]
575    fn has_searchable_terms_rejects_symbol_only_queries() {
576        assert!(!has_searchable_terms("?!… → ---"));
577        assert!(!has_searchable_terms("   "));
578        assert!(has_searchable_terms("deploy plan"));
579        assert!(has_searchable_terms("部署计划"));
580    }
581
582    #[test]
583    fn snippet_is_unicode_safe_when_case_folding_grows_char_count() {
584        // 'İ' (U+0130) lowercases to TWO chars, so a byte-offset-on-lowercased
585        // approach drifts. Several before a near-start match must not panic and
586        // the snippet must still contain the matched token.
587        let prefix = "İ".repeat(20);
588        let tail = "padding ".repeat(40); // push well past MAX_SNIPPET_CHARS
589        let text = format!("{prefix} the marker quantum here {tail}");
590        let hits = search(&[entry("t1", 1, &text)], "quantum", 10);
591        assert_eq!(hits.len(), 1);
592        assert!(
593            hits[0].snippet.to_lowercase().contains("quantum"),
594            "unicode snippet must contain the match: {:?}",
595            hits[0].snippet
596        );
597    }
598
599    #[test]
600    fn grep_matches_by_pattern_and_returns_the_turn() {
601        let entries = vec![
602            entry("t1", 1, "the incident id was INC-4521 that night"),
603            entry("t2", 2, "lunch plans for friday afternoon"),
604        ];
605        let hits = grep(&entries, r"INC-\d+", 10).expect("valid pattern");
606        assert_eq!(hits.len(), 1);
607        assert_eq!(hits[0].turn_id, "t1");
608        assert_eq!(hits[0].position, 1);
609        assert!(
610            hits[0].snippet.contains("INC-4521"),
611            "{:?}",
612            hits[0].snippet
613        );
614    }
615
616    #[test]
617    fn grep_is_case_insensitive_unless_the_pattern_opts_out() {
618        let entries = vec![entry("t1", 1, "we shipped the Deploy Plan")];
619        assert_eq!(grep(&entries, "deploy plan", 10).unwrap().len(), 1);
620        assert!(
621            grep(&entries, "(?-i)deploy plan", 10).unwrap().is_empty(),
622            "an inline (?-i) restores case sensitivity"
623        );
624    }
625
626    #[test]
627    fn grep_returns_one_hit_per_turn_newest_first() {
628        let entries = vec![
629            entry("old", 1, "deploy the flag"),
630            entry("noisy", 2, "deploy one"),
631            entry("noisy", 3, "deploy two"),
632            entry("new", 4, "deploy again"),
633        ];
634        let hits = grep(&entries, "deploy", 10).unwrap();
635        let ids: Vec<&str> = hits.iter().map(|h| h.turn_id.as_str()).collect();
636        assert_eq!(ids, vec!["new", "noisy", "old"], "newest turn first");
637        assert_eq!(
638            hits[1].position, 3,
639            "a turn surfaces once, via its newest matching entry"
640        );
641    }
642
643    #[test]
644    fn grep_limit_caps_the_hits() {
645        let entries = vec![
646            entry("t1", 1, "deploy a"),
647            entry("t2", 2, "deploy b"),
648            entry("t3", 3, "deploy c"),
649        ];
650        let hits = grep(&entries, "deploy", 2).unwrap();
651        assert_eq!(hits.len(), 2);
652        assert_eq!(hits[0].turn_id, "t3", "the newest survive the cap");
653    }
654
655    #[test]
656    fn grep_snippet_is_bounded_and_contains_the_match() {
657        let filler = "padding ".repeat(200); // ~1600 chars of noise
658        let text = format!("{filler}the marker INC-99 appears here {filler}");
659        let hits = grep(&[entry("t1", 1, &text)], r"INC-\d+", 10).unwrap();
660        assert_eq!(hits.len(), 1);
661        assert!(
662            hits[0].snippet.chars().count() <= MAX_SNIPPET_CHARS,
663            "snippet {} chars exceeds cap",
664            hits[0].snippet.chars().count()
665        );
666        assert!(
667            hits[0].snippet.contains("INC-99"),
668            "snippet must show the match: {:?}",
669            hits[0].snippet
670        );
671    }
672
673    #[test]
674    fn grep_is_unicode_safe_when_windowing() {
675        // Multi-byte chars before the match: the byte→char conversion must not
676        // drift or split a boundary when the window lands mid-text.
677        let head = "🚀".repeat(500); // well past MAX_SNIPPET_CHARS, 4 bytes each
678        let text = format!("{head} 部署计划 done");
679        let hits = grep(&[entry("t1", 1, &text)], "部署计划", 10).unwrap();
680        assert_eq!(hits.len(), 1);
681        assert!(
682            hits[0].snippet.contains("部署计划"),
683            "snippet must contain the match: {:?}",
684            hits[0].snippet
685        );
686    }
687
688    #[test]
689    fn grep_rejects_an_empty_pattern() {
690        let entries = vec![entry("t1", 1, "anything")];
691        assert!(matches!(
692            grep(&entries, "", 10),
693            Err(GrepError::EmptyPattern)
694        ));
695    }
696
697    #[test]
698    fn grep_rejects_an_oversized_pattern() {
699        let pattern = "a".repeat(MAX_PATTERN_CHARS + 1);
700        assert!(matches!(
701            grep(&[], &pattern, 10),
702            Err(GrepError::PatternTooLong)
703        ));
704    }
705
706    #[test]
707    fn grep_rejects_a_pattern_that_does_not_compile() {
708        let err = grep(&[], "[unclosed", 10).unwrap_err();
709        assert!(
710            matches!(&err, GrepError::InvalidPattern(msg) if !msg.is_empty()),
711            "{err:?}"
712        );
713    }
714
715    #[test]
716    fn grep_rejects_a_pattern_whose_program_would_balloon() {
717        // Nested counted repetition multiplies the compiled program (a million
718        // copies of `a` here) far past the size cap. The engine is linear-time
719        // in the haystack, so program size is the guarded resource.
720        let err = grep(&[], "(?:a{1000}){1000}", 10).unwrap_err();
721        assert!(matches!(err, GrepError::InvalidPattern(_)), "{err:?}");
722    }
723
724    #[test]
725    fn peek_joins_a_turns_entries_in_order() {
726        let entries = vec![
727            entry("t1", 1, "the user asked about deploys"),
728            entry("t1", 2, "the assistant explained the pipeline"),
729            entry("t2", 3, "an unrelated later turn"),
730        ];
731        let peeked = peek(&entries, "t1").expect("t1 present");
732        assert_eq!(peeked.turn_id, "t1");
733        assert!(!peeked.truncated);
734        assert_eq!(
735            peeked.text,
736            "the user asked about deploys\nthe assistant explained the pipeline"
737        );
738    }
739
740    #[test]
741    fn peek_of_an_unknown_turn_is_none_not_empty() {
742        let entries = vec![entry("t1", 1, "only turn")];
743        assert!(
744            peek(&entries, "does-not-exist").is_none(),
745            "a peek at a turn that isn't in history must fail loud, not read as empty"
746        );
747    }
748
749    #[test]
750    fn peek_middle_elides_an_oversized_turn_keeping_head_and_tail() {
751        let head = "HEAD ".repeat(1_000); // ~5000 chars
752        let tail = "TAIL ".repeat(1_000);
753        let text = format!("{head}MIDDLE-SECRET{tail}");
754        let peeked = peek(&[entry("t1", 1, &text)], "t1").expect("t1");
755        assert!(peeked.truncated, "an oversized turn is elided");
756        assert!(peeked.text.chars().count() <= MAX_PEEK_CHARS);
757        assert!(peeked.text.starts_with("HEAD "), "head kept");
758        assert!(peeked.text.trim_end().ends_with("TAIL"), "tail kept");
759        assert!(peeked.text.contains("elided"), "the cut is marked");
760    }
761
762    #[test]
763    fn middle_elide_honors_a_cap_smaller_than_the_marker() {
764        // Degenerate cap: the output must still fit the cap (hard head
765        // truncation), never emit a marker that overflows it.
766        let (out, truncated) = middle_elide("abcdefghijklmnop", 4);
767        assert!(truncated);
768        assert_eq!(out.chars().count(), 4);
769        assert_eq!(out, "abcd");
770    }
771
772    #[test]
773    fn peek_is_unicode_safe_when_eliding() {
774        // A turn of multi-byte chars over the cap must elide without panicking
775        // on a byte boundary.
776        let text = "🚀".repeat(MAX_PEEK_CHARS + 500);
777        let peeked = peek(&[entry("t1", 1, &text)], "t1").expect("t1");
778        assert!(peeked.truncated);
779        assert!(peeked.text.chars().count() <= MAX_PEEK_CHARS);
780    }
781}