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
20pub 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
55pub 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
161pub 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}