Skip to content

Commit 5414164

Browse files
feat(attachment): add directory listing support (#1905)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent f37adee commit 5414164

8 files changed

Lines changed: 633 additions & 13 deletions

File tree

crates/forge_app/src/infra.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,15 @@ pub trait HttpInfra: Send + Sync + 'static {
199199
/// Service for reading multiple files from a directory asynchronously
200200
#[async_trait::async_trait]
201201
pub trait DirectoryReaderInfra: Send + Sync {
202+
/// Lists all entries (files and directories) in a directory without reading
203+
/// file contents Returns a vector of tuples containing (entry_path,
204+
/// is_directory) This is much more efficient than read_directory_files
205+
/// when you only need to list entries
206+
async fn list_directory_entries(
207+
&self,
208+
directory: &Path,
209+
) -> anyhow::Result<Vec<(PathBuf, bool)>>;
210+
202211
/// Reads all files in a directory that match the given filter pattern
203212
/// Returns a vector of tuples containing (file_path, file_content)
204213
/// Files are read asynchronously/in parallel for better performance

crates/forge_domain/src/attachment.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,15 @@ pub enum AttachmentContent {
1818
end_line: u64,
1919
total_lines: u64,
2020
},
21+
DirectoryListing {
22+
entries: Vec<DirectoryEntry>,
23+
},
24+
}
25+
26+
#[derive(Debug, serde::Deserialize, serde::Serialize, Clone, PartialEq, Eq)]
27+
pub struct DirectoryEntry {
28+
pub path: String,
29+
pub is_dir: bool,
2130
}
2231

2332
impl AttachmentContent {
@@ -32,6 +41,7 @@ impl AttachmentContent {
3241
match self {
3342
AttachmentContent::Image(_) => false,
3443
AttachmentContent::FileContent { content, .. } => content.contains(text),
44+
AttachmentContent::DirectoryListing { .. } => false,
3545
}
3646
}
3747

crates/forge_domain/src/context.rs

Lines changed: 99 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,22 @@ impl Context {
399399
message = message.model(model);
400400
}
401401

402+
message.into()
403+
}
404+
AttachmentContent::DirectoryListing { entries } => {
405+
let elm = Element::new("directory_listing")
406+
.attr("path", attachment.path)
407+
.append(entries.into_iter().map(|entry| {
408+
let tag_name = if entry.is_dir { "dir" } else { "file" };
409+
Element::new(tag_name).text(entry.path)
410+
}));
411+
412+
let mut message = TextMessage::new(Role::User, elm.to_string()).droppable(true);
413+
414+
if let Some(model) = model_id.clone() {
415+
message = message.model(model);
416+
}
417+
402418
message.into()
403419
}
404420
})
@@ -586,8 +602,8 @@ mod tests {
586602
use pretty_assertions::assert_eq;
587603

588604
use super::*;
589-
use crate::estimate_token_count;
590605
use crate::transformer::Transformer;
606+
use crate::{DirectoryEntry, estimate_token_count};
591607

592608
#[test]
593609
fn test_override_system_message() {
@@ -1008,4 +1024,86 @@ mod tests {
10081024
);
10091025
}
10101026
}
1027+
1028+
#[test]
1029+
fn test_add_attachments_directory_listing() {
1030+
let fixture_attachments = vec![Attachment {
1031+
path: "/test/mydir".to_string(),
1032+
content: AttachmentContent::DirectoryListing {
1033+
entries: vec![
1034+
DirectoryEntry { path: "/test/mydir/file1.txt".to_string(), is_dir: false },
1035+
DirectoryEntry { path: "/test/mydir/file2.rs".to_string(), is_dir: false },
1036+
DirectoryEntry { path: "/test/mydir/subdir".to_string(), is_dir: true },
1037+
],
1038+
},
1039+
}];
1040+
1041+
let actual = Context::default().add_attachments(fixture_attachments, None);
1042+
1043+
// Verify message was added
1044+
assert_eq!(actual.messages.len(), 1);
1045+
1046+
// Verify directory listing is formatted correctly as XML
1047+
let message = actual.messages.first().unwrap();
1048+
assert!(
1049+
message.is_droppable(),
1050+
"Directory listing should be marked as droppable"
1051+
);
1052+
1053+
let text = message.to_text();
1054+
// The XML is encoded within the message content
1055+
assert!(text.contains("&lt;directory_listing"));
1056+
// Check that files use <file> tag
1057+
assert!(text.contains("&lt;file&gt;"));
1058+
// Check that directories use <dir> tag
1059+
assert!(text.contains("&lt;dir&gt;"));
1060+
}
1061+
1062+
#[test]
1063+
fn test_directory_listing_sorted_dirs_first() {
1064+
// Create entries already sorted (as they would come from attachment service)
1065+
// Directories first, then files, all sorted alphabetically
1066+
let fixture_attachments = vec![Attachment {
1067+
path: "/test/root".to_string(),
1068+
content: AttachmentContent::DirectoryListing {
1069+
entries: vec![
1070+
DirectoryEntry { path: "apple_dir".to_string(), is_dir: true },
1071+
DirectoryEntry { path: "berry_dir".to_string(), is_dir: true },
1072+
DirectoryEntry { path: "zoo_dir".to_string(), is_dir: true },
1073+
DirectoryEntry { path: "banana.txt".to_string(), is_dir: false },
1074+
DirectoryEntry { path: "cherry.txt".to_string(), is_dir: false },
1075+
DirectoryEntry { path: "zebra.txt".to_string(), is_dir: false },
1076+
],
1077+
},
1078+
}];
1079+
1080+
let actual = Context::default().add_attachments(fixture_attachments, None);
1081+
let text = actual.messages.first().unwrap().to_text();
1082+
1083+
// Extract the order of entries from the XML
1084+
let dir_entries: Vec<&str> = text
1085+
.split("&lt;")
1086+
.filter(|s| s.starts_with("dir&gt;") || s.starts_with("file&gt;"))
1087+
.collect();
1088+
1089+
// Verify directories come first, then files, all sorted alphabetically
1090+
let expected_order = [
1091+
"dir&gt;apple_dir",
1092+
"dir&gt;berry_dir",
1093+
"dir&gt;zoo_dir",
1094+
"file&gt;banana.txt",
1095+
"file&gt;cherry.txt",
1096+
"file&gt;zebra.txt",
1097+
];
1098+
1099+
for (i, expected) in expected_order.iter().enumerate() {
1100+
assert!(
1101+
dir_entries[i].starts_with(expected),
1102+
"Expected entry {} to start with '{}', but got '{}'",
1103+
i,
1104+
expected,
1105+
dir_entries[i]
1106+
);
1107+
}
1108+
}
10111109
}

