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
19pub 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
54pub 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
161pub 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}