1use crate::db::StoredChunk;
2use crate::ranker::MetricScores;
3
4pub struct SearchResult {
5 pub chunk: StoredChunk,
6 pub scores: MetricScores,
7 pub rank: usize,
8}
9
10pub fn format_results(results: &[SearchResult]) -> String {
11 if results.is_empty() {
12 return "No results found.".to_string();
13 }
14
15 let mut lines = Vec::new();
16
17 for (i, r) in results.iter().enumerate() {
18 let line_range = if r.chunk.start_line == r.chunk.end_line {
19 format!("L{}", r.chunk.start_line)
20 } else {
21 format!("L{}-{}", r.chunk.start_line, r.chunk.end_line)
22 };
23
24 let kind_label = match &r.chunk.name {
25 Some(name) => format!("{} {}", r.chunk.kind, name),
26 None => r.chunk.kind.clone(),
27 };
28
29 lines.push(format!(
30 "{}. {}:{} ({})",
31 i + 1,
32 r.chunk.file_path,
33 line_range,
34 kind_label
35 ));
36
37 let score_pairs = [
38 ("bm25", r.scores.bm25),
39 ("cosine", r.scores.cosine),
40 ("pathMatch", r.scores.path_match),
41 ("symbolMatch", r.scores.symbol_match),
42 ("importGraph", r.scores.import_graph),
43 ("gitRecency", r.scores.git_recency),
44 ];
45 let top_scores: Vec<String> = score_pairs
46 .iter()
47 .filter(|(_, v)| *v > 0.01)
48 .map(|(k, v)| format!("{}={:.2}", k, v))
49 .collect();
50 if !top_scores.is_empty() {
51 lines.push(format!(" scores: {}", top_scores.join(" ")));
52 }
53
54 let content_lines: Vec<&str> = r.chunk.content.lines().collect();
55 for line in content_lines.iter().take(3) {
56 let trimmed = crate::util::truncate_with_ellipsis(line, 120);
57 lines.push(format!(" {trimmed}"));
58 }
59 if content_lines.len() > 3 {
60 lines.push(format!(" ... ({} more lines)", content_lines.len() - 3));
61 }
62
63 if i < results.len() - 1 {
64 lines.push(String::new());
65 }
66 }
67
68 lines.join("\n")
69}
70
71#[cfg(test)]
72mod tests {
73 use super::*;
74
75 fn make_result(
76 file_path: &str,
77 start_line: i64,
78 end_line: i64,
79 kind: &str,
80 name: Option<&str>,
81 content: &str,
82 ) -> SearchResult {
83 SearchResult {
84 chunk: StoredChunk {
85 id: 1,
86 file_id: 1,
87 file_path: file_path.to_string(),
88 start_line,
89 end_line,
90 kind: kind.to_string(),
91 name: name.map(String::from),
92 content: content.to_string(),
93 file_type: "rust".to_string(),
94 },
95 scores: MetricScores {
96 bm25: 0.8,
97 cosine: 0.5,
98 path_match: 0.0,
99 symbol_match: 0.3,
100 import_graph: 0.0,
101 git_recency: 0.7,
102 },
103 rank: 0,
104 }
105 }
106
107 #[test]
108 fn empty_results_returns_no_results() {
109 let output = format_results(&[]);
110 assert_eq!(output, "No results found.");
111 }
112
113 #[test]
114 fn single_result_formats_correctly() {
115 let result = make_result(
116 "src/main.rs",
117 1,
118 5,
119 "function",
120 Some("main"),
121 "fn main() {}",
122 );
123 let output = format_results(&[result]);
124
125 assert!(output.contains("src/main.rs:L1-5"));
126 assert!(output.contains("function main"));
127 assert!(output.contains("scores:"));
128 assert!(output.contains("bm25="));
129 }
130
131 #[test]
132 fn single_line_result_shows_l_prefix() {
133 let result = make_result(
134 "test.rs",
135 42,
136 42,
137 "function",
138 Some("helper"),
139 "fn helper() {}",
140 );
141 let output = format_results(&[result]);
142 assert!(output.contains("L42"));
143 assert!(!output.contains("L42-"));
144 }
145
146 #[test]
147 fn result_without_name_shows_kind_only() {
148 let result = make_result("test.rs", 1, 10, "file", None, "some content");
149 let output = format_results(&[result]);
150 assert!(output.contains("(file)"));
151 }
152
153 #[test]
154 fn long_content_preview_truncated() {
155 let long_line: String = "x".repeat(200);
156 let result = make_result("test.rs", 1, 1, "file", None, &long_line);
157 let output = format_results(&[result]);
158 assert!(output.contains("..."));
159 }
160
161 #[test]
162 fn multi_line_content_shows_more_lines_indicator() {
163 let content = "line1\nline2\nline3\nline4\nline5";
164 let result = make_result("test.rs", 1, 5, "file", None, content);
165 let output = format_results(&[result]);
166 assert!(output.contains("2 more lines"));
167 }
168
169 #[test]
170 fn zero_scores_are_omitted() {
171 let mut result = make_result("test.rs", 1, 1, "file", None, "code");
172 result.scores.path_match = 0.0;
173 result.scores.import_graph = 0.0;
174 let output = format_results(&[result]);
175 assert!(!output.contains("pathMatch="));
176 assert!(!output.contains("importGraph="));
177 }
178
179 #[test]
180 fn long_line_with_multibyte_char_does_not_panic() {
181 let prefix = "x".repeat(115);
183 let long_line = format!("{prefix}\u{2019}some more trailing text here");
184 let result = make_result("test.rs", 1, 1, "file", None, &long_line);
185 let output = format_results(&[result]);
186 assert!(output.contains("..."));
187 for line in output.lines() {
188 let content = line.trim_start();
189 assert!(content.len() <= 120, "preview line exceeded 120 bytes");
190 }
191 }
192}