| use std::sync::Arc; |
|
|
| use forge_app::domain::{ |
| Attachment, AttachmentContent, DirectoryEntry, FileTag, Image, LineNumbers, |
| }; |
| use forge_app::utils::format_display_path; |
| use forge_app::{ |
| AttachmentService, DirectoryReaderInfra, EnvironmentInfra, FileInfoInfra, FileReaderInfra, |
| }; |
|
|
| use crate::range::resolve_range; |
|
|
| #[derive(Clone)] |
| pub struct ForgeChatRequest<F> { |
| infra: Arc<F>, |
| } |
|
|
| impl< |
| F: FileReaderInfra |
| + EnvironmentInfra<Config = forge_config::ForgeConfig> |
| + FileInfoInfra |
| + DirectoryReaderInfra, |
| > ForgeChatRequest<F> |
| { |
| pub fn new(infra: Arc<F>) -> Self { |
| Self { infra } |
| } |
|
|
| async fn prepare_attachments(&self, paths: Vec<FileTag>) -> anyhow::Result<Vec<Attachment>> { |
| futures::future::join_all(paths.into_iter().map(|v| self.populate_attachments(v))) |
| .await |
| .into_iter() |
| .collect::<anyhow::Result<Vec<_>>>() |
| } |
|
|
| async fn populate_attachments(&self, tag: FileTag) -> anyhow::Result<Attachment> { |
| let mut path = tag.as_ref().to_path_buf(); |
| let extension = path.extension().map(|v| v.to_string_lossy().to_string()); |
|
|
| if !path.is_absolute() { |
| path = self.infra.get_environment().cwd.join(path); |
| } |
|
|
| |
| if self.infra.exists(&path).await? && !self.infra.is_file(&path).await? { |
| |
| |
| let dir_entries = self.infra.list_directory_entries(&path).await?; |
|
|
| |
| let mut entries: Vec<DirectoryEntry> = dir_entries |
| .into_iter() |
| .map(|(entry_path, is_dir)| { |
| let normalized_path = format_display_path(&entry_path, &path); |
| DirectoryEntry { path: normalized_path, is_dir } |
| }) |
| .collect(); |
|
|
| |
| entries.sort_by(|a, b| { |
| |
| match (a.is_dir, b.is_dir) { |
| (true, false) => std::cmp::Ordering::Less, |
| (false, true) => std::cmp::Ordering::Greater, |
| _ => a.path.cmp(&b.path), |
| } |
| }); |
|
|
| return Ok(Attachment { |
| content: AttachmentContent::DirectoryListing { entries }, |
| path: path.to_string_lossy().to_string(), |
| }); |
| } |
|
|
| |
| let mime_type = extension.and_then(|ext| match ext.as_str() { |
| "jpeg" | "jpg" => Some("image/jpeg".to_string()), |
| "png" => Some("image/png".to_string()), |
| "webp" => Some("image/webp".to_string()), |
| _ => None, |
| }); |
|
|
| |
| let content = match mime_type { |
| Some(mime_type) => { |
| AttachmentContent::Image(Image::new_bytes(self.infra.read(&path).await?, mime_type)) |
| } |
| None => { |
| let start = tag.loc.as_ref().and_then(|loc| loc.start); |
| let end = tag.loc.as_ref().and_then(|loc| loc.end); |
| let max_read_lines = self.infra.get_config()?.max_read_lines; |
| let (start_line, end_line) = resolve_range(start, end, max_read_lines); |
|
|
| |
| |
| |
| |
| let (file_content, file_info) = self |
| .infra |
| .range_read_utf8(&path, start_line, end_line) |
| .await?; |
|
|
| AttachmentContent::FileContent { |
| content: file_content |
| .to_numbered_from(file_info.start_line as usize) |
| .to_string(), |
| info: file_info, |
| } |
| } |
| }; |
|
|
| Ok(Attachment { |
| content, |
| path: path.to_string_lossy().to_string(), |
| }) |
| } |
| } |
|
|
| #[async_trait::async_trait] |
| impl< |
| F: FileReaderInfra |
| + EnvironmentInfra<Config = forge_config::ForgeConfig> |
| + FileInfoInfra |
| + DirectoryReaderInfra, |
| > AttachmentService for ForgeChatRequest<F> |
| { |
| async fn attachments(&self, url: &str) -> anyhow::Result<Vec<Attachment>> { |
| self.prepare_attachments(Attachment::parse_all(url)).await |
| } |
| } |
|
|
| #[cfg(test)] |
| pub mod tests { |
| use std::collections::{BTreeMap, HashMap, HashSet}; |
| use std::path::{Path, PathBuf}; |
| use std::sync::{Arc, Mutex}; |
|
|
| use base64::Engine; |
| use bytes::Bytes; |
| use forge_app::domain::{AttachmentContent, Environment}; |
| use forge_app::utils::compute_hash; |
| use forge_app::{ |
| AttachmentService, DirectoryReaderInfra, EnvironmentInfra, FileDirectoryInfra, |
| FileInfoInfra, FileReaderInfra, FileRemoverInfra, FileWriterInfra, |
| }; |
| use forge_domain::{ConfigOperation, FileInfo}; |
| use futures::stream; |
|
|
| use crate::attachment::ForgeChatRequest; |
|
|
| #[derive(Debug)] |
| pub struct MockEnvironmentInfra {} |
|
|
| impl EnvironmentInfra for MockEnvironmentInfra { |
| type Config = forge_config::ForgeConfig; |
|
|
| fn get_env_var(&self, _key: &str) -> Option<String> { |
| None |
| } |
|
|
| fn get_env_vars(&self) -> BTreeMap<String, String> { |
| BTreeMap::new() |
| } |
|
|
| fn get_environment(&self) -> Environment { |
| use fake::{Fake, Faker}; |
| let fixture: Environment = Faker.fake(); |
| fixture.cwd(PathBuf::from("/test")) |
| } |
|
|
| fn get_config(&self) -> anyhow::Result<forge_config::ForgeConfig> { |
| Ok(forge_config::ForgeConfig { max_read_lines: 2000, ..Default::default() }) |
| } |
|
|
| async fn update_environment(&self, _ops: Vec<ConfigOperation>) -> anyhow::Result<()> { |
| unimplemented!() |
| } |
| } |
|
|
| impl MockFileService { |
| pub fn new() -> Self { |
| let mut files = HashMap::new(); |
| |
| files.insert( |
| PathBuf::from("/test/file1.txt"), |
| "This is a text file content".to_string(), |
| ); |
| files.insert( |
| PathBuf::from("/test/image.png"), |
| "mock-binary-content".to_string(), |
| ); |
| files.insert( |
| PathBuf::from("/test/image with spaces.jpg"), |
| "mock-jpeg-content".to_string(), |
| ); |
|
|
| let binary_exts = [ |
| "exe", "dll", "so", "dylib", "bin", "obj", "o", "class", "pyc", "jar", "war", |
| "ear", "zip", "tar", "gz", "rar", "7z", "iso", "img", "pdf", "doc", "docx", "xls", |
| "xlsx", "ppt", "pptx", "bmp", "ico", "mp3", "mp4", "avi", "mov", "sqlite", "db", |
| "bin", |
| ]; |
| let binary_exts = binary_exts.into_iter().map(|s| s.to_string()).collect(); |
|
|
| Self { |
| files: Mutex::new( |
| files |
| .into_iter() |
| .map(|(a, b)| (a, Bytes::from(b))) |
| .collect::<Vec<_>>(), |
| ), |
| binary_exts, |
| } |
| } |
|
|
| pub fn add_file(&self, path: PathBuf, content: String) { |
| let mut files = self.files.lock().unwrap(); |
| files.push((path, Bytes::from_owner(content))); |
| } |
|
|
| pub fn add_dir(&self, path: PathBuf) { |
| let mut files = self.files.lock().unwrap(); |
| files.push((path, Bytes::new())); |
| } |
| } |
|
|
| #[async_trait::async_trait] |
| impl FileReaderInfra for MockFileService { |
| async fn read_utf8(&self, path: &Path) -> anyhow::Result<String> { |
| let files = self.files.lock().unwrap(); |
| match files.iter().find(|v| v.0 == path) { |
| Some((_, content)) => { |
| let bytes = content.clone(); |
| String::from_utf8(bytes.to_vec()) |
| .map_err(|e| anyhow::anyhow!("Invalid UTF-8 in file: {path:?}: {e}")) |
| } |
| None => Err(anyhow::anyhow!("File not found: {path:?}")), |
| } |
| } |
|
|
| fn read_batch_utf8( |
| &self, |
| _: usize, |
| _: Vec<PathBuf>, |
| ) -> impl futures::Stream<Item = (PathBuf, anyhow::Result<String>)> + Send { |
| stream::empty() |
| } |
|
|
| async fn read(&self, path: &Path) -> anyhow::Result<Vec<u8>> { |
| let files = self.files.lock().unwrap(); |
| match files.iter().find(|v| v.0 == path) { |
| Some((_, content)) => Ok(content.to_vec()), |
| None => Err(anyhow::anyhow!("File not found: {path:?}")), |
| } |
| } |
|
|
| async fn range_read_utf8( |
| &self, |
| path: &Path, |
| start_line: u64, |
| end_line: u64, |
| ) -> anyhow::Result<(String, FileInfo)> { |
| |
| let full_content = self.read_utf8(path).await?; |
| let all_lines: Vec<&str> = full_content.lines().collect(); |
|
|
| |
| let start_idx = start_line.saturating_sub(1) as usize; |
| let end_idx = if end_line > 0 { |
| std::cmp::min(end_line as usize, all_lines.len()) |
| } else { |
| all_lines.len() |
| }; |
|
|
| let filtered_lines = if start_idx < all_lines.len() { |
| &all_lines[start_idx..end_idx] |
| } else { |
| &[] |
| }; |
|
|
| let filtered_content = filtered_lines.join("\n"); |
| let actual_start = if filtered_lines.is_empty() { |
| 0 |
| } else { |
| start_line |
| }; |
| let actual_end = if filtered_lines.is_empty() { |
| 0 |
| } else { |
| start_idx as u64 + filtered_lines.len() as u64 |
| }; |
|
|
| |
| let content_hash = compute_hash(&full_content); |
|
|
| Ok(( |
| filtered_content, |
| forge_domain::FileInfo::new( |
| actual_start, |
| actual_end, |
| all_lines.len() as u64, |
| content_hash, |
| ), |
| )) |
| } |
| } |
|
|
| #[derive(Debug)] |
| pub struct MockFileService { |
| files: Mutex<Vec<(PathBuf, Bytes)>>, |
| binary_exts: HashSet<String>, |
| } |
|
|
| #[async_trait::async_trait] |
| impl FileRemoverInfra for MockFileService { |
| async fn remove(&self, path: &Path) -> anyhow::Result<()> { |
| if !self.exists(path).await? { |
| return Err(anyhow::anyhow!("File not found: {path:?}")); |
| } |
| self.files.lock().unwrap().retain(|(p, _)| p != path); |
| Ok(()) |
| } |
| } |
|
|
| #[async_trait::async_trait] |
| impl FileDirectoryInfra for MockFileService { |
| async fn create_dirs(&self, path: &Path) -> anyhow::Result<()> { |
| self.files |
| .lock() |
| .unwrap() |
| .push((path.to_path_buf(), Bytes::new())); |
| Ok(()) |
| } |
| } |
|
|
| #[async_trait::async_trait] |
| impl DirectoryReaderInfra for MockFileService { |
| async fn list_directory_entries( |
| &self, |
| directory: &Path, |
| ) -> anyhow::Result<Vec<(PathBuf, bool)>> { |
| let files = self.files.lock().unwrap(); |
| let mut results = Vec::new(); |
|
|
| for (path, content) in files.iter() { |
| |
| if let Some(parent) = path.parent() |
| && parent == directory |
| { |
| |
| let is_dir = content.is_empty(); |
| results.push((path.clone(), is_dir)); |
| } |
| } |
|
|
| Ok(results) |
| } |
|
|
| async fn read_directory_files( |
| &self, |
| directory: &Path, |
| _pattern: Option<&str>, |
| ) -> anyhow::Result<Vec<(PathBuf, String)>> { |
| let files = self.files.lock().unwrap(); |
| let mut results = Vec::new(); |
|
|
| for (path, content) in files.iter() { |
| |
| if let Some(parent) = path.parent() |
| && parent == directory |
| { |
| let content_str = String::from_utf8(content.to_vec()).unwrap_or_default(); |
| results.push((path.clone(), content_str)); |
| } |
| } |
|
|
| Ok(results) |
| } |
| } |
|
|
| #[async_trait::async_trait] |
| impl FileWriterInfra for MockFileService { |
| async fn write(&self, path: &Path, contents: Bytes) -> anyhow::Result<()> { |
| let index = self.files.lock().unwrap().iter().position(|v| v.0 == path); |
| if let Some(index) = index { |
| self.files.lock().unwrap().remove(index); |
| } |
| self.files |
| .lock() |
| .unwrap() |
| .push((path.to_path_buf(), contents)); |
| Ok(()) |
| } |
|
|
| async fn append(&self, path: &Path, contents: Bytes) -> anyhow::Result<()> { |
| let mut existing = bytes::Bytes::new(); |
| let index = self.files.lock().unwrap().iter().position(|v| v.0 == path); |
| if let Some(index) = index { |
| existing = self.files.lock().unwrap().remove(index).1; |
| } |
| let mut combined = existing.to_vec(); |
| combined.extend_from_slice(&contents); |
| self.files |
| .lock() |
| .unwrap() |
| .push((path.to_path_buf(), combined.into())); |
| Ok(()) |
| } |
|
|
| async fn write_temp(&self, _: &str, _: &str, content: &str) -> anyhow::Result<PathBuf> { |
| let temp_dir = crate::utils::TempDir::new().unwrap(); |
| let path = temp_dir.path(); |
|
|
| self.write(&path, content.to_string().into()).await?; |
|
|
| Ok(path) |
| } |
| } |
|
|
| #[async_trait::async_trait] |
| impl FileInfoInfra for MockFileService { |
| async fn is_file(&self, path: &Path) -> anyhow::Result<bool> { |
| Ok(self |
| .files |
| .lock() |
| .unwrap() |
| .iter() |
| .filter(|v| v.0.extension().is_some()) |
| .any(|(p, _)| p == path)) |
| } |
|
|
| async fn is_binary(&self, _path: &Path) -> anyhow::Result<bool> { |
| let ext = _path |
| .extension() |
| .and_then(|s| s.to_str()) |
| .map(|s| s.to_lowercase()); |
| Ok(ext.map(|e| self.binary_exts.contains(&e)).unwrap_or(false)) |
| } |
|
|
| async fn exists(&self, path: &Path) -> anyhow::Result<bool> { |
| Ok(self.files.lock().unwrap().iter().any(|(p, _)| p == path)) |
| } |
|
|
| async fn file_size(&self, path: &Path) -> anyhow::Result<u64> { |
| let files = self.files.lock().unwrap(); |
| if let Some((_, content)) = files.iter().find(|(p, _)| p == path) { |
| Ok(content.len() as u64) |
| } else { |
| Err(anyhow::anyhow!("File not found: {}", path.display())) |
| } |
| } |
| } |
|
|
| |
| #[derive(Debug, Clone)] |
| pub struct MockCompositeService { |
| file_service: Arc<MockFileService>, |
| env_service: Arc<MockEnvironmentInfra>, |
| } |
|
|
| impl MockCompositeService { |
| pub fn new() -> Self { |
| Self { |
| file_service: Arc::new(MockFileService::new()), |
| env_service: Arc::new(MockEnvironmentInfra {}), |
| } |
| } |
|
|
| pub fn add_file(&self, path: PathBuf, content: String) { |
| self.file_service.add_file(path, content); |
| } |
| } |
|
|
| #[async_trait::async_trait] |
| impl FileReaderInfra for MockCompositeService { |
| async fn read_utf8(&self, path: &Path) -> anyhow::Result<String> { |
| self.file_service.read_utf8(path).await |
| } |
|
|
| fn read_batch_utf8( |
| &self, |
| batch_size: usize, |
| paths: Vec<PathBuf>, |
| ) -> impl futures::Stream<Item = (PathBuf, anyhow::Result<String>)> + Send { |
| self.file_service.read_batch_utf8(batch_size, paths) |
| } |
|
|
| async fn read(&self, path: &Path) -> anyhow::Result<Vec<u8>> { |
| self.file_service.read(path).await |
| } |
|
|
| async fn range_read_utf8( |
| &self, |
| path: &Path, |
| start_line: u64, |
| end_line: u64, |
| ) -> anyhow::Result<(String, forge_domain::FileInfo)> { |
| self.file_service |
| .range_read_utf8(path, start_line, end_line) |
| .await |
| } |
| } |
|
|
| impl EnvironmentInfra for MockCompositeService { |
| type Config = forge_config::ForgeConfig; |
|
|
| fn get_environment(&self) -> Environment { |
| self.env_service.get_environment() |
| } |
|
|
| fn get_config(&self) -> anyhow::Result<forge_config::ForgeConfig> { |
| self.env_service.get_config() |
| } |
|
|
| fn update_environment( |
| &self, |
| ops: Vec<ConfigOperation>, |
| ) -> impl std::future::Future<Output = anyhow::Result<()>> + Send { |
| let env_service = self.env_service.clone(); |
| async move { env_service.update_environment(ops).await } |
| } |
|
|
| fn get_env_var(&self, key: &str) -> Option<String> { |
| self.env_service.get_env_var(key) |
| } |
|
|
| fn get_env_vars(&self) -> BTreeMap<String, String> { |
| self.env_service.get_env_vars() |
| } |
| } |
|
|
| #[async_trait::async_trait] |
| impl FileInfoInfra for MockCompositeService { |
| async fn is_binary(&self, path: &Path) -> anyhow::Result<bool> { |
| self.file_service.is_binary(path).await |
| } |
|
|
| async fn is_file(&self, path: &Path) -> anyhow::Result<bool> { |
| self.file_service.is_file(path).await |
| } |
|
|
| async fn exists(&self, path: &Path) -> anyhow::Result<bool> { |
| self.file_service.exists(path).await |
| } |
|
|
| async fn file_size(&self, path: &Path) -> anyhow::Result<u64> { |
| self.file_service.file_size(path).await |
| } |
| } |
|
|
| #[async_trait::async_trait] |
| impl DirectoryReaderInfra for MockCompositeService { |
| async fn list_directory_entries( |
| &self, |
| directory: &Path, |
| ) -> anyhow::Result<Vec<(PathBuf, bool)>> { |
| self.file_service.list_directory_entries(directory).await |
| } |
|
|
| async fn read_directory_files( |
| &self, |
| directory: &Path, |
| pattern: Option<&str>, |
| ) -> anyhow::Result<Vec<(PathBuf, String)>> { |
| self.file_service |
| .read_directory_files(directory, pattern) |
| .await |
| } |
| } |
|
|
| #[tokio::test] |
| async fn test_add_url_with_text_file() { |
| |
| let infra = Arc::new(MockCompositeService::new()); |
| let chat_request = ForgeChatRequest::new(infra.clone()); |
|
|
| |
| let url = "@[/test/file1.txt]".to_string(); |
|
|
| |
| let attachments = chat_request.attachments(&url).await.unwrap(); |
|
|
| |
| |
| assert_eq!(attachments.len(), 1); |
| let attachment = attachments.first().unwrap(); |
| assert_eq!(attachment.path, "/test/file1.txt"); |
|
|
| |
| assert!(attachment.content.contains("This is a text file content")); |
| } |
|
|
| #[tokio::test] |
| async fn test_add_url_with_image() { |
| |
| let infra = Arc::new(MockCompositeService::new()); |
| let chat_request = ForgeChatRequest::new(infra.clone()); |
|
|
| |
| let url = "@[/test/image.png]".to_string(); |
|
|
| |
| let attachments = chat_request.attachments(&url).await.unwrap(); |
|
|
| |
| assert_eq!(attachments.len(), 1); |
| let attachment = attachments.first().unwrap(); |
| assert_eq!(attachment.path, "/test/image.png"); |
|
|
| |
| |
| let expected_base64 = |
| base64::engine::general_purpose::STANDARD.encode("mock-binary-content"); |
| assert_eq!( |
| attachment.content.as_image().unwrap().url().as_str(), |
| format!("data:image/png;base64,{expected_base64}") |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn test_add_url_with_jpg_image_with_spaces() { |
| |
| let infra = Arc::new(MockCompositeService::new()); |
| let chat_request = ForgeChatRequest::new(infra.clone()); |
|
|
| |
| let url = "@[/test/image with spaces.jpg]".to_string(); |
|
|
| |
| let attachments = chat_request.attachments(&url).await.unwrap(); |
|
|
| |
| assert_eq!(attachments.len(), 1); |
| let attachment = attachments.first().unwrap(); |
| assert_eq!(attachment.path, "/test/image with spaces.jpg"); |
|
|
| |
| |
| let expected_base64 = base64::engine::general_purpose::STANDARD.encode("mock-jpeg-content"); |
| assert_eq!( |
| attachment.content.as_image().unwrap().url().as_str(), |
| format!("data:image/jpeg;base64,{expected_base64}") |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn test_add_url_with_multiple_files() { |
| |
| let infra = Arc::new(MockCompositeService::new()); |
|
|
| |
| infra.add_file( |
| PathBuf::from("/test/file2.txt"), |
| "This is another text file".to_string(), |
| ); |
|
|
| let chat_request = ForgeChatRequest::new(infra.clone()); |
|
|
| |
| let url = "@[/test/file1.txt] @[/test/file2.txt] @[/test/image.png]".to_string(); |
|
|
| |
| let attachments = chat_request.attachments(&url).await.unwrap(); |
|
|
| |
| |
| assert_eq!(attachments.len(), 3); |
|
|
| |
| let has_file1 = attachments.iter().any(|a| { |
| a.path == "/test/file1.txt" |
| && matches!(a.content, AttachmentContent::FileContent { .. }) |
| }); |
| let has_file2 = attachments.iter().any(|a| { |
| a.path == "/test/file2.txt" |
| && matches!(a.content, AttachmentContent::FileContent { .. }) |
| }); |
| let has_image = attachments.iter().any(|a| { |
| a.path == "/test/image.png" && matches!(a.content, AttachmentContent::Image(_)) |
| }); |
|
|
| assert!(has_file1, "Missing file1.txt in attachments"); |
| assert!(has_file2, "Missing file2.txt in attachments"); |
| assert!(has_image, "Missing image.png in attachments"); |
| } |
|
|
| #[tokio::test] |
| async fn test_add_url_with_nonexistent_file() { |
| |
| let infra = Arc::new(MockCompositeService::new()); |
| let chat_request = ForgeChatRequest::new(infra.clone()); |
|
|
| |
| let url = "@[/test/nonexistent.txt]".to_string(); |
|
|
| |
| let result = chat_request.attachments(&url).await; |
|
|
| |
| assert!(result.is_err()); |
| assert!(result.unwrap_err().to_string().contains("File not found")); |
| } |
|
|
| #[tokio::test] |
| async fn test_add_url_empty() { |
| |
| let infra = Arc::new(MockCompositeService::new()); |
| let chat_request = ForgeChatRequest::new(infra.clone()); |
|
|
| |
| let url = "".to_string(); |
|
|
| |
| let attachments = chat_request.attachments(&url).await.unwrap(); |
|
|
| |
| assert_eq!(attachments.len(), 0); |
| } |
|
|
| #[tokio::test] |
| async fn test_add_url_with_unsupported_extension() { |
| |
| let infra = Arc::new(MockCompositeService::new()); |
|
|
| |
| infra.add_file( |
| PathBuf::from("/test/unknown.xyz"), |
| "Some content".to_string(), |
| ); |
|
|
| let chat_request = ForgeChatRequest::new(infra.clone()); |
|
|
| |
| let url = "@[/test/unknown.xyz]".to_string(); |
|
|
| |
| let attachments = chat_request.attachments(&url).await.unwrap(); |
|
|
| |
| assert_eq!(attachments.len(), 1); |
| let attachment = attachments.first().unwrap(); |
| assert_eq!(attachment.path, "/test/unknown.xyz"); |
|
|
| |
| assert!(attachment.content.contains("Some content")); |
| } |
|
|
| #[tokio::test] |
| async fn test_attachment_range_information() { |
| |
| let infra = Arc::new(MockCompositeService::new()); |
|
|
| |
| infra.add_file( |
| PathBuf::from("/test/multiline.txt"), |
| "Line 1\nLine 2\nLine 3\nLine 4\nLine 5".to_string(), |
| ); |
|
|
| let chat_request = ForgeChatRequest::new(infra.clone()); |
| let url = "@[/test/multiline.txt]".to_string(); |
|
|
| |
| let attachments = chat_request.attachments(&url).await.unwrap(); |
|
|
| |
| assert_eq!(attachments.len(), 1); |
| let attachment = attachments.first().unwrap(); |
|
|
| |
| let range_info = attachment.content.range_info(); |
| assert!( |
| range_info.is_some(), |
| "Range information should be present for file content" |
| ); |
|
|
| let (start_line, end_line, total_lines) = range_info.unwrap(); |
| assert_eq!(start_line, 1, "Start line should be 1"); |
| assert!(end_line >= start_line, "End line should be >= start line"); |
| assert!(total_lines >= end_line, "Total lines should be >= end line"); |
|
|
| |
| let file_content = attachment.content.file_content(); |
| assert!(file_content.is_some(), "File content should be accessible"); |
| assert!( |
| file_content.unwrap().contains("Line 1"), |
| "Should contain file content" |
| ); |
| } |
|
|
| |
| #[tokio::test] |
| async fn test_range_functionality_single_line() { |
| let infra = Arc::new(MockCompositeService::new()); |
|
|
| |
| infra.add_file( |
| PathBuf::from("/test/multiline.txt"), |
| "Line 1\nLine 2\nLine 3\nLine 4\nLine 5".to_string(), |
| ); |
|
|
| let chat_request = ForgeChatRequest::new(infra.clone()); |
|
|
| |
| let url = "@[/test/multiline.txt:2:2]"; |
| let attachments = chat_request.attachments(url).await.unwrap(); |
|
|
| assert_eq!(attachments.len(), 1); |
| assert_eq!( |
| attachments[0].content, |
| AttachmentContent::FileContent { |
| content: "2:Line 2".to_string(), |
| info: FileInfo::new( |
| 2, |
| 2, |
| 5, |
| compute_hash("Line 1\nLine 2\nLine 3\nLine 4\nLine 5") |
| ), |
| } |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn test_range_functionality_multiple_lines() { |
| let infra = Arc::new(MockCompositeService::new()); |
|
|
| |
| infra.add_file( |
| PathBuf::from("/test/range_test.txt"), |
| "Line 1\nLine 2\nLine 3\nLine 4\nLine 5\nLine 6".to_string(), |
| ); |
|
|
| let chat_request = ForgeChatRequest::new(infra.clone()); |
|
|
| |
| let url = "@[/test/range_test.txt:2:4]"; |
| let attachments = chat_request.attachments(url).await.unwrap(); |
|
|
| assert_eq!(attachments.len(), 1); |
| assert_eq!(attachments.len(), 1); |
| assert_eq!( |
| attachments[0].content, |
| AttachmentContent::FileContent { |
| content: "2:Line 2\n3:Line 3\n4:Line 4".to_string(), |
| info: FileInfo::new( |
| 2, |
| 4, |
| 6, |
| compute_hash("Line 1\nLine 2\nLine 3\nLine 4\nLine 5\nLine 6") |
| ), |
| } |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn test_range_functionality_from_start() { |
| let infra = Arc::new(MockCompositeService::new()); |
|
|
| infra.add_file( |
| PathBuf::from("/test/start_range.txt"), |
| "First\nSecond\nThird\nFourth".to_string(), |
| ); |
|
|
| let chat_request = ForgeChatRequest::new(infra.clone()); |
|
|
| |
| let url = "@[/test/start_range.txt:1:2]"; |
| let attachments = chat_request.attachments(url).await.unwrap(); |
| assert_eq!( |
| attachments[0].content, |
| AttachmentContent::FileContent { |
| content: "1:First\n2:Second".to_string(), |
| info: FileInfo::new(1, 2, 4, compute_hash("First\nSecond\nThird\nFourth")), |
| } |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn test_range_functionality_to_end() { |
| let infra = Arc::new(MockCompositeService::new()); |
|
|
| infra.add_file( |
| PathBuf::from("/test/end_range.txt"), |
| "Alpha\nBeta\nGamma\nDelta\nEpsilon".to_string(), |
| ); |
|
|
| let chat_request = ForgeChatRequest::new(infra.clone()); |
|
|
| |
| let url = "@[/test/end_range.txt:3:5]"; |
| let attachments = chat_request.attachments(url).await.unwrap(); |
| assert_eq!( |
| attachments[0].content, |
| AttachmentContent::FileContent { |
| content: "3:Gamma\n4:Delta\n5:Epsilon".to_string(), |
| info: FileInfo::new(3, 5, 5, compute_hash("Alpha\nBeta\nGamma\nDelta\nEpsilon")), |
| } |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn test_range_functionality_edge_cases() { |
| let infra = Arc::new(MockCompositeService::new()); |
|
|
| infra.add_file( |
| PathBuf::from("/test/edge_case.txt"), |
| "Only line".to_string(), |
| ); |
|
|
| let chat_request = ForgeChatRequest::new(infra.clone()); |
|
|
| |
| let url = "@[/test/edge_case.txt:1:10]"; |
| let attachments = chat_request.attachments(url).await.unwrap(); |
| assert_eq!( |
| attachments[0].content, |
| AttachmentContent::FileContent { |
| content: "1:Only line".to_string(), |
| info: FileInfo::new(1, 1, 1, compute_hash("Only line")), |
| } |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn test_range_functionality_combined_with_multiple_files() { |
| let infra = Arc::new(MockCompositeService::new()); |
|
|
| infra.add_file(PathBuf::from("/test/file_a.txt"), "A1\nA2\nA3".to_string()); |
| infra.add_file( |
| PathBuf::from("/test/file_b.txt"), |
| "B1\nB2\nB3\nB4".to_string(), |
| ); |
|
|
| let chat_request = ForgeChatRequest::new(infra.clone()); |
|
|
| |
| let url = "Check @[/test/file_a.txt:1:2] and @[/test/file_b.txt:3:4]"; |
| let attachments = chat_request.attachments(url).await.unwrap(); |
|
|
| assert_eq!(attachments.len(), 2); |
| assert_eq!( |
| attachments[0].content, |
| AttachmentContent::FileContent { |
| content: "1:A1\n2:A2".to_string(), |
| info: FileInfo::new(1, 2, 3, compute_hash("A1\nA2\nA3")), |
| } |
| ); |
| assert_eq!( |
| attachments[1].content, |
| AttachmentContent::FileContent { |
| content: "3:B3\n4:B4".to_string(), |
| info: FileInfo::new(3, 4, 4, compute_hash("B1\nB2\nB3\nB4")), |
| } |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn test_range_functionality_preserves_metadata() { |
| let infra = Arc::new(MockCompositeService::new()); |
|
|
| infra.add_file( |
| PathBuf::from("/test/metadata_test.txt"), |
| "Meta1\nMeta2\nMeta3\nMeta4\nMeta5\nMeta6\nMeta7".to_string(), |
| ); |
|
|
| let chat_request = ForgeChatRequest::new(infra.clone()); |
|
|
| |
| let url = "@[/test/metadata_test.txt:3:5]"; |
| let attachments = chat_request.attachments(url).await.unwrap(); |
|
|
| assert_eq!(attachments.len(), 1); |
| assert_eq!(attachments[0].path, "/test/metadata_test.txt"); |
| assert_eq!( |
| attachments[0].content, |
| AttachmentContent::FileContent { |
| content: "3:Meta3\n4:Meta4\n5:Meta5".to_string(), |
| info: FileInfo::new( |
| 3, |
| 5, |
| 7, |
| compute_hash("Meta1\nMeta2\nMeta3\nMeta4\nMeta5\nMeta6\nMeta7") |
| ), |
| } |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn test_range_functionality_vs_full_file() { |
| let infra = Arc::new(MockCompositeService::new()); |
|
|
| infra.add_file( |
| PathBuf::from("/test/comparison.txt"), |
| "Full1\nFull2\nFull3\nFull4\nFull5".to_string(), |
| ); |
|
|
| let chat_request = ForgeChatRequest::new(infra.clone()); |
|
|
| |
| |
| |
| let full_file_hash = compute_hash("Full1\nFull2\nFull3\nFull4\nFull5"); |
|
|
| let url_full = "@[/test/comparison.txt]"; |
| let url_range = "@[/test/comparison.txt:2:4]"; |
| let url_range_start = "@[/test/comparison.txt:2]"; |
|
|
| let attachments_full = chat_request.attachments(url_full).await.unwrap(); |
| let attachments_range = chat_request.attachments(url_range).await.unwrap(); |
| let attachments_range_start = chat_request.attachments(url_range_start).await.unwrap(); |
|
|
| assert_eq!(attachments_full.len(), 1); |
| assert_eq!( |
| attachments_full[0].content, |
| AttachmentContent::FileContent { |
| content: "1:Full1\n2:Full2\n3:Full3\n4:Full4\n5:Full5".to_string(), |
| info: FileInfo::new(1, 5, 5, full_file_hash.clone()), |
| } |
| ); |
|
|
| assert_eq!(attachments_range.len(), 1); |
| assert_eq!( |
| attachments_range[0].content, |
| AttachmentContent::FileContent { |
| content: "2:Full2\n3:Full3\n4:Full4".to_string(), |
| info: FileInfo::new(2, 4, 5, full_file_hash.clone()), |
| } |
| ); |
|
|
| assert_eq!(attachments_range_start.len(), 1); |
| assert_eq!( |
| attachments_range_start[0].content, |
| AttachmentContent::FileContent { |
| content: "2:Full2\n3:Full3\n4:Full4\n5:Full5".to_string(), |
| info: FileInfo::new(2, 5, 5, full_file_hash), |
| } |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn test_add_url_with_directory() { |
| let infra = Arc::new(MockCompositeService::new()); |
|
|
| |
| infra.file_service.add_dir(PathBuf::from("/test/mydir")); |
| infra.add_file( |
| PathBuf::from("/test/mydir/file1.txt"), |
| "Content of file1".to_string(), |
| ); |
| infra.add_file( |
| PathBuf::from("/test/mydir/file2.txt"), |
| "Content of file2".to_string(), |
| ); |
| infra |
| .file_service |
| .add_dir(PathBuf::from("/test/mydir/subdir")); |
|
|
| let chat_request = ForgeChatRequest::new(infra.clone()); |
|
|
| |
| let url = "@[/test/mydir]"; |
| let attachments = chat_request.attachments(url).await.unwrap(); |
|
|
| |
| assert_eq!(attachments.len(), 1); |
| let attachment = attachments.first().unwrap(); |
|
|
| |
| match &attachment.content { |
| AttachmentContent::DirectoryListing { entries } => { |
| |
| assert_eq!(entries.len(), 3); |
|
|
| |
| let file1 = entries.iter().find(|e| e.path == "file1.txt").unwrap(); |
| assert!(!file1.is_dir); |
|
|
| let file2 = entries.iter().find(|e| e.path == "file2.txt").unwrap(); |
| assert!(!file2.is_dir); |
|
|
| |
| let subdir = entries.iter().find(|e| e.path == "subdir").unwrap(); |
| assert!(subdir.is_dir); |
| } |
| _ => panic!("Expected DirectoryListing attachment"), |
| } |
|
|
| |
| assert_eq!(attachment.path, "/test/mydir"); |
| } |
|
|
| #[tokio::test] |
| async fn test_add_url_with_empty_directory() { |
| let infra = Arc::new(MockCompositeService::new()); |
|
|
| |
| infra.file_service.add_dir(PathBuf::from("/test/emptydir")); |
|
|
| let chat_request = ForgeChatRequest::new(infra.clone()); |
|
|
| |
| let url = "@[/test/emptydir]"; |
| let attachments = chat_request.attachments(url).await.unwrap(); |
|
|
| |
| assert_eq!(attachments.len(), 1); |
| let attachment = attachments.first().unwrap(); |
|
|
| match &attachment.content { |
| AttachmentContent::DirectoryListing { entries } => { |
| assert_eq!(entries.len(), 0); |
| } |
| _ => panic!("Expected DirectoryListing attachment"), |
| } |
|
|
| |
| assert_eq!(attachment.path, "/test/emptydir"); |
| } |
|
|
| #[tokio::test] |
| async fn test_add_url_with_mixed_files_and_directory() { |
| let infra = Arc::new(MockCompositeService::new()); |
|
|
| |
| infra.file_service.add_dir(PathBuf::from("/test/mixdir")); |
| infra.add_file( |
| PathBuf::from("/test/mixdir/dir_file.txt"), |
| "File in directory".to_string(), |
| ); |
|
|
| |
| infra.add_file( |
| PathBuf::from("/test/standalone.txt"), |
| "Standalone file".to_string(), |
| ); |
|
|
| let chat_request = ForgeChatRequest::new(infra.clone()); |
|
|
| |
| let url = "@[/test/mixdir] @[/test/standalone.txt]"; |
| let attachments = chat_request.attachments(url).await.unwrap(); |
|
|
| |
| assert_eq!(attachments.len(), 2); |
|
|
| |
| let dir_attachment = attachments |
| .iter() |
| .find(|a| a.path == "/test/mixdir") |
| .unwrap(); |
| match &dir_attachment.content { |
| AttachmentContent::DirectoryListing { entries } => { |
| assert_eq!(entries.len(), 1); |
| |
| let dir_file = entries.iter().find(|e| e.path == "dir_file.txt").unwrap(); |
| assert!(!dir_file.is_dir); |
| } |
| _ => panic!("Expected DirectoryListing attachment"), |
| } |
|
|
| |
| let file_attachment = attachments |
| .iter() |
| .find(|a| a.path == "/test/standalone.txt") |
| .unwrap(); |
| assert!(matches!( |
| &file_attachment.content, |
| AttachmentContent::FileContent { .. } |
| )); |
| } |
|
|
| #[tokio::test] |
| async fn test_directory_sorting_dirs_first() { |
| let infra = Arc::new(MockCompositeService::new()); |
|
|
| |
| infra.file_service.add_dir(PathBuf::from("/test/sortdir")); |
| infra.add_file( |
| PathBuf::from("/test/sortdir/zebra.txt"), |
| "File Z".to_string(), |
| ); |
| infra |
| .file_service |
| .add_dir(PathBuf::from("/test/sortdir/apple_dir")); |
| infra.add_file( |
| PathBuf::from("/test/sortdir/banana.txt"), |
| "File B".to_string(), |
| ); |
| infra |
| .file_service |
| .add_dir(PathBuf::from("/test/sortdir/zoo_dir")); |
| infra.add_file( |
| PathBuf::from("/test/sortdir/cherry.txt"), |
| "File C".to_string(), |
| ); |
| infra |
| .file_service |
| .add_dir(PathBuf::from("/test/sortdir/berry_dir")); |
|
|
| let chat_request = ForgeChatRequest::new(infra.clone()); |
| let url = "@[/test/sortdir]"; |
| let attachments = chat_request.attachments(url).await.unwrap(); |
|
|
| |
| assert_eq!(attachments.len(), 1); |
| let attachment = attachments.first().unwrap(); |
|
|
| match &attachment.content { |
| AttachmentContent::DirectoryListing { entries } => { |
| assert_eq!(entries.len(), 6); |
|
|
| |
| assert!(entries[0].is_dir); |
| assert_eq!(entries[0].path, "apple_dir"); |
|
|
| assert!(entries[1].is_dir); |
| assert_eq!(entries[1].path, "berry_dir"); |
|
|
| assert!(entries[2].is_dir); |
| assert_eq!(entries[2].path, "zoo_dir"); |
|
|
| |
| assert!(!entries[3].is_dir); |
| assert_eq!(entries[3].path, "banana.txt"); |
|
|
| assert!(!entries[4].is_dir); |
| assert_eq!(entries[4].path, "cherry.txt"); |
|
|
| assert!(!entries[5].is_dir); |
| assert_eq!(entries[5].path, "zebra.txt"); |
| } |
| _ => panic!("Expected DirectoryListing attachment"), |
| } |
| } |
|
|
| #[tokio::test] |
| async fn test_directory_sorting_only_directories() { |
| let infra = Arc::new(MockCompositeService::new()); |
|
|
| |
| infra.file_service.add_dir(PathBuf::from("/test/onlydirs")); |
| infra |
| .file_service |
| .add_dir(PathBuf::from("/test/onlydirs/zebra_dir")); |
| infra |
| .file_service |
| .add_dir(PathBuf::from("/test/onlydirs/alpha_dir")); |
| infra |
| .file_service |
| .add_dir(PathBuf::from("/test/onlydirs/middle_dir")); |
|
|
| let chat_request = ForgeChatRequest::new(infra.clone()); |
| let url = "@[/test/onlydirs]"; |
| let attachments = chat_request.attachments(url).await.unwrap(); |
|
|
| match &attachments[0].content { |
| AttachmentContent::DirectoryListing { entries } => { |
| assert_eq!(entries.len(), 3); |
|
|
| |
| assert!(entries[0].is_dir); |
| assert_eq!(entries[0].path, "alpha_dir"); |
|
|
| assert!(entries[1].is_dir); |
| assert_eq!(entries[1].path, "middle_dir"); |
|
|
| assert!(entries[2].is_dir); |
| assert_eq!(entries[2].path, "zebra_dir"); |
| } |
| _ => panic!("Expected DirectoryListing attachment"), |
| } |
| } |
|
|
| #[tokio::test] |
| async fn test_directory_sorting_only_files() { |
| let infra = Arc::new(MockCompositeService::new()); |
|
|
| |
| infra.file_service.add_dir(PathBuf::from("/test/onlyfiles")); |
| infra.add_file(PathBuf::from("/test/onlyfiles/zebra.txt"), "Z".to_string()); |
| infra.add_file(PathBuf::from("/test/onlyfiles/alpha.txt"), "A".to_string()); |
| infra.add_file(PathBuf::from("/test/onlyfiles/middle.txt"), "M".to_string()); |
|
|
| let chat_request = ForgeChatRequest::new(infra.clone()); |
| let url = "@[/test/onlyfiles]"; |
| let attachments = chat_request.attachments(url).await.unwrap(); |
|
|
| match &attachments[0].content { |
| AttachmentContent::DirectoryListing { entries } => { |
| assert_eq!(entries.len(), 3); |
|
|
| |
| assert!(!entries[0].is_dir); |
| assert_eq!(entries[0].path, "alpha.txt"); |
|
|
| assert!(!entries[1].is_dir); |
| assert_eq!(entries[1].path, "middle.txt"); |
|
|
| assert!(!entries[2].is_dir); |
| assert_eq!(entries[2].path, "zebra.txt"); |
| } |
| _ => panic!("Expected DirectoryListing attachment"), |
| } |
| } |
|
|
| #[tokio::test] |
| async fn test_directory_sorting_case_insensitive() { |
| let infra = Arc::new(MockCompositeService::new()); |
|
|
| |
| infra.file_service.add_dir(PathBuf::from("/test/casetest")); |
| infra |
| .file_service |
| .add_dir(PathBuf::from("/test/casetest/Zebra_dir")); |
| infra |
| .file_service |
| .add_dir(PathBuf::from("/test/casetest/apple_dir")); |
| infra.add_file(PathBuf::from("/test/casetest/Zebra.txt"), "Z".to_string()); |
| infra.add_file(PathBuf::from("/test/casetest/apple.txt"), "A".to_string()); |
|
|
| let chat_request = ForgeChatRequest::new(infra.clone()); |
| let url = "@[/test/casetest]"; |
| let attachments = chat_request.attachments(url).await.unwrap(); |
|
|
| match &attachments[0].content { |
| AttachmentContent::DirectoryListing { entries } => { |
| assert_eq!(entries.len(), 4); |
|
|
| |
| assert!(entries[0].is_dir); |
| assert!(entries[1].is_dir); |
|
|
| |
| assert!(!entries[2].is_dir); |
| assert!(!entries[3].is_dir); |
|
|
| |
| |
| |
| |
| } |
| _ => panic!("Expected DirectoryListing attachment"), |
| } |
| } |
| } |
|
|