1use anyhow::{Context, Result};
7use hashbrown::HashMap;
8use serde::{Deserialize, Serialize};
9use std::collections::VecDeque;
10use std::path::{Path, PathBuf};
11use vtcode_commons::fs::{read_file_with_context, write_file_with_context};
12use vtcode_commons::utils::current_timestamp;
13
14const MAX_ENTITY_MATCHES: usize = 5;
16
17const MAX_RECENT_EDITS: usize = 50;
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct FileLocation {
23 pub path: PathBuf,
24 pub line_start: usize,
25 pub line_end: usize,
26 pub content_preview: String,
27}
28
29#[derive(Debug, Clone)]
31pub struct EntityMatch {
32 pub entity: String,
33 pub locations: Vec<FileLocation>,
34 pub confidence: f32,
35 pub recency_score: f32,
36 pub mention_score: f32,
37 pub proximity_score: f32,
38}
39
40impl EntityMatch {
41 pub fn total_score(&self) -> f32 {
43 self.confidence + self.recency_score + self.mention_score + self.proximity_score
44 }
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct EntityReference {
50 pub entity: String,
51 pub file: PathBuf,
52 pub timestamp: u64,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct StyleValue {
58 pub property: String,
59 pub value: String,
60 pub file: PathBuf,
61 pub line: usize,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct EntityIndex {
67 pub ui_components: HashMap<String, Vec<FileLocation>>,
69
70 pub functions: HashMap<String, Vec<FileLocation>>,
72
73 pub classes: HashMap<String, Vec<FileLocation>>,
75
76 pub style_properties: HashMap<String, Vec<StyleValue>>,
78
79 pub config_keys: HashMap<String, Vec<FileLocation>>,
81
82 pub recent_edits: VecDeque<EntityReference>,
84
85 pub last_mentioned: HashMap<String, u64>,
87
88 pub last_updated: u64,
90}
91
92impl Default for EntityIndex {
93 fn default() -> Self {
94 Self {
95 ui_components: HashMap::new(),
96 functions: HashMap::new(),
97 classes: HashMap::new(),
98 style_properties: HashMap::new(),
99 config_keys: HashMap::new(),
100 recent_edits: VecDeque::with_capacity(MAX_RECENT_EDITS),
101 last_mentioned: HashMap::new(),
102 last_updated: current_timestamp(),
103 }
104 }
105}
106
107impl EntityIndex {
108 pub fn record_mention(&mut self, entity: &str) {
110 self.last_mentioned
111 .insert(entity.to_lowercase(), current_timestamp());
112 }
113
114 pub fn record_edit(&mut self, entity: String, file: PathBuf) {
116 let reference = EntityReference {
117 entity,
118 file,
119 timestamp: current_timestamp(),
120 };
121
122 self.recent_edits.push_back(reference);
123
124 while self.recent_edits.len() > MAX_RECENT_EDITS {
126 self.recent_edits.pop_front();
127 }
128 }
129
130 pub fn was_recently_edited(&self, file: &Path, within_seconds: u64) -> bool {
132 let cutoff = current_timestamp().saturating_sub(within_seconds);
133 self.recent_edits
134 .iter()
135 .any(|r| r.file == file && r.timestamp >= cutoff)
136 }
137}
138
139pub struct EntityResolver {
141 index: EntityIndex,
143
144 #[expect(dead_code)]
146 workspace_root: PathBuf,
147
148 cache_path: Option<PathBuf>,
150}
151
152impl EntityResolver {
153 pub fn new(workspace_root: PathBuf) -> Self {
155 Self {
156 index: EntityIndex::default(),
157 workspace_root,
158 cache_path: None,
159 }
160 }
161
162 pub fn with_cache(workspace_root: PathBuf, cache_path: PathBuf) -> Self {
164 Self {
165 index: EntityIndex::default(),
166 workspace_root,
167 cache_path: Some(cache_path),
168 }
169 }
170
171 pub async fn load_cache(&mut self) -> Result<()> {
173 if let Some(cache_path) = &self.cache_path
174 && cache_path.exists()
175 {
176 let content = read_file_with_context(cache_path, "entity cache")
177 .await
178 .with_context(|| format!("Failed to read entity cache at {:?}", cache_path))?;
179
180 self.index = serde_json::from_str(&content)
181 .with_context(|| "Failed to deserialize entity cache")?;
182 }
183 Ok(())
184 }
185
186 pub async fn save_cache(&self) -> Result<()> {
188 if let Some(cache_path) = &self.cache_path {
189 let content = serde_json::to_string_pretty(&self.index)
190 .with_context(|| "Failed to serialize entity cache")?;
191
192 write_file_with_context(cache_path, &content, "entity cache")
193 .await
194 .with_context(|| format!("Failed to write entity cache to {:?}", cache_path))?;
195 }
196 Ok(())
197 }
198
199 pub fn index_is_empty(&self) -> bool {
201 self.index.ui_components.is_empty()
202 && self.index.functions.is_empty()
203 && self.index.classes.is_empty()
204 }
205
206 pub fn resolve(&self, term: &str) -> Option<EntityMatch> {
208 let matches = self.find_entity_fuzzy(term);
209
210 matches.into_iter().max_by(|a, b| {
212 a.total_score()
213 .partial_cmp(&b.total_score())
214 .unwrap_or(std::cmp::Ordering::Equal)
215 })
216 }
217
218 fn find_entity_fuzzy(&self, term: &str) -> Vec<EntityMatch> {
220 let term_lower = term.to_lowercase();
221 let mut matches = Vec::new();
222
223 self.search_hashmap(&self.index.ui_components, &term_lower, &mut matches);
225
226 self.search_hashmap(&self.index.functions, &term_lower, &mut matches);
228
229 self.search_hashmap(&self.index.classes, &term_lower, &mut matches);
231
232 matches.sort_by(|a, b| {
234 b.total_score()
235 .partial_cmp(&a.total_score())
236 .unwrap_or(std::cmp::Ordering::Equal)
237 });
238 matches.truncate(MAX_ENTITY_MATCHES);
239
240 matches
241 }
242
243 fn search_hashmap(
245 &self,
246 map: &HashMap<String, Vec<FileLocation>>,
247 term: &str,
248 matches: &mut Vec<EntityMatch>,
249 ) {
250 for (entity, locations) in map {
251 let entity_lower = entity.to_lowercase();
252
253 if entity_lower == term {
255 matches.push(EntityMatch {
256 entity: entity.clone(),
257 locations: locations.clone(),
258 confidence: 1.0,
259 recency_score: self.calculate_recency_score(&entity_lower),
260 mention_score: self.calculate_mention_score(&entity_lower),
261 proximity_score: 0.0,
262 });
263 continue;
264 }
265
266 if entity_lower.contains(term) {
268 let confidence = term.len() as f32 / entity_lower.len() as f32;
269 matches.push(EntityMatch {
270 entity: entity.clone(),
271 locations: locations.clone(),
272 confidence: confidence * 0.8, recency_score: self.calculate_recency_score(&entity_lower),
274 mention_score: self.calculate_mention_score(&entity_lower),
275 proximity_score: 0.0,
276 });
277 continue;
278 }
279
280 let distance = levenshtein_distance(term, &entity_lower);
282 if distance <= 2 {
283 let confidence =
284 1.0 - (distance as f32 / term.len().max(entity_lower.len()) as f32);
285 matches.push(EntityMatch {
286 entity: entity.clone(),
287 locations: locations.clone(),
288 confidence: confidence * 0.6, recency_score: self.calculate_recency_score(&entity_lower),
290 mention_score: self.calculate_mention_score(&entity_lower),
291 proximity_score: 0.0,
292 });
293 }
294 }
295 }
296
297 fn calculate_recency_score(&self, entity: &str) -> f32 {
299 let now = current_timestamp();
300
301 if let Some(edit) = self
303 .index
304 .recent_edits
305 .iter()
306 .rev()
307 .find(|e| e.entity.to_lowercase() == entity)
308 {
309 let age_seconds = now.saturating_sub(edit.timestamp);
310 if age_seconds < 300 {
311 return 0.3 * (1.0 - (age_seconds as f32 / 300.0));
313 }
314 }
315
316 0.0
317 }
318
319 fn calculate_mention_score(&self, entity: &str) -> f32 {
321 if let Some(×tamp) = self.index.last_mentioned.get(entity) {
322 let now = current_timestamp();
323 let age_seconds = now.saturating_sub(timestamp);
324
325 if age_seconds < 600 {
327 return 0.2 * (1.0 - (age_seconds as f32 / 600.0));
328 }
329 }
330
331 0.0
332 }
333
334 pub fn record_mention(&mut self, entity: &str) {
336 self.index.record_mention(entity);
337 }
338
339 pub fn record_edit(&mut self, entity: String, file: PathBuf) {
341 self.index.record_edit(entity, file);
342 }
343
344 pub fn index_mut(&mut self) -> &mut EntityIndex {
346 &mut self.index
347 }
348
349 pub fn index(&self) -> &EntityIndex {
351 &self.index
352 }
353}
354
355fn levenshtein_distance(a: &str, b: &str) -> usize {
357 let a_chars: Vec<char> = a.chars().collect();
358 let b_chars: Vec<char> = b.chars().collect();
359 let a_len = a_chars.len();
360 let b_len = b_chars.len();
361
362 if a_len == 0 {
363 return b_len;
364 }
365 if b_len == 0 {
366 return a_len;
367 }
368
369 let mut prev_row: Vec<usize> = (0..=b_len).collect();
371 let mut curr_row = vec![0usize; b_len + 1];
372
373 for (i, a_char) in a_chars.iter().enumerate() {
374 let n = b_len;
377 let (prev, curr) = (&prev_row[..=n], &mut curr_row[..=n]);
378 curr[0] = i + 1;
379
380 for j in 0..n {
381 let cost = usize::from(*a_char != b_chars[j]);
382 curr[j + 1] = (prev[j + 1] + 1).min(curr[j] + 1).min(prev[j] + cost);
383 }
384
385 std::mem::swap(&mut prev_row, &mut curr_row);
386 }
387
388 prev_row[b_len]
389}
390
391#[cfg(test)]
392mod tests {
393 use super::*;
394
395 #[test]
396 fn test_levenshtein_distance() {
397 assert_eq!(levenshtein_distance("", ""), 0);
398 assert_eq!(levenshtein_distance("hello", "hello"), 0);
399 assert_eq!(levenshtein_distance("hello", "hallo"), 1);
400 assert_eq!(levenshtein_distance("sidebar", "sidbar"), 1);
401 assert_eq!(levenshtein_distance("button", "btn"), 3);
402 }
403
404 #[test]
405 fn test_entity_match_scoring() {
406 let match1 = EntityMatch {
407 entity: "Sidebar".to_string(),
408 locations: vec![],
409 confidence: 1.0,
410 recency_score: 0.3,
411 mention_score: 0.2,
412 proximity_score: 0.0,
413 };
414
415 assert_eq!(match1.total_score(), 1.5);
416 }
417
418 #[tokio::test]
419 async fn test_entity_resolver_exact_match() {
420 let mut resolver = EntityResolver::new(PathBuf::from("/test"));
421
422 resolver.index_mut().ui_components.insert(
423 "Sidebar".to_string(),
424 vec![FileLocation {
425 path: PathBuf::from("src/Sidebar.tsx"),
426 line_start: 1,
427 line_end: 50,
428 content_preview: "export const Sidebar = () => {}".to_string(),
429 }],
430 );
431
432 let result = resolver.resolve("sidebar");
433 assert!(result.is_some());
434
435 let matched = result.unwrap();
436 assert_eq!(matched.entity, "Sidebar");
437 assert_eq!(matched.confidence, 1.0);
438 }
439}