Skip to main content

recall_echo/
search.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5use std::fs;
6use std::io::BufRead;
7use std::path::Path;
8
9use crate::error::RecallError;
10use crate::paths;
11
12const BOLD: &str = "\x1b[1m";
13const DIM: &str = "\x1b[2m";
14const CYAN: &str = "\x1b[36m";
15const YELLOW: &str = "\x1b[33m";
16const RESET: &str = "\x1b[0m";
17
18/// Query tokens shorter than this never qualify a file on their own. They are
19/// almost always function words, and because matching is substring-based they
20/// also hit inside unrelated words ("i" matches every file ever written).
21const MIN_TOKEN_LEN: usize = 3;
22
23/// A token present in at least this share of the archive cannot tell one
24/// conversation from another, so it neither qualifies a file nor scores.
25/// Chosen above 0.5 so that a genuinely topical token — an entity that talks
26/// about one project in most of its sessions — survives.
27const DOCUMENT_FREQUENCY_CEILING: f64 = 0.8;
28
29/// Document frequency needs a corpus to mean anything: with two files every
30/// token sits at 0.0, 0.5 or 1.0, and the ceiling would discard real terms.
31const MIN_FILES_FOR_FREQUENCY_FILTER: usize = 5;
32
33pub struct SearchResult {
34    pub file: String,
35    pub line_num: usize,
36    pub line: String,
37}
38
39/// A file-level ranked search result.
40pub struct RankedFile {
41    pub file: String,
42    pub match_count: usize,
43    pub score: f64,
44    pub preview_lines: Vec<String>,
45}
46
47/// One archive file, read once so document frequencies can be measured across
48/// the corpus before any file is scored.
49struct ArchiveFile {
50    name: String,
51    text: String,
52}
53
54pub fn run(query: &str, context_lines: usize) -> Result<(), RecallError> {
55    let base = paths::memory_dir()?;
56    let results = search_with_base(query, &base, context_lines)?;
57
58    if results.is_empty() {
59        eprintln!("No matches found for \"{query}\"");
60        return Ok(());
61    }
62
63    eprintln!(
64        "{BOLD}{} match{} across conversation archives{RESET}\n",
65        results.len(),
66        if results.len() == 1 { "" } else { "es" }
67    );
68
69    let mut current_file = String::new();
70    for result in &results {
71        if result.file != current_file {
72            eprintln!("{CYAN}{}{RESET}", result.file);
73            current_file = result.file.clone();
74        }
75        eprintln!("  {DIM}{:>4}{RESET}  {}", result.line_num, result.line);
76    }
77
78    Ok(())
79}
80
81/// Ranked search: returns files sorted by relevance score.
82///
83/// A file qualifies if it contains *any* discriminative query token — natural
84/// language questions always carry a token no single archive has, so requiring
85/// all of them returns nothing. Ranking then separates the wheat from the
86/// chaff: files matching more distinct tokens, more often, more recently,
87/// score higher.
88pub fn ranked_search(
89    query: &str,
90    base: &Path,
91    max_results: usize,
92) -> Result<Vec<RankedFile>, RecallError> {
93    let files = read_archive_files(base)?;
94    let lowered: Vec<String> = files.iter().map(|f| f.text.to_lowercase()).collect();
95    let query_lower = query.to_lowercase();
96    let tokens = discriminative_tokens(&query_lower, &lowered);
97    if tokens.is_empty() {
98        return Ok(Vec::new());
99    }
100
101    let total_files = files.len();
102    let mut ranked: Vec<RankedFile> = Vec::new();
103
104    for (idx, (file, content_lower)) in files.iter().zip(&lowered).enumerate() {
105        let hits: Vec<usize> = tokens
106            .iter()
107            .map(|t| content_lower.matches(t).count())
108            .collect();
109        let matched_tokens = hits.iter().filter(|&&n| n > 0).count();
110        if matched_tokens == 0 {
111            continue;
112        }
113
114        let word_match_count: usize = hits.iter().sum();
115
116        // Lucene's coordination factor: how much of the query this file covers.
117        // Under the old all-words gate every survivor covered the whole query,
118        // so this term is 1.0 for anything the previous implementation would
119        // have returned — it only separates the newly admitted partial matches.
120        let coverage = matched_tokens as f64 / tokens.len() as f64;
121
122        let recency = if total_files > 1 {
123            0.5 + 0.5 * (idx as f64 / (total_files - 1) as f64)
124        } else {
125            1.0
126        };
127
128        let content_boost = if content_lower.contains(&format!(
129            "### user\n\n{}",
130            query_lower.chars().take(20).collect::<String>()
131        )) {
132            1.5
133        } else {
134            1.0
135        };
136
137        let score = word_match_count as f64 * coverage * recency * content_boost;
138
139        ranked.push(RankedFile {
140            file: file.name.clone(),
141            match_count: word_match_count,
142            score,
143            preview_lines: preview_lines(&file.text, &query_lower, &tokens),
144        });
145    }
146
147    ranked.sort_by(|a, b| {
148        b.score
149            .partial_cmp(&a.score)
150            .unwrap_or(std::cmp::Ordering::Equal)
151    });
152    ranked.truncate(max_results);
153
154    Ok(ranked)
155}
156
157/// Read every `conversation-NNN.md` under `base/conversations`, in filename
158/// order (which is chronological, and is what the recency term assumes).
159fn read_archive_files(base: &Path) -> Result<Vec<ArchiveFile>, RecallError> {
160    let conversations_dir = base.join("conversations");
161    if !conversations_dir.exists() {
162        return Err(RecallError::NotInitialized(
163            "conversations/ directory not found. Run `recall-echo init` first.".into(),
164        ));
165    }
166
167    let mut entries: Vec<_> = fs::read_dir(&conversations_dir)?
168        .filter_map(|e| e.ok())
169        .filter(|e| {
170            let name = e.file_name();
171            let name = name.to_string_lossy();
172            name.starts_with("conversation-") && name.ends_with(".md")
173        })
174        .collect();
175    entries.sort_by_key(|e| e.file_name());
176
177    Ok(entries
178        .iter()
179        .filter_map(|entry| {
180            fs::read_to_string(entry.path())
181                .ok()
182                .map(|text| ArchiveFile {
183                    name: entry.file_name().to_string_lossy().to_string(),
184                    text,
185                })
186        })
187        .collect())
188}
189
190/// The query tokens worth searching on, in query order and without duplicates.
191///
192/// Three filters, each falling back to the previous stage if it would leave
193/// nothing to search for:
194/// 1. outer punctuation stripped, so `bowl?` and `[current` match their words;
195/// 2. tokens under [`MIN_TOKEN_LEN`], or with no letter at all, dropped;
196/// 3. tokens above [`DOCUMENT_FREQUENCY_CEILING`] dropped — a corpus-derived
197///    stop list, which needs no hard-coded word list and works in any language.
198///
199/// `lowered` is the archive corpus, already lowercased, one entry per file.
200fn discriminative_tokens<'a>(query_lower: &'a str, lowered: &[String]) -> Vec<&'a str> {
201    let normalized = dedup_preserving_order(
202        query_lower
203            .split_whitespace()
204            .map(|w| w.trim_matches(|c: char| !c.is_alphanumeric()))
205            .filter(|w| !w.is_empty()),
206    );
207
208    let meaningful: Vec<&str> = normalized
209        .iter()
210        .copied()
211        .filter(|w| w.chars().count() >= MIN_TOKEN_LEN && w.chars().any(char::is_alphabetic))
212        .collect();
213    let meaningful = if meaningful.is_empty() {
214        normalized
215    } else {
216        meaningful
217    };
218
219    if lowered.len() < MIN_FILES_FOR_FREQUENCY_FILTER {
220        return meaningful;
221    }
222
223    let ceiling = DOCUMENT_FREQUENCY_CEILING * lowered.len() as f64;
224    let discriminative: Vec<&str> = meaningful
225        .iter()
226        .copied()
227        .filter(|token| {
228            let document_frequency = lowered.iter().filter(|text| text.contains(token)).count();
229            (document_frequency as f64) < ceiling
230        })
231        .collect();
232
233    if discriminative.is_empty() {
234        meaningful
235    } else {
236        discriminative
237    }
238}
239
240fn dedup_preserving_order<'a>(tokens: impl Iterator<Item = &'a str>) -> Vec<&'a str> {
241    let mut seen = std::collections::HashSet::new();
242    tokens.filter(|t| seen.insert(*t)).collect()
243}
244
245/// Up to three prose lines from the file that carry the query phrase or one of
246/// its tokens. Headings, rules and fences are skipped: they are archive
247/// structure, not conversation content.
248fn preview_lines(content: &str, query_lower: &str, tokens: &[&str]) -> Vec<String> {
249    let mut previews = Vec::new();
250    for line in content.lines() {
251        let line_lower = line.to_lowercase();
252        if !line_lower.contains(query_lower) && !tokens.iter().any(|t| line_lower.contains(t)) {
253            continue;
254        }
255        let trimmed = line.trim();
256        if trimmed.is_empty()
257            || trimmed.starts_with('#')
258            || trimmed.starts_with("---")
259            || trimmed.starts_with("```")
260        {
261            continue;
262        }
263        previews.push(trimmed.to_string());
264        if previews.len() >= 3 {
265            break;
266        }
267    }
268    previews
269}
270
271/// Run ranked search and display results.
272pub fn run_ranked(query: &str, max_results: usize) -> Result<(), RecallError> {
273    let base = paths::memory_dir()?;
274    let results = ranked_search(query, &base, max_results)?;
275
276    if results.is_empty() {
277        eprintln!("No matches found for \"{query}\"");
278        return Ok(());
279    }
280
281    eprintln!(
282        "{BOLD}{} conversation{} matching \"{query}\"{RESET}\n",
283        results.len(),
284        if results.len() == 1 { "" } else { "s" }
285    );
286
287    for (i, result) in results.iter().enumerate() {
288        eprintln!(
289            "  {CYAN}{}. {}{RESET}  {DIM}({} matches, score {:.1}){RESET}",
290            i + 1,
291            result.file,
292            result.match_count,
293            result.score
294        );
295        for preview in &result.preview_lines {
296            let highlighted = highlight_match(preview, query);
297            eprintln!("     {highlighted}");
298        }
299        if i < results.len() - 1 {
300            eprintln!();
301        }
302    }
303
304    Ok(())
305}
306
307pub fn search_with_base(
308    query: &str,
309    base: &Path,
310    context_lines: usize,
311) -> Result<Vec<SearchResult>, RecallError> {
312    let conversations_dir = base.join("conversations");
313    if !conversations_dir.exists() {
314        return Err(RecallError::NotInitialized(
315            "conversations/ directory not found. Run `recall-echo init` first.".into(),
316        ));
317    }
318
319    let query_lower = query.to_lowercase();
320    let mut results = Vec::new();
321
322    let mut files: Vec<_> = fs::read_dir(&conversations_dir)?
323        .filter_map(|e| e.ok())
324        .filter(|e| {
325            let name = e.file_name();
326            let name = name.to_string_lossy();
327            name.starts_with("conversation-") && name.ends_with(".md")
328        })
329        .collect();
330    files.sort_by_key(|e| e.file_name());
331
332    for entry in &files {
333        let file = std::io::BufReader::new(fs::File::open(entry.path())?);
334
335        let lines: Vec<String> = file.lines().map_while(Result::ok).collect();
336        let filename = entry.file_name().to_string_lossy().to_string();
337
338        for (i, line) in lines.iter().enumerate() {
339            if line.to_lowercase().contains(&query_lower) {
340                let start = i.saturating_sub(context_lines);
341                for (ci, ctx_line) in lines.iter().enumerate().take(i).skip(start) {
342                    results.push(SearchResult {
343                        file: filename.clone(),
344                        line_num: ci + 1,
345                        line: format!("{DIM}{ctx_line}{RESET}"),
346                    });
347                }
348
349                let highlighted = highlight_match(line, query);
350                results.push(SearchResult {
351                    file: filename.clone(),
352                    line_num: i + 1,
353                    line: highlighted,
354                });
355
356                let end = (i + context_lines + 1).min(lines.len());
357                for (ci, ctx_line) in lines.iter().enumerate().take(end).skip(i + 1) {
358                    results.push(SearchResult {
359                        file: filename.clone(),
360                        line_num: ci + 1,
361                        line: format!("{DIM}{ctx_line}{RESET}"),
362                    });
363                }
364            }
365        }
366    }
367
368    Ok(results)
369}
370
371fn highlight_match(line: &str, query: &str) -> String {
372    let lower_line = line.to_lowercase();
373    let lower_query = query.to_lowercase();
374
375    let mut result = String::new();
376    let mut pos = 0;
377
378    while let Some(found) = lower_line[pos..].find(&lower_query) {
379        let abs_pos = pos + found;
380        result.push_str(&line[pos..abs_pos]);
381        result.push_str(YELLOW);
382        result.push_str(BOLD);
383        result.push_str(&line[abs_pos..abs_pos + query.len()]);
384        result.push_str(RESET);
385        pos = abs_pos + query.len();
386    }
387    result.push_str(&line[pos..]);
388
389    result
390}
391
392#[cfg(test)]
393mod tests {
394    use super::*;
395
396    #[test]
397    fn search_finds_matches() {
398        let tmp = tempfile::tempdir().unwrap();
399        let base = tmp.path();
400        let conv_dir = base.join("conversations");
401        fs::create_dir_all(&conv_dir).unwrap();
402
403        fs::write(
404            conv_dir.join("conversation-001.md"),
405            "# Conversation 001\n\n### User\n\nHow do I refactor auth?\n\n### Assistant\n\nLet me check the auth module.\n",
406        ).unwrap();
407
408        let results = search_with_base("auth", base, 0).unwrap();
409        assert_eq!(results.len(), 2);
410    }
411
412    #[test]
413    fn search_case_insensitive() {
414        let tmp = tempfile::tempdir().unwrap();
415        let base = tmp.path();
416        let conv_dir = base.join("conversations");
417        fs::create_dir_all(&conv_dir).unwrap();
418
419        fs::write(
420            conv_dir.join("conversation-001.md"),
421            "JWT tokens are great\n",
422        )
423        .unwrap();
424
425        let results = search_with_base("jwt", base, 0).unwrap();
426        assert_eq!(results.len(), 1);
427    }
428
429    #[test]
430    fn search_no_matches() {
431        let tmp = tempfile::tempdir().unwrap();
432        let base = tmp.path();
433        let conv_dir = base.join("conversations");
434        fs::create_dir_all(&conv_dir).unwrap();
435
436        fs::write(conv_dir.join("conversation-001.md"), "hello world\n").unwrap();
437
438        let results = search_with_base("nonexistent", base, 0).unwrap();
439        assert!(results.is_empty());
440    }
441
442    #[test]
443    fn search_with_context() {
444        let tmp = tempfile::tempdir().unwrap();
445        let base = tmp.path();
446        let conv_dir = base.join("conversations");
447        fs::create_dir_all(&conv_dir).unwrap();
448
449        fs::write(
450            conv_dir.join("conversation-001.md"),
451            "line one\nline two\nfind this\nline four\nline five\n",
452        )
453        .unwrap();
454
455        let results = search_with_base("find this", base, 1).unwrap();
456        assert_eq!(results.len(), 3);
457    }
458
459    #[test]
460    fn search_missing_dir() {
461        let tmp = tempfile::tempdir().unwrap();
462        let result = search_with_base("test", tmp.path(), 0);
463        assert!(result.is_err());
464    }
465
466    // ── ranked_search ────────────────────────────────────────────────────
467
468    /// Write `contents[i]` as `conversation-{i+1:03}.md` and return the base.
469    fn archive_with(contents: &[&str]) -> tempfile::TempDir {
470        let tmp = tempfile::tempdir().unwrap();
471        let conv_dir = tmp.path().join("conversations");
472        fs::create_dir_all(&conv_dir).unwrap();
473        for (i, body) in contents.iter().enumerate() {
474            fs::write(conv_dir.join(format!("conversation-{:03}.md", i + 1)), body).unwrap();
475        }
476        tmp
477    }
478
479    fn ranked_files(results: &[RankedFile]) -> Vec<&str> {
480        results.iter().map(|r| r.file.as_str()).collect()
481    }
482
483    /// The defect this replaces: `ranked_search` required *every* whitespace
484    /// token to be present, so a natural-language question carrying a date
485    /// prefix and punctuation matched nothing at all. Partial matches must now
486    /// come back.
487    #[test]
488    fn ranked_search_admits_partial_token_matches() {
489        let tmp = archive_with(&[
490            "### User\n\nI tried a new barbecue sauce today.\n",
491            "### User\n\nMy favourite barbecue sauce is Kansas City Masterpiece.\n",
492            "### User\n\nWe talked about bicycle maintenance.\n",
493        ]);
494
495        let results = ranked_search(
496            "[Current date: 2023-05-30T15:43:00Z] What is my favourite barbecue sauce?",
497            tmp.path(),
498            5,
499        )
500        .unwrap();
501
502        assert_eq!(
503            ranked_files(&results)[0],
504            "conversation-002.md",
505            "the file covering the most query tokens must rank first"
506        );
507        assert!(
508            ranked_files(&results).contains(&"conversation-001.md"),
509            "a file matching only some tokens must still be returned"
510        );
511    }
512
513    /// Outer punctuation must not be part of the token: `sauce?` has to match
514    /// `sauce`.
515    #[test]
516    fn ranked_search_strips_punctuation_from_tokens() {
517        let tmp = archive_with(&["### User\n\nThe barbecue sauce was excellent.\n"]);
518        let results = ranked_search("barbecue sauce?", tmp.path(), 5).unwrap();
519        assert_eq!(results.len(), 1);
520        assert!(results[0].match_count >= 2);
521    }
522
523    /// Covering more distinct query tokens beats repeating one of them at the
524    /// same raw match count — enough to overcome a recency disadvantage.
525    #[test]
526    fn ranked_search_prefers_broader_token_coverage() {
527        let tmp = archive_with(&[
528            "sourdough baguette convection\n",
529            "sourdough sourdough sourdough\n",
530        ]);
531
532        let results = ranked_search("sourdough baguette convection", tmp.path(), 5).unwrap();
533        assert_eq!(results[0].match_count, results[1].match_count);
534        assert_eq!(ranked_files(&results)[0], "conversation-001.md");
535    }
536
537    /// A token in every archive discriminates nothing, so it must not qualify
538    /// files on its own. Needs at least MIN_FILES_FOR_FREQUENCY_FILTER files
539    /// for document frequency to be measurable.
540    #[test]
541    fn ranked_search_ignores_corpus_wide_tokens() {
542        let mut bodies = vec!["### User\n\nToday the weather was fine.\n"; 5];
543        bodies.push("### User\n\nToday the weather was fine and I bought emerald earrings.\n");
544        let tmp = archive_with(&bodies);
545
546        let results =
547            ranked_search("Today what about the emerald earrings", tmp.path(), 10).unwrap();
548
549        assert_eq!(
550            ranked_files(&results),
551            vec!["conversation-006.md"],
552            "only `emerald`/`earrings` discriminate; `today`, `the` and `about` are everywhere"
553        );
554    }
555
556    /// Nothing shared with the corpus still means no results — OR-matching
557    /// loosens the gate, it does not remove it.
558    #[test]
559    fn ranked_search_returns_nothing_without_a_token_match() {
560        let tmp = archive_with(&["### User\n\nWe discussed rust lifetimes.\n"]);
561        let results = ranked_search("photosynthesis chlorophyll", tmp.path(), 5).unwrap();
562        assert!(results.is_empty());
563    }
564
565    /// A query made entirely of sub-MIN_TOKEN_LEN words must fall back to those
566    /// words rather than degrade to "no discriminative tokens, no results".
567    #[test]
568    fn ranked_search_falls_back_to_short_tokens() {
569        let tmp = archive_with(&["### User\n\nThe ok signal arrived.\n"]);
570        let results = ranked_search("ok", tmp.path(), 5).unwrap();
571        assert_eq!(results.len(), 1);
572    }
573
574    #[test]
575    fn ranked_search_honours_max_results() {
576        let tmp = archive_with(&[
577            "### User\n\nbarbecue one\n",
578            "### User\n\nbarbecue two\n",
579            "### User\n\nbarbecue three\n",
580        ]);
581        let results = ranked_search("barbecue", tmp.path(), 2).unwrap();
582        assert_eq!(results.len(), 2);
583    }
584
585    #[test]
586    fn ranked_search_previews_only_prose() {
587        let tmp = archive_with(&["# Conversation 001\n\n---\n\n### User\n\nbarbecue sauce here\n"]);
588        let results = ranked_search("barbecue", tmp.path(), 5).unwrap();
589        assert_eq!(results[0].preview_lines, vec!["barbecue sauce here"]);
590    }
591
592    #[test]
593    fn ranked_search_missing_dir() {
594        let tmp = tempfile::tempdir().unwrap();
595        assert!(ranked_search("anything", tmp.path(), 5).is_err());
596    }
597}