Skip to main content

aster/context/
file_mention.rs

1//! File Mention Resolver
2//!
3//! This module provides functionality to parse and resolve file mentions
4//! in text using the @ syntax. It supports:
5//!
6//! - Parsing @filename patterns from text
7//! - Resolving file paths relative to working directory
8//! - Trying common extensions if not specified
9//! - Reading and including file content in processed text
10//!
11//! # Example
12//!
13//! ```ignore
14//! use aster::context::file_mention::FileMentionResolver;
15//!
16//! let resolver = FileMentionResolver::new("/path/to/project");
17//! let result = resolver.resolve_mentions("Check @main.rs for details").await?;
18//! ```
19
20use crate::context::types::{ContextError, FileMentionResult, ResolvedFile};
21use regex::Regex;
22use std::path::{Path, PathBuf};
23use tokio::fs;
24
25/// Common file extensions to try when resolving mentions without extensions.
26pub const COMMON_EXTENSIONS: &[&str] = &[".rs", ".ts", ".js", ".md", ".py", ".go", ".tsx", ".jsx"];
27
28/// File mention resolver for parsing and resolving @ mentions in text.
29///
30/// The resolver parses @filename patterns from text and attempts to resolve
31/// them to actual files in the working directory. If a file is found, its
32/// content is included in the processed text.
33pub struct FileMentionResolver {
34    /// Working directory for resolving relative paths
35    working_directory: PathBuf,
36}
37
38impl FileMentionResolver {
39    /// Create a new FileMentionResolver with the given working directory.
40    ///
41    /// # Arguments
42    ///
43    /// * `working_directory` - The base directory for resolving relative file paths
44    ///
45    /// # Example
46    ///
47    /// ```ignore
48    /// let resolver = FileMentionResolver::new("/path/to/project");
49    /// ```
50    pub fn new(working_directory: impl Into<PathBuf>) -> Self {
51        Self {
52            working_directory: working_directory.into(),
53        }
54    }
55
56    /// Get the working directory.
57    pub fn working_directory(&self) -> &Path {
58        &self.working_directory
59    }
60
61    /// Parse @filename patterns from text.
62    ///
63    /// This method extracts all @mentions from the text. It supports:
64    /// - Simple mentions: @filename.rs
65    /// - Path mentions: @src/main.rs
66    /// - Mentions without extensions: @main
67    ///
68    /// # Arguments
69    ///
70    /// * `text` - The text to parse for mentions
71    ///
72    /// # Returns
73    ///
74    /// A vector of mention strings (without the @ prefix)
75    ///
76    /// # Example
77    ///
78    /// ```ignore
79    /// let mentions = FileMentionResolver::parse_mentions("Check @main.rs and @utils");
80    /// assert_eq!(mentions, vec!["main.rs", "utils"]);
81    /// ```
82    pub fn parse_mentions(text: &str) -> Vec<String> {
83        // Pattern matches @followed by a valid file path
84        // - Starts with @
85        // - Followed by alphanumeric, underscore, hyphen, dot, or forward slash
86        // - Must not be preceded by alphanumeric (to avoid email addresses)
87        // - Must not be followed by certain characters that indicate it's not a file mention
88        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                // Filter out obvious non-file patterns
95                if !mention_str.contains("..") && !mention_str.starts_with('/') {
96                    mentions.push(mention_str);
97                }
98            }
99        }
100
101        mentions
102    }
103
104    /// Try to resolve a file path, attempting common extensions if needed.
105    ///
106    /// This method attempts to find a file matching the mention:
107    /// 1. First tries the exact path
108    /// 2. If not found and no extension, tries common extensions
109    ///
110    /// # Arguments
111    ///
112    /// * `mention` - The file mention to resolve (without @ prefix)
113    ///
114    /// # Returns
115    ///
116    /// `Some(PathBuf)` if a matching file is found, `None` otherwise
117    pub fn try_resolve_path(&self, mention: &str) -> Option<PathBuf> {
118        let base_path = self.working_directory.join(mention);
119
120        // First, try the exact path
121        if base_path.exists() && base_path.is_file() {
122            return Some(base_path);
123        }
124
125        // If the mention has no extension, try common extensions
126        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    /// Resolve all @ mentions in text and read file contents.
139    ///
140    /// This method:
141    /// 1. Parses all @mentions from the text
142    /// 2. Attempts to resolve each mention to a file
143    /// 3. Reads the content of found files
144    /// 4. Returns processed text with file contents and list of resolved files
145    ///
146    /// If a file is not found, the mention is left unchanged in the text.
147    ///
148    /// # Arguments
149    ///
150    /// * `text` - The text containing @ mentions
151    ///
152    /// # Returns
153    ///
154    /// A `FileMentionResult` containing the processed text and resolved files
155    ///
156    /// # Errors
157    ///
158    /// Returns an error if file reading fails for a resolved file
159    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                        // Create the file reference block to insert
169                        let file_block = format!(
170                            "\n\n<file path=\"{}\">\n{}\n</file>\n",
171                            path.display(),
172                            content
173                        );
174
175                        // Replace the @mention with the file content
176                        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                        // Log the error but continue processing other mentions
183                        tracing::warn!(
184                            "Failed to read file {} for mention @{}: {}",
185                            path.display(),
186                            mention,
187                            e
188                        );
189                        // Leave the mention unchanged
190                    }
191                }
192            }
193            // If file not found, leave the mention unchanged (per requirement 7.5)
194        }
195
196        Ok(FileMentionResult::new(processed_text, resolved_files))
197    }
198
199    /// Resolve mentions synchronously (blocking).
200    ///
201    /// This is a convenience method for contexts where async is not available.
202    /// It uses blocking file I/O.
203    ///
204    /// # Arguments
205    ///
206    /// * `text` - The text containing @ mentions
207    ///
208    /// # Returns
209    ///
210    /// A `FileMentionResult` containing the processed text and resolved files
211    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                        // Create the file reference block to insert
221                        let file_block = format!(
222                            "\n\n<file path=\"{}\">\n{}\n</file>\n",
223                            path.display(),
224                            content
225                        );
226
227                        // Replace the @mention with the file content
228                        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// ============================================================================
250// Tests
251// ============================================================================
252
253#[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        // Should not match email addresses
299        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        // Mention should be left unchanged
400        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}