Skip to main content

vtcode_core/context/
entity_resolver.rs

1//! Entity resolution for vibe coding support
2//!
3//! This module provides fuzzy entity matching to resolve vague terms like
4//! "the sidebar" or "that button" to actual workspace entities (files, components, etc.)
5
6use 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
14/// Maximum number of entity matches to return
15const MAX_ENTITY_MATCHES: usize = 5;
16
17/// Maximum number of recent edits to track
18const MAX_RECENT_EDITS: usize = 50;
19
20/// Location of an entity within a file
21#[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/// A matched entity with confidence score
30#[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    /// Calculate total score for ranking
42    pub fn total_score(&self) -> f32 {
43        self.confidence + self.recency_score + self.mention_score + self.proximity_score
44    }
45}
46
47/// Reference to an entity that was recently edited
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct EntityReference {
50    pub entity: String,
51    pub file: PathBuf,
52    pub timestamp: u64,
53}
54
55/// Value found in a style file (CSS, SCSS, etc.)
56#[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/// Index of entities in the workspace
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct EntityIndex {
67    /// UI components (Sidebar, Button, etc.)
68    pub ui_components: HashMap<String, Vec<FileLocation>>,
69
70    /// Functions and methods
71    pub functions: HashMap<String, Vec<FileLocation>>,
72
73    /// Classes and structs
74    pub classes: HashMap<String, Vec<FileLocation>>,
75
76    /// Style properties (padding, color, etc.)
77    pub style_properties: HashMap<String, Vec<StyleValue>>,
78
79    /// Config keys
80    pub config_keys: HashMap<String, Vec<FileLocation>>,
81
82    /// Recent edits for recency ranking
83    pub recent_edits: VecDeque<EntityReference>,
84
85    /// Last mentioned entities with timestamps
86    pub last_mentioned: HashMap<String, u64>,
87
88    /// Last update timestamp
89    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    /// Record an entity mention for recency tracking
109    pub fn record_mention(&mut self, entity: &str) {
110        self.last_mentioned
111            .insert(entity.to_lowercase(), current_timestamp());
112    }
113
114    /// Record a recent edit
115    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        // Keep bounded
125        while self.recent_edits.len() > MAX_RECENT_EDITS {
126            self.recent_edits.pop_front();
127        }
128    }
129
130    /// Check if file was recently edited
131    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
139/// Entity resolver for fuzzy matching
140pub struct EntityResolver {
141    /// The entity index
142    index: EntityIndex,
143
144    /// Workspace root for path resolution
145    #[expect(dead_code)]
146    workspace_root: PathBuf,
147
148    /// Cache file path
149    cache_path: Option<PathBuf>,
150}
151
152impl EntityResolver {
153    /// Create a new entity resolver
154    pub fn new(workspace_root: PathBuf) -> Self {
155        Self {
156            index: EntityIndex::default(),
157            workspace_root,
158            cache_path: None,
159        }
160    }
161
162    /// Create with cache file path
163    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    /// Load index from cache file
172    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    /// Save index to cache file
187    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    /// Check if the entity index is empty
200    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    /// Resolve a vague term to entity matches
207    pub fn resolve(&self, term: &str) -> Option<EntityMatch> {
208        let matches = self.find_entity_fuzzy(term);
209
210        // Return best match if any
211        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    /// Find entities using fuzzy matching
219    fn find_entity_fuzzy(&self, term: &str) -> Vec<EntityMatch> {
220        let term_lower = term.to_lowercase();
221        let mut matches = Vec::new();
222
223        // Search UI components
224        self.search_hashmap(&self.index.ui_components, &term_lower, &mut matches);
225
226        // Search functions
227        self.search_hashmap(&self.index.functions, &term_lower, &mut matches);
228
229        // Search classes
230        self.search_hashmap(&self.index.classes, &term_lower, &mut matches);
231
232        // Sort by total score and limit
233        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    /// Search a hashmap for matching entities
244    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            // Exact match
254            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            // Case-insensitive substring match
267            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, // Slightly lower than exact
273                    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            // Fuzzy match using Levenshtein distance
281            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, // Lower confidence for fuzzy
289                    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    /// Calculate recency score based on recent edits
298    fn calculate_recency_score(&self, entity: &str) -> f32 {
299        let now = current_timestamp();
300
301        // Check if entity was recently edited (within 5 minutes)
302        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                // Score decays over 5 minutes
312                return 0.3 * (1.0 - (age_seconds as f32 / 300.0));
313            }
314        }
315
316        0.0
317    }
318
319    /// Calculate mention score based on conversation history
320    fn calculate_mention_score(&self, entity: &str) -> f32 {
321        if let Some(&timestamp) = self.index.last_mentioned.get(entity) {
322            let now = current_timestamp();
323            let age_seconds = now.saturating_sub(timestamp);
324
325            // Score decays over 10 minutes
326            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    /// Record a mention for recency tracking
335    pub fn record_mention(&mut self, entity: &str) {
336        self.index.record_mention(entity);
337    }
338
339    /// Record an edit for recency tracking
340    pub fn record_edit(&mut self, entity: String, file: PathBuf) {
341        self.index.record_edit(entity, file);
342    }
343
344    /// Get mutable access to the index for building
345    pub fn index_mut(&mut self) -> &mut EntityIndex {
346        &mut self.index
347    }
348
349    /// Get read-only access to the index
350    pub fn index(&self) -> &EntityIndex {
351        &self.index
352    }
353}
354
355/// Calculate Levenshtein distance between two strings
356fn 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    // Keep only two rows to reduce memory traffic and allocations.
370    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        // Reslice to the known length so LLVM can elide bounds checks
375        // on all four indexed accesses inside the inner loop.
376        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}