1use 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
18const MIN_TOKEN_LEN: usize = 3;
22
23const DOCUMENT_FREQUENCY_CEILING: f64 = 0.8;
28
29const 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
39pub struct RankedFile {
41 pub file: String,
42 pub match_count: usize,
43 pub score: f64,
44 pub preview_lines: Vec<String>,
45}
46
47struct 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
81pub 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 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
157fn 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
190fn 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
245fn 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
271pub 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 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 #[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 #[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 #[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 #[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 #[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 #[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}