11use std:: sync:: Arc ;
22
33use forge_domain:: Metrics ;
4+ use futures:: StreamExt ;
45use tracing:: debug;
56
67use crate :: FsReadService ;
@@ -17,6 +18,7 @@ pub struct FileChange {
1718#[ derive( Clone ) ]
1819pub struct FileChangeDetector < F > {
1920 fs_read_service : Arc < F > ,
21+ parallel_file_reads : usize ,
2022}
2123
2224impl < F : FsReadService > FileChangeDetector < F > {
@@ -25,8 +27,9 @@ impl<F: FsReadService> FileChangeDetector<F> {
2527 /// # Arguments
2628 ///
2729 /// * `fs_read_service` - The file system read service implementation
28- pub fn new ( fs_read_service : Arc < F > ) -> Self {
29- Self { fs_read_service }
30+ /// * `parallel_file_reads` - Maximum number of files to hash concurrently
31+ pub fn new ( fs_read_service : Arc < F > , parallel_file_reads : usize ) -> Self {
32+ Self { fs_read_service, parallel_file_reads }
3033 }
3134
3235 /// Detects files that have changed since the last notification
@@ -39,20 +42,34 @@ impl<F: FsReadService> FileChangeDetector<F> {
3942 /// * `tracked_files` - Map of file paths to their last known hashes (None
4043 /// if unreadable)
4144 pub async fn detect ( & self , metrics : & Metrics ) -> Vec < FileChange > {
42- let futures: Vec < _ > = metrics
45+ let fs = self . fs_read_service . clone ( ) ;
46+ // Collect into owned data upfront so the stream futures are 'static-safe
47+ let entries: Vec < ( std:: path:: PathBuf , Option < String > ) > = metrics
4348 . file_operations
4449 . iter ( )
4550 . map ( |( path, file_metrics) | {
46- let file_path = std:: path:: PathBuf :: from ( path) ;
47- let last_hash = file_metrics. content_hash . clone ( ) ;
51+ (
52+ std:: path:: PathBuf :: from ( path) ,
53+ file_metrics. content_hash . clone ( ) ,
54+ )
55+ } )
56+ . collect ( ) ;
57+
58+ let mut changes: Vec < FileChange > = futures:: stream:: iter ( entries)
59+ . map ( |( file_path, last_hash) | {
60+ let fs = fs. clone ( ) ;
4861
4962 async move {
5063 // Get current hash from the full raw file content (not the
5164 // truncated/formatted content returned to the LLM).
5265 // ReadOutput.content_hash is always computed from the
5366 // unprocessed file, so it is directly comparable with the
5467 // stored hash.
55- let current_hash = self . read_file_hash ( & file_path) . await . ok ( ) ;
68+ let current_hash = fs
69+ . read ( file_path. to_string_lossy ( ) . to_string ( ) , None , None )
70+ . await
71+ . ok ( )
72+ . map ( |o| o. content_hash ) ;
5673
5774 // Check if hash has changed
5875 if current_hash != last_hash {
@@ -68,30 +85,16 @@ impl<F: FsReadService> FileChangeDetector<F> {
6885 }
6986 }
7087 } )
71- . collect ( ) ;
72-
73- let mut changes: Vec < FileChange > = futures:: future:: join_all ( futures)
74- . await
75- . into_iter ( )
76- . flatten ( )
77- . collect ( ) ;
88+ . buffer_unordered ( self . parallel_file_reads )
89+ . filter_map ( std:: future:: ready)
90+ . collect ( )
91+ . await ;
7892
7993 // Sort by path for deterministic ordering
8094 changes. sort_by ( |a, b| a. path . cmp ( & b. path ) ) ;
8195
8296 changes
8397 }
84-
85- /// Reads a file and returns its content hash computed from the full raw
86- /// content, bypassing any line truncation or range limiting.
87- async fn read_file_hash ( & self , path : & std:: path:: Path ) -> anyhow:: Result < String > {
88- let output = self
89- . fs_read_service
90- . read ( path. to_string_lossy ( ) . to_string ( ) , None , None )
91- . await ?;
92-
93- Ok ( output. content_hash )
94- }
9598}
9699
97100#[ cfg( test) ]
@@ -198,7 +201,7 @@ mod tests {
198201 let content_hash = compute_hash ( content) ;
199202
200203 let fs = MockFsReadService :: new ( ) . with_file ( "/test/file.txt" , content) ;
201- let detector = FileChangeDetector :: new ( Arc :: new ( fs) ) ;
204+ let detector = FileChangeDetector :: new ( Arc :: new ( fs) , 64 ) ;
202205
203206 let mut metrics = Metrics :: default ( ) ;
204207 metrics. file_operations . insert (
@@ -219,7 +222,7 @@ mod tests {
219222 let new_hash = compute_hash ( new_content) ;
220223
221224 let fs = MockFsReadService :: new ( ) . with_file ( "/test/file.txt" , new_content) ;
222- let detector = FileChangeDetector :: new ( Arc :: new ( fs) ) ;
225+ let detector = FileChangeDetector :: new ( Arc :: new ( fs) , 64 ) ;
223226
224227 let mut metrics = Metrics :: default ( ) ;
225228 metrics. file_operations . insert (
@@ -241,7 +244,7 @@ mod tests {
241244 let old_hash = compute_hash ( "old content" ) ;
242245
243246 let fs = MockFsReadService :: new ( ) . with_not_found ( "/test/file.txt" ) ;
244- let detector = FileChangeDetector :: new ( Arc :: new ( fs) ) ;
247+ let detector = FileChangeDetector :: new ( Arc :: new ( fs) , 64 ) ;
245248
246249 let mut metrics = Metrics :: default ( ) ;
247250 metrics. file_operations . insert (
@@ -265,7 +268,7 @@ mod tests {
265268 let old_hash = "old_hash" . to_string ( ) ;
266269
267270 let fs = MockFsReadService :: new ( ) . with_file ( "/test/file.txt" , new_content) ;
268- let detector = FileChangeDetector :: new ( Arc :: new ( fs) ) ;
271+ let detector = FileChangeDetector :: new ( Arc :: new ( fs) , 64 ) ;
269272
270273 // First call: detect change
271274 let mut metrics = Metrics :: default ( ) ;
@@ -296,7 +299,7 @@ mod tests {
296299 let content_hash = compute_hash ( content) ;
297300
298301 let fs = MockFsReadService :: new ( ) . with_file ( "/test/file.txt" , content) ;
299- let detector = FileChangeDetector :: new ( Arc :: new ( fs) ) ;
302+ let detector = FileChangeDetector :: new ( Arc :: new ( fs) , 64 ) ;
300303
301304 let mut metrics = Metrics :: default ( ) ;
302305 metrics. file_operations . insert (
@@ -324,7 +327,7 @@ mod tests {
324327 & raw_content,
325328 & displayed_content,
326329 ) ;
327- let detector = FileChangeDetector :: new ( Arc :: new ( fs) ) ;
330+ let detector = FileChangeDetector :: new ( Arc :: new ( fs) , 64 ) ;
328331
329332 let mut metrics = Metrics :: default ( ) ;
330333 metrics. file_operations . insert (
@@ -353,7 +356,7 @@ mod tests {
353356 & raw_content,
354357 & displayed_content,
355358 ) ;
356- let detector = FileChangeDetector :: new ( Arc :: new ( fs) ) ;
359+ let detector = FileChangeDetector :: new ( Arc :: new ( fs) , 64 ) ;
357360
358361 let mut metrics = Metrics :: default ( ) ;
359362 metrics. file_operations . insert (
@@ -378,7 +381,7 @@ mod tests {
378381 let written_hash = compute_hash ( written) ;
379382
380383 let fs = MockFsReadService :: new ( ) . with_file ( "/test/file.txt" , written) ;
381- let detector = FileChangeDetector :: new ( Arc :: new ( fs) ) ;
384+ let detector = FileChangeDetector :: new ( Arc :: new ( fs) , 64 ) ;
382385
383386 // Step 1: Read the file (insert via Metrics::insert like production)
384387 let metrics = Metrics :: default ( ) . insert (
@@ -409,7 +412,7 @@ mod tests {
409412
410413 // Disk now has the externally modified content
411414 let fs = MockFsReadService :: new ( ) . with_file ( "/test/file.txt" , external) ;
412- let detector = FileChangeDetector :: new ( Arc :: new ( fs) ) ;
415+ let detector = FileChangeDetector :: new ( Arc :: new ( fs) , 64 ) ;
413416
414417 // Step 1: Read, Step 2: Write
415418 let metrics = Metrics :: default ( )
@@ -441,7 +444,7 @@ mod tests {
441444 let content_hash = compute_hash ( content) ;
442445
443446 let fs = MockFsReadService :: new ( ) . with_file ( "/test/file.txt" , content) ;
444- let detector = FileChangeDetector :: new ( Arc :: new ( fs) ) ;
447+ let detector = FileChangeDetector :: new ( Arc :: new ( fs) , 64 ) ;
445448
446449 // Step 1: Write, Step 2: Read back (overwrites Write entry)
447450 let metrics = Metrics :: default ( )
@@ -481,7 +484,7 @@ mod tests {
481484 . with_file ( "/test/b.txt" , b_external) // user modified B
482485 . with_file ( "/test/c.txt" , c_content)
483486 . with_file ( "/test/d.txt" , d_content) ;
484- let detector = FileChangeDetector :: new ( Arc :: new ( fs) ) ;
487+ let detector = FileChangeDetector :: new ( Arc :: new ( fs) , 64 ) ;
485488
486489 let metrics = Metrics :: default ( )
487490 . insert (
@@ -527,7 +530,7 @@ mod tests {
527530 let modified = "someone changed this" ;
528531
529532 let fs = MockFsReadService :: new ( ) . with_file ( "/test/file.txt" , modified) ;
530- let detector = FileChangeDetector :: new ( Arc :: new ( fs) ) ;
533+ let detector = FileChangeDetector :: new ( Arc :: new ( fs) , 64 ) ;
531534
532535 let metrics = Metrics :: default ( ) . insert (
533536 "/test/file.txt" . to_string ( ) ,
@@ -551,7 +554,7 @@ mod tests {
551554 let final_hash = compute_hash ( final_content) ;
552555
553556 let fs = MockFsReadService :: new ( ) . with_file ( "/test/file.txt" , final_content) ;
554- let detector = FileChangeDetector :: new ( Arc :: new ( fs) ) ;
557+ let detector = FileChangeDetector :: new ( Arc :: new ( fs) , 64 ) ;
555558
556559 let metrics = Metrics :: default ( )
557560 . insert (
@@ -585,7 +588,7 @@ mod tests {
585588 let original_hash = compute_hash ( original) ;
586589
587590 let fs = MockFsReadService :: new ( ) . with_file ( "/test/file.txt" , original) ;
588- let detector = FileChangeDetector :: new ( Arc :: new ( fs) ) ;
591+ let detector = FileChangeDetector :: new ( Arc :: new ( fs) , 64 ) ;
589592
590593 let metrics = Metrics :: default ( )
591594 . insert (
@@ -617,7 +620,7 @@ mod tests {
617620
618621 // After write, disk has the written content
619622 let fs = MockFsReadService :: new ( ) . with_file ( "/test/file.txt" , written_content) ;
620- let detector = FileChangeDetector :: new ( Arc :: new ( fs) ) ;
623+ let detector = FileChangeDetector :: new ( Arc :: new ( fs) , 64 ) ;
621624
622625 // Read (truncated display), then Write
623626 let metrics = Metrics :: default ( )
0 commit comments