1use hashbrown::HashMap;
6
7#[derive(Debug, Clone)]
9pub struct SearchMetric {
10 pub pattern: String,
12 pub match_count: usize,
14 pub result_tokens: usize,
16 pub duration_ms: u64,
18 pub files_searched: usize,
20 pub is_expensive: bool,
22}
23
24#[derive(Debug, Clone)]
26pub struct SearchMetrics {
27 searches: HashMap<String, SearchMetric>,
29 pub total_tokens: usize,
31 pub total_searches: usize,
33 expensive_threshold: usize,
35}
36
37impl Default for SearchMetrics {
38 fn default() -> Self {
39 Self::new()
40 }
41}
42
43impl SearchMetrics {
44 pub fn new() -> Self {
46 Self {
47 searches: HashMap::new(),
48 total_tokens: 0,
49 total_searches: 0,
50 expensive_threshold: 10000,
51 }
52 }
53
54 pub fn with_expensive_threshold(mut self, threshold: usize) -> Self {
56 self.expensive_threshold = threshold;
57 self
58 }
59
60 pub fn record_search(
62 &mut self,
63 pattern: &str,
64 match_count: usize,
65 result_chars: usize,
66 duration_ms: u64,
67 files_searched: usize,
68 ) {
69 #[allow(
71 clippy::cast_sign_loss,
72 reason = "Intentional compatibility, platform, or test-only suppression."
73 )]
74 let estimated_tokens = ((result_chars as f64 / 4.0).ceil()).max(0.0) as usize;
75 let is_expensive = estimated_tokens > self.expensive_threshold;
76
77 let metric = SearchMetric {
78 pattern: pattern.to_owned(),
79 match_count,
80 result_tokens: estimated_tokens,
81 duration_ms,
82 files_searched,
83 is_expensive,
84 };
85
86 self.total_tokens += estimated_tokens;
87 self.total_searches += 1;
88 self.searches.insert(pattern.to_owned(), metric);
89 }
90
91 pub fn get_search(&self, pattern: &str) -> Option<&SearchMetric> {
93 self.searches.get(pattern)
94 }
95
96 pub fn expensive_searches(&self, limit: usize) -> Vec<&SearchMetric> {
98 let mut searches: Vec<_> = self.searches.values().filter(|s| s.is_expensive).collect();
99 searches.sort_by_key(|a| std::cmp::Reverse(a.result_tokens));
100 searches.into_iter().take(limit).collect()
101 }
102
103 pub fn slowest_searches(&self, limit: usize) -> Vec<&SearchMetric> {
105 let mut searches: Vec<_> = self.searches.values().collect();
106 searches.sort_by_key(|a| std::cmp::Reverse(a.duration_ms));
107 searches.into_iter().take(limit).collect()
108 }
109
110 pub fn avg_tokens_per_search(&self) -> f64 {
112 if self.total_searches == 0 {
113 0.0
114 } else {
115 self.total_tokens as f64 / self.total_searches as f64
116 }
117 }
118
119 pub fn should_sample_results(&self, pattern: &str) -> bool {
121 self.get_search(pattern).map(|m| m.is_expensive).unwrap_or(false)
122 }
123
124 pub fn estimate_sampling_ratio(&self, pattern: &str) -> f64 {
128 if let Some(metric) = self.get_search(pattern) {
129 if !metric.is_expensive {
130 return 1.0;
131 }
132
133 let ratio = self.expensive_threshold as f64 / metric.result_tokens as f64;
135 (ratio * 0.9 + 0.1).clamp(0.1, 1.0)
136 } else {
137 1.0 }
139 }
140
141 pub fn format_summary(&self) -> String {
143 use std::fmt::Write;
144 let mut output = String::with_capacity(512);
146 output.push_str("[SEARCH] Metrics Summary\n");
147 let _ = writeln!(output, " Total searches: {}", self.total_searches);
148 let _ = writeln!(output, " Total tokens: {}", self.total_tokens);
149 let _ = writeln!(output, " Avg tokens/search: {:.0}", self.avg_tokens_per_search());
150 let _ = writeln!(output, " Expensive searches: {}", self.searches.values().filter(|s| s.is_expensive).count());
151
152 let expensive = self.expensive_searches(3);
153 if !expensive.is_empty() {
154 output.push_str("\n Most expensive searches:\n");
155 for (i, metric) in expensive.iter().enumerate() {
156 let _ = writeln!(
157 output,
158 " {}. '{}': {} tokens ({} matches)",
159 i + 1,
160 metric.pattern,
161 metric.result_tokens,
162 metric.match_count
163 );
164 }
165 }
166
167 output
168 }
169
170 pub fn reset(&mut self) {
172 self.searches.clear();
173 self.total_tokens = 0;
174 self.total_searches = 0;
175 }
176
177 pub fn stats(&self) -> SearchMetricsStats {
179 let expensive_count = self.searches.values().filter(|s| s.is_expensive).count();
180 SearchMetricsStats {
181 total_searches: self.total_searches,
182 total_tokens: self.total_tokens,
183 expensive_searches: expensive_count,
184 avg_tokens_per_search: self.avg_tokens_per_search(),
185 }
186 }
187}
188
189#[derive(Debug, Clone)]
191pub struct SearchMetricsStats {
192 pub total_searches: usize,
193 pub total_tokens: usize,
194 pub expensive_searches: usize,
195 pub avg_tokens_per_search: f64,
196}
197
198#[cfg(test)]
199mod tests {
200 use super::*;
201
202 #[test]
203 fn test_creates_metrics() {
204 let metrics = SearchMetrics::new();
205 assert_eq!(metrics.total_searches, 0);
206 assert_eq!(metrics.total_tokens, 0);
207 }
208
209 #[test]
210 fn test_records_search() {
211 let mut metrics = SearchMetrics::new();
212 metrics.record_search("fn main", 5, 1000, 10, 3);
213
214 assert_eq!(metrics.total_searches, 1);
215 assert!(metrics.total_tokens > 0);
216
217 let metric = metrics.get_search("fn main").unwrap();
218 assert_eq!(metric.match_count, 5);
219 assert_eq!(metric.files_searched, 3);
220 }
221
222 #[test]
223 fn test_identifies_expensive_searches() {
224 let mut metrics = SearchMetrics::new().with_expensive_threshold(5000);
225 metrics.record_search("common_pattern", 100, 50000, 50, 50);
227
228 let metric = metrics.get_search("common_pattern").unwrap();
229 assert!(metric.is_expensive);
230 }
231
232 #[test]
233 fn test_expensive_searches() {
234 let mut metrics = SearchMetrics::new().with_expensive_threshold(5000);
235 metrics.record_search("pattern1", 10, 25000, 20, 5);
236 metrics.record_search("pattern2", 5, 2000, 10, 2);
237 metrics.record_search("pattern3", 50, 30000, 100, 20);
238
239 let expensive = metrics.expensive_searches(2);
240 assert_eq!(expensive.len(), 2);
241 assert!(expensive[0].result_tokens > expensive[1].result_tokens);
242 }
243
244 #[test]
245 fn test_slowest_searches() {
246 let mut metrics = SearchMetrics::new();
247 metrics.record_search("fast", 10, 1000, 5, 2);
248 metrics.record_search("slow", 10, 1000, 100, 2);
249 metrics.record_search("medium", 10, 1000, 50, 2);
250
251 let slowest = metrics.slowest_searches(2);
252 assert_eq!(slowest.len(), 2);
253 assert!(slowest[0].duration_ms > slowest[1].duration_ms);
254 }
255
256 #[test]
257 fn test_sampling_ratio() {
258 let mut metrics = SearchMetrics::new().with_expensive_threshold(10000);
259
260 metrics.record_search("cheap", 10, 5000, 10, 5);
262 assert!((metrics.estimate_sampling_ratio("cheap") - 1.0).abs() < f64::EPSILON);
263
264 metrics.record_search("expensive", 100, 50000, 100, 50);
266 let ratio = metrics.estimate_sampling_ratio("expensive");
267 assert!(ratio < 1.0);
268 assert!(ratio >= 0.1);
269 }
270
271 #[test]
272 fn test_average_tokens() {
273 let mut metrics = SearchMetrics::new();
274 metrics.record_search("search1", 10, 4000, 10, 5);
275 metrics.record_search("search2", 5, 8000, 20, 3);
276
277 let avg = metrics.avg_tokens_per_search();
278 assert!(avg > 0.0);
279 }
281
282 #[test]
283 fn test_should_sample_results() {
284 let mut metrics = SearchMetrics::new().with_expensive_threshold(5000);
285 metrics.record_search("cheap", 10, 2000, 10, 5);
286 metrics.record_search("expensive", 100, 50000, 100, 50);
287
288 assert!(!metrics.should_sample_results("cheap"));
289 assert!(metrics.should_sample_results("expensive"));
290 }
291
292 #[test]
293 fn test_format_summary() {
294 let mut metrics = SearchMetrics::new();
295 metrics.record_search("pattern1", 10, 4000, 10, 5);
296
297 let summary = metrics.format_summary();
298 assert!(summary.contains("[SEARCH] Metrics Summary"));
299 assert!(summary.contains("Total searches: 1"));
300 }
301
302 #[test]
303 fn test_reset() {
304 let mut metrics = SearchMetrics::new();
305 metrics.record_search("pattern1", 10, 4000, 10, 5);
306
307 metrics.reset();
308 assert_eq!(metrics.total_searches, 0);
309 assert_eq!(metrics.total_tokens, 0);
310 assert!(metrics.get_search("pattern1").is_none());
311 }
312
313 #[test]
314 fn test_stats() {
315 let mut metrics = SearchMetrics::new().with_expensive_threshold(5000);
316 metrics.record_search("cheap", 10, 2000, 10, 5);
317 metrics.record_search("expensive", 100, 50000, 100, 50);
318
319 let stats = metrics.stats();
320 assert_eq!(stats.total_searches, 2);
321 assert_eq!(stats.expensive_searches, 1);
322 assert!(stats.avg_tokens_per_search > 0.0);
323 }
324}