aster/context/
file_mention.rs1use crate::context::types::{ContextError, FileMentionResult, ResolvedFile};
21use regex::Regex;
22use std::path::{Path, PathBuf};
23use tokio::fs;
24
25pub const COMMON_EXTENSIONS: &[&str] = &[".rs", ".ts", ".js", ".md", ".py", ".go", ".tsx", ".jsx"];
27
28pub struct FileMentionResolver {
34 working_directory: PathBuf,
36}
37
38impl FileMentionResolver {
39 pub fn new(working_directory: impl Into<PathBuf>) -> Self {
51 Self {
52 working_directory: working_directory.into(),
53 }
54 }
55
56 pub fn working_directory(&self) -> &Path {
58 &self.working_directory
59 }
60
61 pub fn parse_mentions(text: &str) -> Vec<String> {
83 let pattern = Regex::new(r"(?:^|[^a-zA-Z0-9])@([a-zA-Z0-9_\-./]+[a-zA-Z0-9_\-])").unwrap();
89
90 let mut mentions = Vec::new();
91 for cap in pattern.captures_iter(text) {
92 if let Some(mention) = cap.get(1) {
93 let mention_str = mention.as_str().to_string();
94 if !mention_str.contains("..") && !mention_str.starts_with('/') {
96 mentions.push(mention_str);
97 }
98 }
99 }
100
101 mentions
102 }
103
104 pub fn try_resolve_path(&self, mention: &str) -> Option<PathBuf> {
118 let base_path = self.working_directory.join(mention);
119
120 if base_path.exists() && base_path.is_file() {
122 return Some(base_path);
123 }
124
125 if Path::new(mention).extension().is_none() {
127 for ext in COMMON_EXTENSIONS {
128 let path_with_ext = self.working_directory.join(format!("{}{}", mention, ext));
129 if path_with_ext.exists() && path_with_ext.is_file() {
130 return Some(path_with_ext);
131 }
132 }
133 }
134
135 None
136 }
137
138 pub async fn resolve_mentions(&self, text: &str) -> Result<FileMentionResult, ContextError> {
160 let mentions = Self::parse_mentions(text);
161 let mut resolved_files = Vec::new();
162 let mut processed_text = text.to_string();
163
164 for mention in mentions {
165 if let Some(path) = self.try_resolve_path(&mention) {
166 match fs::read_to_string(&path).await {
167 Ok(content) => {
168 let file_block = format!(
170 "\n\n<file path=\"{}\">\n{}\n</file>\n",
171 path.display(),
172 content
173 );
174
175 let mention_pattern = format!("@{}", mention);
177 processed_text = processed_text.replace(&mention_pattern, &file_block);
178
179 resolved_files.push(ResolvedFile::new(path, content));
180 }
181 Err(e) => {
182 tracing::warn!(
184 "Failed to read file {} for mention @{}: {}",
185 path.display(),
186 mention,
187 e
188 );
189 }
191 }
192 }
193 }
195
196 Ok(FileMentionResult::new(processed_text, resolved_files))
197 }
198
199 pub fn resolve_mentions_sync(&self, text: &str) -> Result<FileMentionResult, ContextError> {
212 let mentions = Self::parse_mentions(text);
213 let mut resolved_files = Vec::new();
214 let mut processed_text = text.to_string();
215
216 for mention in mentions {
217 if let Some(path) = self.try_resolve_path(&mention) {
218 match std::fs::read_to_string(&path) {
219 Ok(content) => {
220 let file_block = format!(
222 "\n\n<file path=\"{}\">\n{}\n</file>\n",
223 path.display(),
224 content
225 );
226
227 let mention_pattern = format!("@{}", mention);
229 processed_text = processed_text.replace(&mention_pattern, &file_block);
230
231 resolved_files.push(ResolvedFile::new(path, content));
232 }
233 Err(e) => {
234 tracing::warn!(
235 "Failed to read file {} for mention @{}: {}",
236 path.display(),
237 mention,
238 e
239 );
240 }
241 }
242 }
243 }
244
245 Ok(FileMentionResult::new(processed_text, resolved_files))
246 }
247}
248
249#[cfg(test)]
254mod tests {
255 use super::*;
256 use std::fs;
257 use tempfile::TempDir;
258
259 #[test]
260 fn test_parse_mentions_simple() {
261 let text = "Check @main.rs for details";
262 let mentions = FileMentionResolver::parse_mentions(text);
263 assert_eq!(mentions, vec!["main.rs"]);
264 }
265
266 #[test]
267 fn test_parse_mentions_multiple() {
268 let text = "Look at @main.rs and @utils.rs for the implementation";
269 let mentions = FileMentionResolver::parse_mentions(text);
270 assert_eq!(mentions, vec!["main.rs", "utils.rs"]);
271 }
272
273 #[test]
274 fn test_parse_mentions_with_path() {
275 let text = "Check @src/lib.rs and @tests/test_main.rs";
276 let mentions = FileMentionResolver::parse_mentions(text);
277 assert_eq!(mentions, vec!["src/lib.rs", "tests/test_main.rs"]);
278 }
279
280 #[test]
281 fn test_parse_mentions_without_extension() {
282 let text = "See @README and @main for more info";
283 let mentions = FileMentionResolver::parse_mentions(text);
284 assert_eq!(mentions, vec!["README", "main"]);
285 }
286
287 #[test]
288 fn test_parse_mentions_at_start() {
289 let text = "@config.rs contains the settings";
290 let mentions = FileMentionResolver::parse_mentions(text);
291 assert_eq!(mentions, vec!["config.rs"]);
292 }
293
294 #[test]
295 fn test_parse_mentions_ignores_email() {
296 let text = "Contact user@example.com for help";
297 let mentions = FileMentionResolver::parse_mentions(text);
298 assert!(mentions.is_empty() || !mentions.contains(&"example.com".to_string()));
300 }
301
302 #[test]
303 fn test_parse_mentions_with_hyphen_underscore() {
304 let text = "Check @my-file.rs and @my_other_file.ts";
305 let mentions = FileMentionResolver::parse_mentions(text);
306 assert_eq!(mentions, vec!["my-file.rs", "my_other_file.ts"]);
307 }
308
309 #[test]
310 fn test_parse_mentions_empty_text() {
311 let text = "";
312 let mentions = FileMentionResolver::parse_mentions(text);
313 assert!(mentions.is_empty());
314 }
315
316 #[test]
317 fn test_parse_mentions_no_mentions() {
318 let text = "This text has no file mentions";
319 let mentions = FileMentionResolver::parse_mentions(text);
320 assert!(mentions.is_empty());
321 }
322
323 #[test]
324 fn test_try_resolve_path_exact() {
325 let temp_dir = TempDir::new().unwrap();
326 let file_path = temp_dir.path().join("test.rs");
327 fs::write(&file_path, "fn main() {}").unwrap();
328
329 let resolver = FileMentionResolver::new(temp_dir.path());
330 let resolved = resolver.try_resolve_path("test.rs");
331
332 assert!(resolved.is_some());
333 assert_eq!(resolved.unwrap(), file_path);
334 }
335
336 #[test]
337 fn test_try_resolve_path_with_extension_fallback() {
338 let temp_dir = TempDir::new().unwrap();
339 let file_path = temp_dir.path().join("main.rs");
340 fs::write(&file_path, "fn main() {}").unwrap();
341
342 let resolver = FileMentionResolver::new(temp_dir.path());
343 let resolved = resolver.try_resolve_path("main");
344
345 assert!(resolved.is_some());
346 assert_eq!(resolved.unwrap(), file_path);
347 }
348
349 #[test]
350 fn test_try_resolve_path_not_found() {
351 let temp_dir = TempDir::new().unwrap();
352 let resolver = FileMentionResolver::new(temp_dir.path());
353 let resolved = resolver.try_resolve_path("nonexistent.rs");
354
355 assert!(resolved.is_none());
356 }
357
358 #[test]
359 fn test_try_resolve_path_subdirectory() {
360 let temp_dir = TempDir::new().unwrap();
361 let sub_dir = temp_dir.path().join("src");
362 fs::create_dir(&sub_dir).unwrap();
363 let file_path = sub_dir.join("lib.rs");
364 fs::write(&file_path, "pub mod test;").unwrap();
365
366 let resolver = FileMentionResolver::new(temp_dir.path());
367 let resolved = resolver.try_resolve_path("src/lib.rs");
368
369 assert!(resolved.is_some());
370 assert_eq!(resolved.unwrap(), file_path);
371 }
372
373 #[tokio::test]
374 async fn test_resolve_mentions_single_file() {
375 let temp_dir = TempDir::new().unwrap();
376 let file_path = temp_dir.path().join("test.rs");
377 let content = "fn main() { println!(\"Hello\"); }";
378 fs::write(&file_path, content).unwrap();
379
380 let resolver = FileMentionResolver::new(temp_dir.path());
381 let result = resolver
382 .resolve_mentions("Check @test.rs for details")
383 .await
384 .unwrap();
385
386 assert_eq!(result.files.len(), 1);
387 assert_eq!(result.files[0].content, content);
388 assert!(result.processed_text.contains(content));
389 assert!(!result.processed_text.contains("@test.rs"));
390 }
391
392 #[tokio::test]
393 async fn test_resolve_mentions_file_not_found() {
394 let temp_dir = TempDir::new().unwrap();
395 let resolver = FileMentionResolver::new(temp_dir.path());
396 let original_text = "Check @nonexistent.rs for details";
397 let result = resolver.resolve_mentions(original_text).await.unwrap();
398
399 assert!(result.processed_text.contains("@nonexistent.rs"));
401 assert!(result.files.is_empty());
402 }
403
404 #[tokio::test]
405 async fn test_resolve_mentions_multiple_files() {
406 let temp_dir = TempDir::new().unwrap();
407
408 let file1_path = temp_dir.path().join("main.rs");
409 fs::write(&file1_path, "fn main() {}").unwrap();
410
411 let file2_path = temp_dir.path().join("lib.rs");
412 fs::write(&file2_path, "pub mod utils;").unwrap();
413
414 let resolver = FileMentionResolver::new(temp_dir.path());
415 let result = resolver
416 .resolve_mentions("See @main.rs and @lib.rs")
417 .await
418 .unwrap();
419
420 assert_eq!(result.files.len(), 2);
421 assert!(result.processed_text.contains("fn main() {}"));
422 assert!(result.processed_text.contains("pub mod utils;"));
423 }
424
425 #[tokio::test]
426 async fn test_resolve_mentions_mixed_found_not_found() {
427 let temp_dir = TempDir::new().unwrap();
428 let file_path = temp_dir.path().join("exists.rs");
429 fs::write(&file_path, "// exists").unwrap();
430
431 let resolver = FileMentionResolver::new(temp_dir.path());
432 let result = resolver
433 .resolve_mentions("Check @exists.rs and @missing.rs")
434 .await
435 .unwrap();
436
437 assert_eq!(result.files.len(), 1);
438 assert!(result.processed_text.contains("// exists"));
439 assert!(result.processed_text.contains("@missing.rs"));
440 }
441
442 #[test]
443 fn test_resolve_mentions_sync() {
444 let temp_dir = TempDir::new().unwrap();
445 let file_path = temp_dir.path().join("sync_test.rs");
446 let content = "// sync test content";
447 fs::write(&file_path, content).unwrap();
448
449 let resolver = FileMentionResolver::new(temp_dir.path());
450 let result = resolver
451 .resolve_mentions_sync("Check @sync_test.rs")
452 .unwrap();
453
454 assert_eq!(result.files.len(), 1);
455 assert!(result.processed_text.contains(content));
456 }
457
458 #[test]
459 fn test_working_directory_getter() {
460 let path = PathBuf::from("/test/path");
461 let resolver = FileMentionResolver::new(&path);
462 assert_eq!(resolver.working_directory(), &path);
463 }
464}