crates/forge_infra/src/forge_infra.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,15 @@ impl HttpInfra for ForgeInfra {
245245
}
246246
#[async_trait::async_trait]
247247
impl DirectoryReaderInfra for ForgeInfra {
248+
async fn list_directory_entries(
249+
&self,
250+
directory: &Path,
251+
) -> anyhow::Result<Vec<(PathBuf, bool)>> {
252+
self.directory_reader_service
253+
.list_directory_entries(directory)
254+
.await
255+
}
256+
248257
async fn read_directory_files(
249258
&self,
250259
directory: &Path,

crates/forge_infra/src/fs_read_dir.rs

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,28 @@ use glob::Pattern;
1010
pub struct ForgeDirectoryReaderService;
1111

1212
impl ForgeDirectoryReaderService {
13+
/// Lists all entries in a directory without reading file contents
14+
/// Returns a vector of tuples containing (entry_path, is_directory)
15+
/// Much more efficient than read_directory_files for directory listings
16+
async fn list_directory_entries(&self, directory: &Path) -> Result<Vec<(PathBuf, bool)>> {
17+
// Check if directory exists
18+
if !ForgeFS::exists(directory) || ForgeFS::is_file(directory) {
19+
return Ok(vec![]);
20+
}
21+
22+
// Read directory entries
23+
let mut dir = ForgeFS::read_dir(directory).await?;
24+
let mut entries = Vec::new();
25+
26+
while let Some(entry) = dir.next_entry().await? {
27+
let path = entry.path();
28+
let is_dir = path.is_dir();
29+
entries.push((path, is_dir));
30+
}
31+
32+
Ok(entries)
33+
}
34+
1335
/// Reads all files in a directory that match the given filter pattern
1436
/// Returns a vector of tuples containing (file_path, file_content)
1537
/// Files are read asynchronously/in parallel for better performance
@@ -74,6 +96,10 @@ impl ForgeDirectoryReaderService {
7496

7597
#[async_trait::async_trait]
7698
impl DirectoryReaderInfra for ForgeDirectoryReaderService {
99+
async fn list_directory_entries(&self, directory: &Path) -> Result<Vec<(PathBuf, bool)>> {
100+
self.list_directory_entries(directory).await
101+
}
102+
77103
async fn read_directory_files(
78104
&self,
79105
directory: &Path,
@@ -162,4 +188,38 @@ mod tests {
162188
let expected = vec![(fixture.path().join("test.txt"), "File content".to_string())];
163189
assert_eq!(actual, expected);
164190
}
191+
192+
#[tokio::test]
193+
async fn test_list_directory_entries() {
194+
let fixture = tempdir().unwrap();
195+
write_file(&fixture.path().join("file1.txt"), "Content 1");
196+
write_file(&fixture.path().join("file2.md"), "Content 2");
197+
198+
let subdir = fixture.path().join("subdir");
199+
fs::create_dir(&subdir).unwrap();
200+
201+
let mut actual = ForgeDirectoryReaderService
202+
.list_directory_entries(fixture.path())
203+
.await
204+
.unwrap();
205+
actual.sort_by(|(a, _), (b, _)| a.file_name().cmp(&b.file_name()));
206+
207+
let expected = vec![
208+
(fixture.path().join("file1.txt"), false),
209+
(fixture.path().join("file2.md"), false),
210+
(fixture.path().join("subdir"), true),
211+
];
212+
assert_eq!(actual, expected);
213+
}
214+
215+
#[tokio::test]
216+
async fn test_list_directory_entries_nonexistent() {
217+
let actual = ForgeDirectoryReaderService
218+
.list_directory_entries(Path::new("/nonexistent"))
219+
.await
220+
.unwrap();
221+
222+
let expected: Vec<(PathBuf, bool)> = vec![];
223+
assert_eq!(actual, expected);
224+
}
165225
}

crates/forge_repo/src/forge_repo.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,13 @@ impl<F> DirectoryReaderInfra for ForgeRepo<F>
291291
where
292292
F: DirectoryReaderInfra + Send + Sync,
293293
{
294+
async fn list_directory_entries(
295+
&self,
296+
directory: &Path,
297+
) -> anyhow::Result<Vec<(PathBuf, bool)>> {
298+
self.infra.list_directory_entries(directory).await
299+
}
300+
294301
async fn read_directory_files(
295302
&self,
296303
directory: &Path,

0 commit comments

Comments
 (0)