Skip to main content

recall_echo/
search.rs

1use std::fs;
2use std::io::BufRead;
3use std::path::Path;
4
5use crate::error::RecallError;
6use crate::paths;
7
8const BOLD: &str = "\x1b[1m";
9const DIM: &str = "\x1b[2m";
10const CYAN: &str = "\x1b[36m";
11const YELLOW: &str = "\x1b[33m";
12const RESET: &str = "\x1b[0m";
13
14pub struct SearchResult {
15    pub file: String,
16    pub line_num: usize,
17    pub line: String,
18}
19
20/// A file-level ranked search result.
21pub struct RankedFile {
22    pub file: String,
23    pub match_count: usize,
24    pub score: f64,
25    pub preview_lines: Vec<String>,
26}
27
28pub fn run(query: &str, context_lines: usize) -> Result<(), RecallError> {
29    let base = paths::memory_dir()?;
30    let results = search_with_base(query, &base, context_lines)?;
31
32    if results.is_empty() {
33        eprintln!("No matches found for \"{query}\"");
34        return Ok(());
35    }
36
37    eprintln!(
38        "{BOLD}{} match{} across conversation archives{RESET}\n",
39        results.len(),
40        if results.len() == 1 { "" } else { "es" }
41    );
42
43    let mut current_file = String::new();
44    for result in &results {
45        if result.file != current_file {
46            eprintln!("{CYAN}{}{RESET}", result.file);
47            current_file = result.file.clone();
48        }
49        eprintln!("  {DIM}{:>4}{RESET}  {}", result.line_num, result.line);
50    }
51
52    Ok(())
53}
54
55/// Ranked search: returns files sorted by relevance score.
56pub fn ranked_search(
57    query: &str,
58    base: &Path,
59    max_results: usize,
60) -> Result<Vec<RankedFile>, RecallError> {
61    let conversations_dir = base.join("conversations");
62    if !conversations_dir.exists() {
63        return Err(RecallError::NotInitialized(
64            "conversations/ directory not found. Run `recall-echo init` first.".into(),
65        ));
66    }
67
68    let query_lower = query.to_lowercase();
69    let query_words: Vec<&str> = query_lower.split_whitespace().collect();
70    let mut ranked: Vec<RankedFile> = Vec::new();
71
72    let mut files: Vec<_> = fs::read_dir(&conversations_dir)?
73        .filter_map(|e| e.ok())
74        .filter(|e| {
75            let name = e.file_name();
76            let name = name.to_string_lossy();
77            name.starts_with("conversation-") && name.ends_with(".md")
78        })
79        .collect();
80    files.sort_by_key(|e| e.file_name());
81    let total_files = files.len();
82
83    for (idx, entry) in files.iter().enumerate() {
84        let content = match fs::read_to_string(entry.path()) {
85            Ok(c) => c,
86            Err(_) => continue,
87        };
88        let content_lower = content.to_lowercase();
89        let filename = entry.file_name().to_string_lossy().to_string();
90
91        let all_words_present = query_words.iter().all(|w| content_lower.contains(w));
92        if !all_words_present {
93            continue;
94        }
95
96        let match_count = content_lower.matches(&query_lower).count();
97        let word_match_count: usize = if query_words.len() > 1 {
98            query_words
99                .iter()
100                .map(|w| content_lower.matches(w).count())
101                .sum()
102        } else {
103            match_count
104        };
105
106        let recency = if total_files > 1 {
107            0.5 + 0.5 * (idx as f64 / (total_files - 1) as f64)
108        } else {
109            1.0
110        };
111
112        let content_boost = if content_lower.contains(&format!(
113            "### user\n\n{}",
114            query_lower.chars().take(20).collect::<String>()
115        )) {
116            1.5
117        } else {
118            1.0
119        };
120
121        let score = word_match_count as f64 * recency * content_boost;
122
123        let mut preview_lines = Vec::new();
124        for line in content.lines() {
125            if line.to_lowercase().contains(&query_lower)
126                || (query_words.len() > 1
127                    && query_words.iter().any(|w| line.to_lowercase().contains(w)))
128            {
129                let trimmed = line.trim();
130                if !trimmed.is_empty()
131                    && !trimmed.starts_with('#')
132                    && !trimmed.starts_with("---")
133                    && !trimmed.starts_with("```")
134                {
135                    preview_lines.push(trimmed.to_string());
136                    if preview_lines.len() >= 3 {
137                        break;
138                    }
139                }
140            }
141        }
142
143        ranked.push(RankedFile {
144            file: filename,
145            match_count: word_match_count,
146            score,
147            preview_lines,
148        });
149    }
150
151    ranked.sort_by(|a, b| {
152        b.score
153            .partial_cmp(&a.score)
154            .unwrap_or(std::cmp::Ordering::Equal)
155    });
156    ranked.truncate(max_results);
157
158    Ok(ranked)
159}
160
161/// Run ranked search and display results.
162pub fn run_ranked(query: &str, max_results: usize) -> Result<(), RecallError> {
163    let base = paths::memory_dir()?;
164    let results = ranked_search(query, &base, max_results)?;
165
166    if results.is_empty() {
167        eprintln!("No matches found for \"{query}\"");
168        return Ok(());
169    }
170
171    eprintln!(
172        "{BOLD}{} conversation{} matching \"{query}\"{RESET}\n",
173        results.len(),
174        if results.len() == 1 { "" } else { "s" }
175    );
176
177    for (i, result) in results.iter().enumerate() {
178        eprintln!(
179            "  {CYAN}{}. {}{RESET}  {DIM}({} matches, score {:.1}){RESET}",
180            i + 1,
181            result.file,
182            result.match_count,
183            result.score
184        );
185        for preview in &result.preview_lines {
186            let highlighted = highlight_match(preview, query);
187            eprintln!("     {highlighted}");
188        }
189        if i < results.len() - 1 {
190            eprintln!();
191        }
192    }
193
194    Ok(())
195}
196
197pub fn search_with_base(
198    query: &str,
199    base: &Path,
200    context_lines: usize,
201) -> Result<Vec<SearchResult>, RecallError> {
202    let conversations_dir = base.join("conversations");
203    if !conversations_dir.exists() {
204        return Err(RecallError::NotInitialized(
205            "conversations/ directory not found. Run `recall-echo init` first.".into(),
206        ));
207    }
208
209    let query_lower = query.to_lowercase();
210    let mut results = Vec::new();
211
212    let mut files: Vec<_> = fs::read_dir(&conversations_dir)?
213        .filter_map(|e| e.ok())
214        .filter(|e| {
215            let name = e.file_name();
216            let name = name.to_string_lossy();
217            name.starts_with("conversation-") && name.ends_with(".md")
218        })
219        .collect();
220    files.sort_by_key(|e| e.file_name());
221
222    for entry in &files {
223        let file = std::io::BufReader::new(fs::File::open(entry.path())?);
224
225        let lines: Vec<String> = file.lines().map_while(Result::ok).collect();
226        let filename = entry.file_name().to_string_lossy().to_string();
227
228        for (i, line) in lines.iter().enumerate() {
229            if line.to_lowercase().contains(&query_lower) {
230                let start = i.saturating_sub(context_lines);
231                for (ci, ctx_line) in lines.iter().enumerate().take(i).skip(start) {
232                    results.push(SearchResult {
233                        file: filename.clone(),
234                        line_num: ci + 1,
235                        line: format!("{DIM}{ctx_line}{RESET}"),
236                    });
237                }
238
239                let highlighted = highlight_match(line, query);
240                results.push(SearchResult {
241                    file: filename.clone(),
242                    line_num: i + 1,
243                    line: highlighted,
244                });
245
246                let end = (i + context_lines + 1).min(lines.len());
247                for (ci, ctx_line) in lines.iter().enumerate().take(end).skip(i + 1) {
248                    results.push(SearchResult {
249                        file: filename.clone(),
250                        line_num: ci + 1,
251                        line: format!("{DIM}{ctx_line}{RESET}"),
252                    });
253                }
254            }
255        }
256    }
257
258    Ok(results)
259}
260
261fn highlight_match(line: &str, query: &str) -> String {
262    let lower_line = line.to_lowercase();
263    let lower_query = query.to_lowercase();
264
265    let mut result = String::new();
266    let mut pos = 0;
267
268    while let Some(found) = lower_line[pos..].find(&lower_query) {
269        let abs_pos = pos + found;
270        result.push_str(&line[pos..abs_pos]);
271        result.push_str(YELLOW);
272        result.push_str(BOLD);
273        result.push_str(&line[abs_pos..abs_pos + query.len()]);
274        result.push_str(RESET);
275        pos = abs_pos + query.len();
276    }
277    result.push_str(&line[pos..]);
278
279    result
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285
286    #[test]
287    fn search_finds_matches() {
288        let tmp = tempfile::tempdir().unwrap();
289        let base = tmp.path();
290        let conv_dir = base.join("conversations");
291        fs::create_dir_all(&conv_dir).unwrap();
292
293        fs::write(
294            conv_dir.join("conversation-001.md"),
295            "# Conversation 001\n\n### User\n\nHow do I refactor auth?\n\n### Assistant\n\nLet me check the auth module.\n",
296        ).unwrap();
297
298        let results = search_with_base("auth", base, 0).unwrap();
299        assert_eq!(results.len(), 2);
300    }
301
302    #[test]
303    fn search_case_insensitive() {
304        let tmp = tempfile::tempdir().unwrap();
305        let base = tmp.path();
306        let conv_dir = base.join("conversations");
307        fs::create_dir_all(&conv_dir).unwrap();
308
309        fs::write(
310            conv_dir.join("conversation-001.md"),
311            "JWT tokens are great\n",
312        )
313        .unwrap();
314
315        let results = search_with_base("jwt", base, 0).unwrap();
316        assert_eq!(results.len(), 1);
317    }
318
319    #[test]
320    fn search_no_matches() {
321        let tmp = tempfile::tempdir().unwrap();
322        let base = tmp.path();
323        let conv_dir = base.join("conversations");
324        fs::create_dir_all(&conv_dir).unwrap();
325
326        fs::write(conv_dir.join("conversation-001.md"), "hello world\n").unwrap();
327
328        let results = search_with_base("nonexistent", base, 0).unwrap();
329        assert!(results.is_empty());
330    }
331
332    #[test]
333    fn search_with_context() {
334        let tmp = tempfile::tempdir().unwrap();
335        let base = tmp.path();
336        let conv_dir = base.join("conversations");
337        fs::create_dir_all(&conv_dir).unwrap();
338
339        fs::write(
340            conv_dir.join("conversation-001.md"),
341            "line one\nline two\nfind this\nline four\nline five\n",
342        )
343        .unwrap();
344
345        let results = search_with_base("find this", base, 1).unwrap();
346        assert_eq!(results.len(), 3);
347    }
348
349    #[test]
350    fn search_missing_dir() {
351        let tmp = tempfile::tempdir().unwrap();
352        let result = search_with_base("test", tmp.path(), 0);
353        assert!(result.is_err());
354    }
355}