opendev_context/retrieval/
retriever.rs1use std::fs;
7use std::path::{Path, PathBuf};
8use std::process::Command;
9
10use regex::Regex;
11
12#[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#[derive(Debug, Clone)]
24pub struct FileMatch {
25 pub path: String,
26 pub reason: String,
27 pub entity: String,
28}
29
30#[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#[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 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 pub fn extract_entities(&self, input: &str) -> Entities {
83 let mut entities = Entities::default();
84
85 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 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 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 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 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#[derive(Debug)]
139pub struct ContextRetriever {
140 working_dir: PathBuf,
141 extractor: EntityExtractor,
142}
143
144impl ContextRetriever {
145 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 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 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 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 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 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 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 pub fn grep_pattern(&self, pattern: &str, limit: usize) -> Vec<String> {
232 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 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 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;