Skip to main content

recall_echo/
search.rs

1use std::fs;
2use std::io::BufRead;
3use std::path::Path;
4
5use crate::paths;
6
7const BOLD: &str = "\x1b[1m";
8const DIM: &str = "\x1b[2m";
9const CYAN: &str = "\x1b[36m";
10const YELLOW: &str = "\x1b[33m";
11const RESET: &str = "\x1b[0m";
12
13pub struct SearchResult {
14    pub file: String,
15    pub line_num: usize,
16    pub line: String,
17}
18
19/// A file-level ranked search result.
20pub struct RankedFile {
21    pub file: String,
22    pub match_count: usize,
23    pub score: f64,
24    pub preview_lines: Vec<String>,
25}
26
27pub fn run(query: &str, context_lines: usize) -> Result<(), String> {
28    let base = paths::memory_dir()?;
29    let results = search_with_base(query, &base, context_lines)?;
30
31    if results.is_empty() {
32        eprintln!("No matches found for \"{query}\"");
33        return Ok(());
34    }
35
36    eprintln!(
37        "{BOLD}{} match{} across conversation archives{RESET}\n",
38        results.len(),
39        if results.len() == 1 { "" } else { "es" }
40    );
41
42    let mut current_file = String::new();
43    for result in &results {
44        if result.file != current_file {
45            eprintln!("{CYAN}{}{RESET}", result.file);
46            current_file = result.file.clone();
47        }
48        eprintln!("  {DIM}{:>4}{RESET}  {}", result.line_num, result.line);
49    }
50
51    Ok(())
52}
53
54/// Ranked search: returns files sorted by relevance score.
55pub fn ranked_search(
56    query: &str,
57    base: &Path,
58    max_results: usize,
59) -> Result<Vec<RankedFile>, String> {
60    let conversations_dir = base.join("conversations");
61    if !conversations_dir.exists() {
62        return Err(
63            "conversations/ directory not found. Run `recall-echo init` first.".to_string(),
64        );
65    }
66
67    let query_lower = query.to_lowercase();
68    let query_words: Vec<&str> = query_lower.split_whitespace().collect();
69    let mut ranked: Vec<RankedFile> = Vec::new();
70
71    let mut files: Vec<_> = fs::read_dir(&conversations_dir)
72        .map_err(|e| format!("Failed to read conversations directory: {e}"))?
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<(), String> {
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>, String> {
202    let conversations_dir = base.join("conversations");
203    if !conversations_dir.exists() {
204        return Err(
205            "conversations/ directory not found. Run `recall-echo init` first.".to_string(),
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        .map_err(|e| format!("Failed to read conversations directory: {e}"))?
214        .filter_map(|e| e.ok())
215        .filter(|e| {
216            let name = e.file_name();
217            let name = name.to_string_lossy();
218            name.starts_with("conversation-") && name.ends_with(".md")
219        })
220        .collect();
221    files.sort_by_key(|e| e.file_name());
222
223    for entry in &files {
224        let file = std::io::BufReader::new(
225            fs::File::open(entry.path())
226                .map_err(|e| format!("Failed to open {}: {e}", entry.path().display()))?,
227        );
228
229        let lines: Vec<String> = file.lines().map_while(Result::ok).collect();
230        let filename = entry.file_name().to_string_lossy().to_string();
231
232        for (i, line) in lines.iter().enumerate() {
233            if line.to_lowercase().contains(&query_lower) {
234                let start = i.saturating_sub(context_lines);
235                for (ci, ctx_line) in lines.iter().enumerate().take(i).skip(start) {
236                    results.push(SearchResult {
237                        file: filename.clone(),
238                        line_num: ci + 1,
239                        line: format!("{DIM}{ctx_line}{RESET}"),
240                    });
241                }
242
243                let highlighted = highlight_match(line, query);
244                results.push(SearchResult {
245                    file: filename.clone(),
246                    line_num: i + 1,
247                    line: highlighted,
248                });
249
250                let end = (i + context_lines + 1).min(lines.len());
251                for (ci, ctx_line) in lines.iter().enumerate().take(end).skip(i + 1) {
252                    results.push(SearchResult {
253                        file: filename.clone(),
254                        line_num: ci + 1,
255                        line: format!("{DIM}{ctx_line}{RESET}"),
256                    });
257                }
258            }
259        }
260    }
261
262    Ok(results)
263}
264
265fn highlight_match(line: &str, query: &str) -> String {
266    let lower_line = line.to_lowercase();
267    let lower_query = query.to_lowercase();
268
269    let mut result = String::new();
270    let mut pos = 0;
271
272    while let Some(found) = lower_line[pos..].find(&lower_query) {
273        let abs_pos = pos + found;
274        result.push_str(&line[pos..abs_pos]);
275        result.push_str(YELLOW);
276        result.push_str(BOLD);
277        result.push_str(&line[abs_pos..abs_pos + query.len()]);
278        result.push_str(RESET);
279        pos = abs_pos + query.len();
280    }
281    result.push_str(&line[pos..]);
282
283    result
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289
290    #[test]
291    fn search_finds_matches() {
292        let tmp = tempfile::tempdir().unwrap();
293        let base = tmp.path();
294        let conv_dir = base.join("conversations");
295        fs::create_dir_all(&conv_dir).unwrap();
296
297        fs::write(
298            conv_dir.join("conversation-001.md"),
299            "# Conversation 001\n\n### User\n\nHow do I refactor auth?\n\n### Assistant\n\nLet me check the auth module.\n",
300        ).unwrap();
301
302        let results = search_with_base("auth", base, 0).unwrap();
303        assert_eq!(results.len(), 2);
304    }
305
306    #[test]
307    fn search_case_insensitive() {
308        let tmp = tempfile::tempdir().unwrap();
309        let base = tmp.path();
310        let conv_dir = base.join("conversations");
311        fs::create_dir_all(&conv_dir).unwrap();
312
313        fs::write(
314            conv_dir.join("conversation-001.md"),
315            "JWT tokens are great\n",
316        )
317        .unwrap();
318
319        let results = search_with_base("jwt", base, 0).unwrap();
320        assert_eq!(results.len(), 1);
321    }
322
323    #[test]
324    fn search_no_matches() {
325        let tmp = tempfile::tempdir().unwrap();
326        let base = tmp.path();
327        let conv_dir = base.join("conversations");
328        fs::create_dir_all(&conv_dir).unwrap();
329
330        fs::write(conv_dir.join("conversation-001.md"), "hello world\n").unwrap();
331
332        let results = search_with_base("nonexistent", base, 0).unwrap();
333        assert!(results.is_empty());
334    }
335
336    #[test]
337    fn search_with_context() {
338        let tmp = tempfile::tempdir().unwrap();
339        let base = tmp.path();
340        let conv_dir = base.join("conversations");
341        fs::create_dir_all(&conv_dir).unwrap();
342
343        fs::write(
344            conv_dir.join("conversation-001.md"),
345            "line one\nline two\nfind this\nline four\nline five\n",
346        )
347        .unwrap();
348
349        let results = search_with_base("find this", base, 1).unwrap();
350        assert_eq!(results.len(), 3);
351    }
352
353    #[test]
354    fn search_missing_dir() {
355        let tmp = tempfile::tempdir().unwrap();
356        let result = search_with_base("test", tmp.path(), 0);
357        assert!(result.is_err());
358    }
359}