| use std::sync::Arc; |
|
|
| use forge_domain::Metrics; |
| use futures::StreamExt; |
| use tracing::debug; |
|
|
| use crate::FsReadService; |
|
|
| |
| #[derive(Debug, Clone, PartialEq)] |
| pub struct FileChange { |
| pub path: std::path::PathBuf, |
| |
| pub content_hash: Option<String>, |
| } |
|
|
| |
| #[derive(Clone)] |
| pub struct FileChangeDetector<F> { |
| fs_read_service: Arc<F>, |
| } |
|
|
| impl<F: FsReadService> FileChangeDetector<F> { |
| |
| |
| |
| |
| |
| pub fn new(fs_read_service: Arc<F>) -> Self { |
| Self { fs_read_service } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| pub async fn detect(&self, metrics: &Metrics, parallel_file_reads: usize) -> Vec<FileChange> { |
| let fs = self.fs_read_service.clone(); |
| |
| 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 { |
| |
| |
| |
| |
| |
| let current_hash = fs |
| .read(file_path.to_string_lossy().to_string(), None, None) |
| .await |
| .ok() |
| .map(|o| o.info.content_hash); |
|
|
| |
| 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; |
|
|
| |
| changes.sort_by(|a, b| a.path.cmp(&b.path)); |
|
|
| changes |
| } |
| } |
|
|
| #[cfg(test)] |
| 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; |
|
|
| |
| |
| |
| |
| |
| struct MockFsReadService { |
| files: HashMap<String, MockFile>, |
| not_found_files: Vec<String>, |
| } |
|
|
| struct MockFile { |
| |
| raw_content: String, |
| |
| displayed_content: String, |
| } |
|
|
| impl MockFsReadService { |
| fn new() -> Self { |
| Self { files: HashMap::new(), not_found_files: Vec::new() } |
| } |
|
|
| |
| |
| 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 |
| } |
|
|
| |
| |
| 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 |
| } |
| } |
|
|
| #[async_trait::async_trait] |
| 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 |
| ))) |
| } |
| } |
| } |
|
|
| #[tokio::test] |
| 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); |
| } |
|
|
| #[tokio::test] |
| 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); |
| } |
|
|
| #[tokio::test] |
| 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); |
| } |
|
|
| #[tokio::test] |
| 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)); |
|
|
| |
| 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); |
|
|
| |
| metrics.file_operations.insert( |
| "/test/file.txt".to_string(), |
| FileOperation::new(ToolKind::Write).content_hash(Some(new_hash)), |
| ); |
|
|
| |
| let actual = detector.detect(&metrics, 64).await; |
| let expected = vec![]; |
|
|
| assert_eq!(actual, expected); |
| } |
|
|
| #[tokio::test] |
| 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)), |
| ); |
|
|
| |
| let actual = detector.detect(&metrics, 64).await; |
| let expected = vec![]; |
|
|
| assert_eq!(actual, expected); |
| } |
|
|
| #[tokio::test] |
| async fn test_truncated_content_does_not_cause_false_positive() { |
| |
| |
| let raw_content = "a".repeat(5000); |
| let displayed_content = "a".repeat(2000); |
| 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)), |
| ); |
|
|
| |
| |
| let actual = detector.detect(&metrics, 64).await; |
| let expected = vec![]; |
|
|
| assert_eq!(actual, expected); |
| } |
|
|
| #[tokio::test] |
| async fn test_truncated_written_file_not_false_positive() { |
| |
| |
| let raw_content = "line1\n".repeat(3000); |
| let displayed_content = "line1\n".repeat(2000); |
| 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)), |
| ); |
|
|
| |
| |
| let actual = detector.detect(&metrics, 64).await; |
| let expected = vec![]; |
|
|
| assert_eq!(actual, expected); |
| } |
|
|
| #[tokio::test] |
| async fn test_read_then_write_same_file_no_external_change() { |
| |
| |
| 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)); |
|
|
| |
| let metrics = Metrics::default().insert( |
| "/test/file.txt".to_string(), |
| FileOperation::new(ToolKind::Read).content_hash(Some(compute_hash(original))), |
| ); |
| |
| let metrics = metrics.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); |
| } |
|
|
| #[tokio::test] |
| async fn test_read_then_write_same_file_externally_modified() { |
| |
| |
| let written = "written content"; |
| let external = "user modified this"; |
| let written_hash = compute_hash(written); |
| let external_hash = compute_hash(external); |
|
|
| |
| let fs = MockFsReadService::new().with_file("/test/file.txt", external); |
| 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"))), |
| ) |
| .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![FileChange { |
| path: std::path::PathBuf::from("/test/file.txt"), |
| content_hash: Some(external_hash), |
| }]; |
|
|
| assert_eq!(actual, expected); |
| } |
|
|
| #[tokio::test] |
| async fn test_write_then_read_back_same_file_no_false_positive() { |
| |
| |
| |
| 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)); |
|
|
| |
| 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)), |
| ); |
|
|
| |
| let actual = detector.detect(&metrics, 64).await; |
| let expected = vec![]; |
|
|
| assert_eq!(actual, expected); |
| } |
|
|
| #[tokio::test] |
| async fn test_mixed_read_and_write_multiple_files() { |
| |
| |
| |
| |
| |
| |
| |
| 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) |
| .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; |
|
|
| |
| |
| let expected = vec![FileChange { |
| path: std::path::PathBuf::from("/test/b.txt"), |
| content_hash: Some(compute_hash(b_external)), |
| }]; |
|
|
| assert_eq!(actual, expected); |
| } |
|
|
| #[tokio::test] |
| async fn test_read_only_file_externally_modified_still_detected() { |
| |
| |
| |
| 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); |
| } |
|
|
| #[tokio::test] |
| async fn test_multiple_patches_then_detect_no_change() { |
| |
| |
| 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); |
| } |
|
|
| #[tokio::test] |
| async fn test_write_then_undo_then_detect() { |
| |
| |
| 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); |
| } |
|
|
| #[tokio::test] |
| async fn test_truncated_read_then_write_no_false_positive() { |
| |
| |
| let raw_content = "a".repeat(5000); |
| let written_content = "new short content"; |
| let written_hash = compute_hash(written_content); |
|
|
| |
| let fs = MockFsReadService::new().with_file("/test/file.txt", written_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(&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); |
| } |
| } |
|
|