1#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct HistoryEntry {
21 pub turn_id: String,
23 pub position: u64,
25 pub text: String,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct HistoryHit {
33 pub turn_id: String,
35 pub position: u64,
37 pub snippet: String,
39}
40
41#[must_use]
48pub fn has_searchable_terms(query: &str) -> bool {
49 !terms_of(query).is_empty()
50}
51
52#[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 let use_substring = terms.iter().any(|t| !t.is_ascii());
71 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 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
118const MAX_SNIPPET_CHARS: usize = 200;
122
123#[derive(Debug, Clone, PartialEq, Eq)]
125pub struct TurnText {
126 pub turn_id: String,
128 pub text: String,
131 pub truncated: bool,
134}
135
136pub const MAX_PEEK_CHARS: usize = 8_000;
144
145#[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
171pub const MAX_PATTERN_CHARS: usize = 512;
178
179const REGEX_SIZE_LIMIT: usize = 1 << 18;
186
187#[derive(Debug, thiserror::Error)]
193pub enum GrepError {
194 #[error("pattern is empty")]
197 EmptyPattern,
198 #[error("pattern is longer than {MAX_PATTERN_CHARS} characters")]
200 PatternTooLong,
201 #[error("pattern does not compile: {0}")]
205 InvalidPattern(String),
206}
207
208pub 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 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 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
274const ELISION_MARKER: &str = "\n…[middle elided]…\n";
278
279fn 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 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
304fn 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 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
324fn 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
337fn 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
346fn 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
371fn 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
397fn 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#[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 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); 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 let head = "concatenation ".repeat(30); 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 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 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 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 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); 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 let prefix = "İ".repeat(20);
588 let tail = "padding ".repeat(40); 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); 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 let head = "🚀".repeat(500); 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 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); 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 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 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}