Skip to main content

search_semantically/
format.rs

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        // Regression test for issue 7: U+2019 (3-byte char) near byte 117.
182        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}