Download crates/forge_app/src/file_tracking.rs from SaylorTwift/forgecode: direct link, hf CLI and curl.
- Browser
- Download file 23.3 kB
-
https://huggingface.co/SaylorTwift/forgecode/resolve/main/crates/forge_app/src/file_tracking.rs
- Command line
-
hf download hf://SaylorTwift/forgecode/crates/forge_app/src/file_tracking.rs
-
curl -L -o file_tracking.rs https://huggingface.co/SaylorTwift/forgecode/resolve/main/crates/forge_app/src/file_tracking.rs
23.3 kB
| use std::sync::Arc; | |
| use forge_domain::Metrics; | |
| use futures::StreamExt; | |
| use tracing::debug; | |
| use crate::FsReadService; | |
| /// Information about a detected file change | |
| pub struct FileChange { | |
| pub path: std::path::PathBuf, | |
| /// File hash if readable, None if unreadable | |
| pub content_hash: Option<String>, | |
| } | |
| /// Detects file changes by comparing current file hashes with stored hashes | |
| pub struct FileChangeDetector<F> { | |
| fs_read_service: Arc<F>, | |
| } | |
| impl<F: FsReadService> FileChangeDetector<F> { | |
| /// Creates a new FileChangeDetector with the provided file read service | |
| /// | |
| /// # Arguments | |
| /// | |
| /// * `fs_read_service` - The file system read service implementation | |
| pub fn new(fs_read_service: Arc<F>) -> Self { | |
| Self { fs_read_service } | |
| } | |
| /// Detects files that have changed since the last notification | |
| /// | |
| /// Compares current file hash with stored hash. Returns a list of file | |
| /// changes sorted by path for deterministic ordering. | |
| /// | |
| /// # Arguments | |
| /// | |
| /// * `tracked_files` - Map of file paths to their last known hashes (None | |
| /// if unreadable) | |
| pub async fn detect(&self, metrics: &Metrics, parallel_file_reads: usize) -> Vec<FileChange> { | |
| let fs = self.fs_read_service.clone(); | |
| // Collect into owned data upfront so the stream futures are 'static-safe | |
| let entries: Vec<(std::path::PathBuf, Option<String>)> = metrics | |
| .file_operations | |
| .iter() | |
| .map(|(path, file_metrics)| { | |
| ( | |
| std::path::PathBuf::from(path), | |
| file_metrics.content_hash.clone(), | |
| ) | |
| }) | |
| .collect(); | |
| let mut changes: Vec<FileChange> = futures::stream::iter(entries) | |
| .map(|(file_path, last_hash)| { | |
| let fs = fs.clone(); | |
| async move { | |
| // Get current hash from the full raw file content (not the | |
| // truncated/formatted content returned to the LLM). | |
| // ReadOutput.info.content_hash is always computed from the | |
| // unprocessed file, so it is directly comparable with the | |
| // stored hash. | |
| let current_hash = fs | |
| .read(file_path.to_string_lossy().to_string(), None, None) | |
| .await | |
| .ok() | |
| .map(|o| o.info.content_hash); | |
| // Check if hash has changed | |
| if current_hash != last_hash { | |
| debug!( | |
| path = %file_path.display(), | |
| last_hash = ?last_hash, | |
| current_hash = ?current_hash, | |
| "Detected file change" | |
| ); | |
| Some(FileChange { path: file_path, content_hash: current_hash }) | |
| } else { | |
| None | |
| } | |
| } | |
| }) | |
| .buffer_unordered(parallel_file_reads) | |
| .filter_map(std::future::ready) | |
| .collect() | |
| .await; | |
| // Sort by path for deterministic ordering | |
| changes.sort_by(|a, b| a.path.cmp(&b.path)); | |
| changes | |
| } | |
| } | |
| mod tests { | |
| use std::collections::HashMap; | |
| use forge_domain::{FileOperation, Metrics, ToolKind}; | |
| use pretty_assertions::assert_eq; | |
| use super::*; | |
| use crate::Content; | |
| use crate::utils::compute_hash; | |
| /// Mock FsReadService for testing. | |
| /// | |
| /// Returns `content_hash` computed from the raw content (mirroring | |
| /// the real implementation at `fs_read.rs:164`), while `content` | |
| /// may differ (simulating truncation / formatting). | |
| struct MockFsReadService { | |
| files: HashMap<String, MockFile>, | |
| not_found_files: Vec<String>, | |
| } | |
| struct MockFile { | |
| /// The raw, unprocessed file content (used to compute content_hash) | |
| raw_content: String, | |
| /// The content returned in ReadOutput.content (may be truncated) | |
| displayed_content: String, | |
| } | |
| impl MockFsReadService { | |
| fn new() -> Self { | |
| Self { files: HashMap::new(), not_found_files: Vec::new() } | |
| } | |
| /// Adds a file where displayed content equals raw content (no | |
| /// truncation). | |
| fn with_file(mut self, path: impl Into<String>, content: impl Into<String>) -> Self { | |
| let content = content.into(); | |
| self.files.insert( | |
| path.into(), | |
| MockFile { raw_content: content.clone(), displayed_content: content }, | |
| ); | |
| self | |
| } | |
| /// Adds a file where the displayed content differs from the raw | |
| /// content, simulating line truncation or range limiting. | |
| fn with_truncated_file( | |
| mut self, | |
| path: impl Into<String>, | |
| raw_content: impl Into<String>, | |
| displayed_content: impl Into<String>, | |
| ) -> Self { | |
| self.files.insert( | |
| path.into(), | |
| MockFile { | |
| raw_content: raw_content.into(), | |
| displayed_content: displayed_content.into(), | |
| }, | |
| ); | |
| self | |
| } | |
| fn with_not_found(mut self, path: impl Into<String>) -> Self { | |
| self.not_found_files.push(path.into()); | |
| self | |
| } | |
| } | |
| impl FsReadService for MockFsReadService { | |
| async fn read( | |
| &self, | |
| path: String, | |
| _: Option<u64>, | |
| _: Option<u64>, | |
| ) -> anyhow::Result<crate::ReadOutput> { | |
| if self.not_found_files.contains(&path) { | |
| return Err(anyhow::anyhow!(std::io::Error::from( | |
| std::io::ErrorKind::NotFound | |
| ))); | |
| } | |
| if let Some(file) = self.files.get(&path) { | |
| Ok(crate::ReadOutput { | |
| content: Content::File(file.displayed_content.clone()), | |
| info: forge_domain::FileInfo::new(1, 1, 1, compute_hash(&file.raw_content)), | |
| }) | |
| } else { | |
| Err(anyhow::anyhow!(std::io::Error::from( | |
| std::io::ErrorKind::NotFound | |
| ))) | |
| } | |
| } | |
| } | |
| async fn test_no_change() { | |
| let content = "hello world"; | |
| let content_hash = compute_hash(content); | |
| let fs = MockFsReadService::new().with_file("/test/file.txt", content); | |
| let detector = FileChangeDetector::new(Arc::new(fs)); | |
| let mut metrics = Metrics::default(); | |
| metrics.file_operations.insert( | |
| "/test/file.txt".to_string(), | |
| FileOperation::new(ToolKind::Write).content_hash(Some(content_hash)), | |
| ); | |
| let actual = detector.detect(&metrics, 64).await; | |
| let expected = vec![]; | |
| assert_eq!(actual, expected); | |
| } | |
| async fn test_file_modified() { | |
| let old_hash = compute_hash("old content"); | |
| let new_content = "new content"; | |
| let new_hash = compute_hash(new_content); | |
| let fs = MockFsReadService::new().with_file("/test/file.txt", new_content); | |
| let detector = FileChangeDetector::new(Arc::new(fs)); | |
| let mut metrics = Metrics::default(); | |
| metrics.file_operations.insert( | |
| "/test/file.txt".to_string(), | |
| FileOperation::new(ToolKind::Write).content_hash(Some(old_hash)), | |
| ); | |
| let actual = detector.detect(&metrics, 64).await; | |
| let expected = vec![FileChange { | |
| path: std::path::PathBuf::from("/test/file.txt"), | |
| content_hash: Some(new_hash), | |
| }]; | |
| assert_eq!(actual, expected); | |
| } | |
| async fn test_file_becomes_unreadable() { | |
| let old_hash = compute_hash("old content"); | |
| let fs = MockFsReadService::new().with_not_found("/test/file.txt"); | |
| let detector = FileChangeDetector::new(Arc::new(fs)); | |
| let mut metrics = Metrics::default(); | |
| metrics.file_operations.insert( | |
| "/test/file.txt".to_string(), | |
| FileOperation::new(ToolKind::Write).content_hash(Some(old_hash)), | |
| ); | |
| let actual = detector.detect(&metrics, 64).await; | |
| let expected = vec![FileChange { | |
| path: std::path::PathBuf::from("/test/file.txt"), | |
| content_hash: None, | |
| }]; | |
| assert_eq!(actual, expected); | |
| } | |
| async fn test_no_duplicate_notification() { | |
| let new_content = "new content"; | |
| let new_hash = compute_hash(new_content); | |
| let old_hash = "old_hash".to_string(); | |
| let fs = MockFsReadService::new().with_file("/test/file.txt", new_content); | |
| let detector = FileChangeDetector::new(Arc::new(fs)); | |
| // First call: detect change | |
| let mut metrics = Metrics::default(); | |
| metrics.file_operations.insert( | |
| "/test/file.txt".to_string(), | |
| FileOperation::new(ToolKind::Write).content_hash(Some(old_hash)), | |
| ); | |
| let first = detector.detect(&metrics, 64).await; | |
| assert_eq!(first.len(), 1); | |
| // Simulate updating content_hash after notification (like app.rs does) | |
| metrics.file_operations.insert( | |
| "/test/file.txt".to_string(), | |
| FileOperation::new(ToolKind::Write).content_hash(Some(new_hash)), | |
| ); | |
| // Second call: should not detect change | |
| let actual = detector.detect(&metrics, 64).await; | |
| let expected = vec![]; | |
| assert_eq!(actual, expected); | |
| } | |
| async fn test_read_file_with_matching_hash_not_detected() { | |
| let content = "hello world"; | |
| let content_hash = compute_hash(content); | |
| let fs = MockFsReadService::new().with_file("/test/file.txt", content); | |
| let detector = FileChangeDetector::new(Arc::new(fs)); | |
| let mut metrics = Metrics::default(); | |
| metrics.file_operations.insert( | |
| "/test/file.txt".to_string(), | |
| FileOperation::new(ToolKind::Read).content_hash(Some(content_hash)), | |
| ); | |
| // Hash computed from raw content matches stored hash -- no change | |
| let actual = detector.detect(&metrics, 64).await; | |
| let expected = vec![]; | |
| assert_eq!(actual, expected); | |
| } | |
| async fn test_truncated_content_does_not_cause_false_positive() { | |
| // Simulates a file with a very long line that gets truncated when | |
| // displayed, but the content_hash is still computed from raw content. | |
| let raw_content = "a".repeat(5000); // long line that would be truncated | |
| let displayed_content = "a".repeat(2000); // truncated version | |
| let raw_hash = compute_hash(&raw_content); | |
| let fs = MockFsReadService::new().with_truncated_file( | |
| "/test/file.txt", | |
| &raw_content, | |
| &displayed_content, | |
| ); | |
| let detector = FileChangeDetector::new(Arc::new(fs)); | |
| let mut metrics = Metrics::default(); | |
| metrics.file_operations.insert( | |
| "/test/file.txt".to_string(), | |
| FileOperation::new(ToolKind::Read).content_hash(Some(raw_hash)), | |
| ); | |
| // Even though displayed content differs from raw, the hash comparison | |
| // uses the raw-based content_hash from ReadOutput, so no false positive. | |
| let actual = detector.detect(&metrics, 64).await; | |
| let expected = vec![]; | |
| assert_eq!(actual, expected); | |
| } | |
| async fn test_truncated_written_file_not_false_positive() { | |
| // Same scenario but for a written file -- ensures the fix applies to | |
| // all ToolKinds, not just Read. | |
| let raw_content = "line1\n".repeat(3000); // > 2000 lines, would be range-limited | |
| let displayed_content = "line1\n".repeat(2000); // truncated version | |
| let raw_hash = compute_hash(&raw_content); | |
| let fs = MockFsReadService::new().with_truncated_file( | |
| "/test/file.txt", | |
| &raw_content, | |
| &displayed_content, | |
| ); | |
| let detector = FileChangeDetector::new(Arc::new(fs)); | |
| let mut metrics = Metrics::default(); | |
| metrics.file_operations.insert( | |
| "/test/file.txt".to_string(), | |
| FileOperation::new(ToolKind::Write).content_hash(Some(raw_hash)), | |
| ); | |
| // Hash from ReadOutput.content_hash (raw) matches stored hash -- no | |
| // false positive despite displayed content being truncated. | |
| let actual = detector.detect(&metrics, 64).await; | |
| let expected = vec![]; | |
| assert_eq!(actual, expected); | |
| } | |
| async fn test_read_then_write_same_file_no_external_change() { | |
| // Simulates: agent reads file, then writes to it, file is unchanged | |
| // on disk since the write. | |
| let original = "original content"; | |
| let written = "written content"; | |
| let written_hash = compute_hash(written); | |
| let fs = MockFsReadService::new().with_file("/test/file.txt", written); | |
| let detector = FileChangeDetector::new(Arc::new(fs)); | |
| // Step 1: Read the file (insert via Metrics::insert like production) | |
| let metrics = Metrics::default().insert( | |
| "/test/file.txt".to_string(), | |
| FileOperation::new(ToolKind::Read).content_hash(Some(compute_hash(original))), | |
| ); | |
| // Step 2: Write the file (overwrites the Read entry) | |
| let metrics = metrics.insert( | |
| "/test/file.txt".to_string(), | |
| FileOperation::new(ToolKind::Write).content_hash(Some(written_hash)), | |
| ); | |
| // File on disk matches what was written -- no change | |
| let actual = detector.detect(&metrics, 64).await; | |
| let expected = vec![]; | |
| assert_eq!(actual, expected); | |
| } | |
| async fn test_read_then_write_same_file_externally_modified() { | |
| // Simulates: agent reads file, writes to it, then user modifies | |
| // the file externally. | |
| let written = "written content"; | |
| let external = "user modified this"; | |
| let written_hash = compute_hash(written); | |
| let external_hash = compute_hash(external); | |
| // Disk now has the externally modified content | |
| let fs = MockFsReadService::new().with_file("/test/file.txt", external); | |
| let detector = FileChangeDetector::new(Arc::new(fs)); | |
| // Step 1: Read, Step 2: Write | |
| let metrics = Metrics::default() | |
| .insert( | |
| "/test/file.txt".to_string(), | |
| FileOperation::new(ToolKind::Read).content_hash(Some(compute_hash("original"))), | |
| ) | |
| .insert( | |
| "/test/file.txt".to_string(), | |
| FileOperation::new(ToolKind::Write).content_hash(Some(written_hash)), | |
| ); | |
| // External modification detected | |
| let actual = detector.detect(&metrics, 64).await; | |
| let expected = vec![FileChange { | |
| path: std::path::PathBuf::from("/test/file.txt"), | |
| content_hash: Some(external_hash), | |
| }]; | |
| assert_eq!(actual, expected); | |
| } | |
| async fn test_write_then_read_back_same_file_no_false_positive() { | |
| // Simulates: agent writes file, then reads it back. The last | |
| // operation in file_operations is Read, but it should still | |
| // not report a false positive since the hash matches. | |
| let content = "final content"; | |
| let content_hash = compute_hash(content); | |
| let fs = MockFsReadService::new().with_file("/test/file.txt", content); | |
| let detector = FileChangeDetector::new(Arc::new(fs)); | |
| // Step 1: Write, Step 2: Read back (overwrites Write entry) | |
| let metrics = Metrics::default() | |
| .insert( | |
| "/test/file.txt".to_string(), | |
| FileOperation::new(ToolKind::Write).content_hash(Some(content_hash.clone())), | |
| ) | |
| .insert( | |
| "/test/file.txt".to_string(), | |
| FileOperation::new(ToolKind::Read).content_hash(Some(content_hash)), | |
| ); | |
| // Last entry is Read with matching hash -- no false positive | |
| let actual = detector.detect(&metrics, 64).await; | |
| let expected = vec![]; | |
| assert_eq!(actual, expected); | |
| } | |
| async fn test_mixed_read_and_write_multiple_files() { | |
| // Simulates a real workflow: | |
| // - Read file A (inspect) | |
| // - Read file B (inspect) | |
| // - Write file B (modify) | |
| // - Patch file C | |
| // - Read file D (inspect only) | |
| // Then user externally modifies file B. | |
| let a_content = "file a content"; | |
| let b_written = "file b written"; | |
| let b_external = "file b external edit"; | |
| let c_content = "file c patched"; | |
| let d_content = "file d content"; | |
| let fs = MockFsReadService::new() | |
| .with_file("/test/a.txt", a_content) | |
| .with_file("/test/b.txt", b_external) // user modified B | |
| .with_file("/test/c.txt", c_content) | |
| .with_file("/test/d.txt", d_content); | |
| let detector = FileChangeDetector::new(Arc::new(fs)); | |
| let metrics = Metrics::default() | |
| .insert( | |
| "/test/a.txt".to_string(), | |
| FileOperation::new(ToolKind::Read).content_hash(Some(compute_hash(a_content))), | |
| ) | |
| .insert( | |
| "/test/b.txt".to_string(), | |
| FileOperation::new(ToolKind::Read) | |
| .content_hash(Some(compute_hash("file b original"))), | |
| ) | |
| .insert( | |
| "/test/b.txt".to_string(), | |
| FileOperation::new(ToolKind::Write).content_hash(Some(compute_hash(b_written))), | |
| ) | |
| .insert( | |
| "/test/c.txt".to_string(), | |
| FileOperation::new(ToolKind::Patch).content_hash(Some(compute_hash(c_content))), | |
| ) | |
| .insert( | |
| "/test/d.txt".to_string(), | |
| FileOperation::new(ToolKind::Read).content_hash(Some(compute_hash(d_content))), | |
| ); | |
| let actual = detector.detect(&metrics, 64).await; | |
| // Only file B should be detected: externally modified after write. | |
| // A and D are unchanged. C is unchanged. | |
| let expected = vec![FileChange { | |
| path: std::path::PathBuf::from("/test/b.txt"), | |
| content_hash: Some(compute_hash(b_external)), | |
| }]; | |
| assert_eq!(actual, expected); | |
| } | |
| async fn test_read_only_file_externally_modified_still_detected() { | |
| // If a file was only read, and then externally modified, detect() | |
| // SHOULD still report it -- the purpose is to notify the LLM that | |
| // context it saw is now stale. | |
| let original = "original"; | |
| let modified = "someone changed this"; | |
| let fs = MockFsReadService::new().with_file("/test/file.txt", modified); | |
| let detector = FileChangeDetector::new(Arc::new(fs)); | |
| let metrics = Metrics::default().insert( | |
| "/test/file.txt".to_string(), | |
| FileOperation::new(ToolKind::Read).content_hash(Some(compute_hash(original))), | |
| ); | |
| let actual = detector.detect(&metrics, 64).await; | |
| let expected = vec![FileChange { | |
| path: std::path::PathBuf::from("/test/file.txt"), | |
| content_hash: Some(compute_hash(modified)), | |
| }]; | |
| assert_eq!(actual, expected); | |
| } | |
| async fn test_multiple_patches_then_detect_no_change() { | |
| // Agent patches a file multiple times, disk still matches | |
| // the final patch. | |
| let final_content = "v3"; | |
| let final_hash = compute_hash(final_content); | |
| let fs = MockFsReadService::new().with_file("/test/file.txt", final_content); | |
| let detector = FileChangeDetector::new(Arc::new(fs)); | |
| let metrics = Metrics::default() | |
| .insert( | |
| "/test/file.txt".to_string(), | |
| FileOperation::new(ToolKind::Read).content_hash(Some(compute_hash("v0"))), | |
| ) | |
| .insert( | |
| "/test/file.txt".to_string(), | |
| FileOperation::new(ToolKind::Patch).content_hash(Some(compute_hash("v1"))), | |
| ) | |
| .insert( | |
| "/test/file.txt".to_string(), | |
| FileOperation::new(ToolKind::Patch).content_hash(Some(compute_hash("v2"))), | |
| ) | |
| .insert( | |
| "/test/file.txt".to_string(), | |
| FileOperation::new(ToolKind::Patch).content_hash(Some(final_hash)), | |
| ); | |
| let actual = detector.detect(&metrics, 64).await; | |
| let expected = vec![]; | |
| assert_eq!(actual, expected); | |
| } | |
| async fn test_write_then_undo_then_detect() { | |
| // Agent writes a file, then undoes it. The undo operation records | |
| // the restored content hash. Disk should match. | |
| let original = "original"; | |
| let original_hash = compute_hash(original); | |
| let fs = MockFsReadService::new().with_file("/test/file.txt", original); | |
| let detector = FileChangeDetector::new(Arc::new(fs)); | |
| let metrics = Metrics::default() | |
| .insert( | |
| "/test/file.txt".to_string(), | |
| FileOperation::new(ToolKind::Read).content_hash(Some(original_hash.clone())), | |
| ) | |
| .insert( | |
| "/test/file.txt".to_string(), | |
| FileOperation::new(ToolKind::Write).content_hash(Some(compute_hash("modified"))), | |
| ) | |
| .insert( | |
| "/test/file.txt".to_string(), | |
| FileOperation::new(ToolKind::Undo).content_hash(Some(original_hash)), | |
| ); | |
| let actual = detector.detect(&metrics, 64).await; | |
| let expected = vec![]; | |
| assert_eq!(actual, expected); | |
| } | |
| async fn test_truncated_read_then_write_no_false_positive() { | |
| // Read a file with long lines (content gets truncated in display), | |
| // then write to it. The write hash should match disk. | |
| let raw_content = "a".repeat(5000); | |
| let written_content = "new short content"; | |
| let written_hash = compute_hash(written_content); | |
| // After write, disk has the written content | |
| let fs = MockFsReadService::new().with_file("/test/file.txt", written_content); | |
| let detector = FileChangeDetector::new(Arc::new(fs)); | |
| // Read (truncated display), then Write | |
| let metrics = Metrics::default() | |
| .insert( | |
| "/test/file.txt".to_string(), | |
| FileOperation::new(ToolKind::Read).content_hash(Some(compute_hash(&raw_content))), | |
| ) | |
| .insert( | |
| "/test/file.txt".to_string(), | |
| FileOperation::new(ToolKind::Write).content_hash(Some(written_hash)), | |
| ); | |
| let actual = detector.detect(&metrics, 64).await; | |
| let expected = vec![]; | |
| assert_eq!(actual, expected); | |
| } | |
| } | |