Skip to main content

opendev_context/retrieval/
retriever.rs

1//! Just-in-time context retrieval for proactive code loading.
2//!
3//! Extracts entities (files, functions, classes) from user input and
4//! resolves them against the filesystem to provide relevant context.
5
6use std::fs;
7use std::path::{Path, PathBuf};
8use std::process::Command;
9
10use regex::Regex;
11
12/// Entities extracted from user input.
13#[derive(Debug, Clone, Default)]
14pub struct Entities {
15    pub files: Vec<String>,
16    pub functions: Vec<String>,
17    pub classes: Vec<String>,
18    pub variables: Vec<String>,
19    pub actions: Vec<String>,
20}
21
22/// A file matched during context retrieval.
23#[derive(Debug, Clone)]
24pub struct FileMatch {
25    pub path: String,
26    pub reason: String,
27    pub entity: String,
28}
29
30/// Result of a context retrieval operation.
31#[derive(Debug, Clone, Default)]
32pub struct RetrievalContext {
33    pub entities: Entities,
34    pub files_found: Vec<FileMatch>,
35    pub suggestions: Vec<String>,
36}
37
38/// Extract entities (files, functions, classes, variables, actions) from user input.
39#[derive(Debug)]
40pub struct EntityExtractor {
41    file_path_re: Regex,
42    function_re: Regex,
43    class_re: Regex,
44    variable_re: Regex,
45    action_re: Regex,
46}
47
48impl Default for EntityExtractor {
49    fn default() -> Self {
50        Self::new()
51    }
52}
53
54impl EntityExtractor {
55    /// Create a new entity extractor with pre-compiled regex patterns.
56    pub fn new() -> Self {
57        let extensions = [
58            "py", "js", "ts", "jsx", "tsx", "java", "cpp", "c", "h", "hpp", "go", "rs", "rb",
59            "php", "swift", "kt", "cs", "r", "m", "scala", "sh", "bash", "zsh", "yaml", "yml",
60            "json", "toml", "xml", "html", "css", "scss", "sass", "md", "txt", "sql",
61        ];
62        let ext_pattern = extensions.join("|");
63        let file_path_pattern = format!(r"[\w\-_./]+\.(?:{})", ext_pattern);
64
65        Self {
66            file_path_re: Regex::new(&file_path_pattern).unwrap(),
67            function_re: Regex::new(r"(?:^|[^A-Z])([a-z_][a-z0-9_]*)\s*\(").unwrap(),
68            class_re: Regex::new(
69                r"\b([A-Z][a-zA-Z0-9]*(?:Error|Exception|Manager|Service|Handler|Controller|Model|View|Component)?)\b",
70            )
71            .unwrap(),
72            variable_re: Regex::new(r"\b(?:var|let|const|self|this)\s+([a-z_][a-z0-9_]*)\b")
73                .unwrap(),
74            action_re: Regex::new(
75                r"(?i)\b(fix|debug|implement|create|add|remove|delete|update|modify|refactor|test|check|verify|optimize)\b",
76            )
77            .unwrap(),
78        }
79    }
80
81    /// Extract entities from user input text.
82    pub fn extract_entities(&self, input: &str) -> Entities {
83        let mut entities = Entities::default();
84
85        // Files
86        for cap in self.file_path_re.find_iter(input) {
87            let val = cap.as_str().to_string();
88            if !entities.files.contains(&val) {
89                entities.files.push(val);
90            }
91        }
92
93        // Functions
94        for cap in self.function_re.captures_iter(input) {
95            if let Some(m) = cap.get(1) {
96                let val = m.as_str().to_string();
97                if !entities.functions.contains(&val) {
98                    entities.functions.push(val);
99                }
100            }
101        }
102
103        // Classes
104        for cap in self.class_re.captures_iter(input) {
105            if let Some(m) = cap.get(1) {
106                let val = m.as_str().to_string();
107                if !entities.classes.contains(&val) {
108                    entities.classes.push(val);
109                }
110            }
111        }
112
113        // Variables
114        for cap in self.variable_re.captures_iter(input) {
115            if let Some(m) = cap.get(1) {
116                let val = m.as_str().to_string();
117                if !entities.variables.contains(&val) {
118                    entities.variables.push(val);
119                }
120            }
121        }
122
123        // Actions
124        for cap in self.action_re.captures_iter(input) {
125            if let Some(m) = cap.get(1) {
126                let val = m.as_str().to_lowercase();
127                if !entities.actions.contains(&val) {
128                    entities.actions.push(val);
129                }
130            }
131        }
132
133        entities
134    }
135}
136
137/// Retrieve relevant context based on user intent.
138#[derive(Debug)]
139pub struct ContextRetriever {
140    working_dir: PathBuf,
141    extractor: EntityExtractor,
142}
143
144impl ContextRetriever {
145    /// Create a new context retriever rooted at the given directory.
146    pub fn new(working_dir: &Path) -> Self {
147        Self {
148            working_dir: working_dir.to_path_buf(),
149            extractor: EntityExtractor::new(),
150        }
151    }
152
153    /// Retrieve context relevant to the user's input.
154    ///
155    /// Extracts entities, resolves file paths, searches for symbols,
156    /// and generates suggestions based on detected actions.
157    pub fn retrieve_context(&self, input: &str, max_files: usize) -> RetrievalContext {
158        let entities = self.extractor.extract_entities(input);
159        let mut ctx = RetrievalContext {
160            entities: entities.clone(),
161            files_found: Vec::new(),
162            suggestions: Vec::new(),
163        };
164
165        // Resolve directly mentioned files
166        for file_path in &entities.files {
167            if let Some(resolved) = self.resolve_file_path(file_path) {
168                ctx.files_found.push(FileMatch {
169                    path: resolved.to_string_lossy().to_string(),
170                    reason: "direct_mention".to_string(),
171                    entity: file_path.clone(),
172                });
173            }
174        }
175
176        // Search for functions and classes
177        let search_terms: Vec<&String> = entities
178            .functions
179            .iter()
180            .chain(entities.classes.iter())
181            .collect();
182
183        for term in search_terms {
184            let matches = self.grep_pattern(term, 5);
185            for match_path in matches {
186                let already_found = ctx.files_found.iter().any(|f| f.path == match_path);
187                if !already_found {
188                    ctx.files_found.push(FileMatch {
189                        path: match_path,
190                        reason: "contains_entity".to_string(),
191                        entity: term.clone(),
192                    });
193                }
194            }
195        }
196
197        // Generate suggestions based on actions
198        if entities.actions.contains(&"fix".to_string())
199            || entities.actions.contains(&"debug".to_string())
200        {
201            ctx.suggestions
202                .push("Consider checking test files and error logs".to_string());
203        }
204        if entities.actions.contains(&"implement".to_string())
205            || entities.actions.contains(&"create".to_string())
206        {
207            ctx.suggestions
208                .push("Consider checking similar implementations".to_string());
209        }
210
211        ctx.files_found.truncate(max_files);
212        ctx
213    }
214
215    /// Resolve a file path relative to the working directory.
216    ///
217    /// First checks the exact path, then searches recursively by filename.
218    pub fn resolve_file_path(&self, file_path: &str) -> Option<PathBuf> {
219        let path = self.working_dir.join(file_path);
220        if path.exists() {
221            return Some(path);
222        }
223
224        // Search by filename
225        let target_name = Path::new(file_path).file_name()?.to_str()?;
226
227        self.find_file_recursive(&self.working_dir, target_name)
228    }
229
230    /// Search for a pattern using `rg` (ripgrep), falling back to `grep`.
231    pub fn grep_pattern(&self, pattern: &str, limit: usize) -> Vec<String> {
232        // Try ripgrep first
233        let result = Command::new("rg")
234            .args(["-l", pattern])
235            .arg(&self.working_dir)
236            .output();
237
238        let output = match result {
239            Ok(output) if output.status.success() => output,
240            _ => {
241                // Fallback to grep
242                match Command::new("grep")
243                    .args(["-r", "-l", pattern])
244                    .arg(&self.working_dir)
245                    .output()
246                {
247                    Ok(output) if output.status.success() => output,
248                    _ => return Vec::new(),
249                }
250            }
251        };
252
253        String::from_utf8_lossy(&output.stdout)
254            .lines()
255            .filter(|line| !line.is_empty())
256            .take(limit)
257            .map(|line| line.trim().to_string())
258            .collect()
259    }
260
261    // -- Private helpers --
262
263    fn find_file_recursive(&self, dir: &Path, target_name: &str) -> Option<PathBuf> {
264        let entries = fs::read_dir(dir).ok()?;
265        for entry in entries.flatten() {
266            let path = entry.path();
267            let name = entry.file_name();
268            let name_str = name.to_string_lossy();
269
270            if name_str.starts_with('.') || name_str == "node_modules" || name_str == "target" {
271                continue;
272            }
273
274            if path.is_file() {
275                if name_str == target_name {
276                    return Some(path);
277                }
278            } else if path.is_dir()
279                && let Some(found) = self.find_file_recursive(&path, target_name)
280            {
281                return Some(found);
282            }
283        }
284        None
285    }
286}
287
288#[cfg(test)]
289#[path = "retriever_tests.rs"]
290mod tests;