diff --git a/crates/forge_domain/src/repo.rs b/crates/forge_domain/src/repo.rs index 4d6205f575..558d54244a 100644 --- a/crates/forge_domain/src/repo.rs +++ b/crates/forge_domain/src/repo.rs @@ -9,6 +9,12 @@ use crate::{ SearchMatch, Skill, Snapshot, WorkspaceAuth, WorkspaceId, }; +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TextPatchBlock { + pub patch: String, + pub patched_text: String, +} + /// Repository for managing file snapshots /// /// This repository provides operations for creating and restoring file @@ -232,3 +238,13 @@ pub trait FuzzySearchRepository: Send + Sync { search_all: bool, ) -> Result>; } + +#[async_trait::async_trait] +pub trait TextPatchRepository: Send + Sync { + async fn build_text_patch( + &self, + haystack: &str, + old_string: &str, + new_string: &str, + ) -> Result; +} diff --git a/crates/forge_repo/proto/forge.proto b/crates/forge_repo/proto/forge.proto index 5ea339a85d..e02a3e1c6a 100644 --- a/crates/forge_repo/proto/forge.proto +++ b/crates/forge_repo/proto/forge.proto @@ -47,6 +47,9 @@ service ForgeService { // Searches for needle in haystack using fuzzy search rpc FuzzySearch(FuzzySearchRequest) returns (FuzzySearchResponse); + + // Builds a serialized text patch for fuzzy replacement + rpc BuildTextPatch(BuildTextPatchRequest) returns (BuildTextPatchResponse); } // Node types @@ -356,6 +359,17 @@ message FuzzySearchResponse { repeated SearchMatch matches = 1; } +message BuildTextPatchRequest { + string haystack = 1; + string old_string = 2; + string new_string = 3; +} + +message BuildTextPatchResponse { + string patch = 1; + string patched_text = 2; +} + message SearchMatch { uint32 start_line = 1; uint32 end_line = 2; diff --git a/crates/forge_repo/src/forge_repo.rs b/crates/forge_repo/src/forge_repo.rs index 34d1bb8498..3d390cee29 100644 --- a/crates/forge_repo/src/forge_repo.rs +++ b/crates/forge_repo/src/forge_repo.rs @@ -14,7 +14,7 @@ use forge_domain::{ Conversation, ConversationId, ConversationRepository, Environment, FileInfo, FuzzySearchRepository, McpServerConfig, MigrationResult, Model, ModelId, Provider, ProviderId, ProviderRepository, ResultStream, SearchMatch, Skill, SkillRepository, Snapshot, - SnapshotRepository, + SnapshotRepository, TextPatchBlock, TextPatchRepository, }; // Re-export CacacheStorage from forge_infra pub use forge_infra::CacacheStorage; @@ -628,6 +628,29 @@ impl FuzzySearchRepository for ForgeRepo { } } +#[async_trait::async_trait] +impl TextPatchRepository for ForgeRepo { + async fn build_text_patch( + &self, + haystack: &str, + old_string: &str, + new_string: &str, + ) -> anyhow::Result { + let request = tonic::Request::new(crate::proto_generated::BuildTextPatchRequest { + haystack: haystack.to_string(), + old_string: old_string.to_string(), + new_string: new_string.to_string(), + }); + + let channel = self.infra.channel()?; + let mut client = + crate::proto_generated::forge_service_client::ForgeServiceClient::new(channel); + let response = client.build_text_patch(request).await?.into_inner(); + + Ok(TextPatchBlock { patch: response.patch, patched_text: response.patched_text }) + } +} + impl GrpcInfra for ForgeRepo { fn channel(&self) -> anyhow::Result { self.infra.channel() diff --git a/crates/forge_services/src/forge_services.rs b/crates/forge_services/src/forge_services.rs index 7ff1d1a2fb..cd2f775899 100644 --- a/crates/forge_services/src/forge_services.rs +++ b/crates/forge_services/src/forge_services.rs @@ -7,7 +7,8 @@ use forge_app::{ }; use forge_domain::{ ChatRepository, ConversationRepository, FuzzySearchRepository, ProviderRepository, - SkillRepository, SnapshotRepository, ValidationRepository, WorkspaceIndexRepository, + SkillRepository, SnapshotRepository, TextPatchRepository, ValidationRepository, + WorkspaceIndexRepository, }; use crate::ForgeProviderAuthService; @@ -199,6 +200,7 @@ impl< + WorkspaceIndexRepository + ValidationRepository + FuzzySearchRepository + + TextPatchRepository + Clone + 'static, > Services for ForgeServices diff --git a/crates/forge_services/src/tool_services/fs_patch.rs b/crates/forge_services/src/tool_services/fs_patch.rs index b5c674fd69..476b2aa961 100644 --- a/crates/forge_services/src/tool_services/fs_patch.rs +++ b/crates/forge_services/src/tool_services/fs_patch.rs @@ -4,7 +4,10 @@ use std::sync::Arc; use bytes::Bytes; use forge_app::domain::PatchOperation; use forge_app::{FileWriterInfra, FsPatchService, PatchOutput, compute_hash}; -use forge_domain::{FuzzySearchRepository, SearchMatch, SnapshotRepository, ValidationRepository}; +use forge_domain::{ + FuzzySearchRepository, SearchMatch, SnapshotRepository, TextPatchRepository, + ValidationRepository, +}; use thiserror::Error; use tokio::fs; @@ -145,6 +148,8 @@ enum Error { "Match range [{0}..{1}) is out of bounds for content of length {2}. File may have changed externally, consider reading the file again." )] RangeOutOfBounds(usize, usize, usize), + #[error("Failed to build fuzzy patch: {message}")] + PatchBuild { message: String }, } /// Compute a range from search text, with operation-aware error handling @@ -371,8 +376,13 @@ impl ForgeFsPatch { } #[async_trait::async_trait] -impl - FsPatchService for ForgeFsPatch +impl< + F: FileWriterInfra + + SnapshotRepository + + ValidationRepository + + FuzzySearchRepository + + TextPatchRepository, +> FsPatchService for ForgeFsPatch { async fn patch( &self, @@ -400,36 +410,28 @@ impl r, + current_content = match compute_range(¤t_content, Some(&search), &operation) { + Ok(range) => apply_replacement(current_content, range, &operation, &content)?, Err(Error::NoMatch(search_text)) if matches!( operation, PatchOperation::Replace | PatchOperation::ReplaceAll | PatchOperation::Swap ) => { - // Try fuzzy search as fallback - match self + let normalized_search = + Range::normalize_search_line_endings(¤t_content, &search_text); + let normalized_content = + Range::normalize_search_line_endings(¤t_content, &content); + let patch = self .infra - .fuzzy_search(&search_text, ¤t_content, false) + .build_text_patch(¤t_content, &normalized_search, &normalized_content) .await - { - Ok(matches) if !matches.is_empty() => { - // Use the first fuzzy match - matches - .first() - .map(|m| Range::from_search_match(¤t_content, m)) - } - _ => return Err(Error::NoMatch(search_text).into()), - } + .map_err(|error| Error::PatchBuild { message: error.to_string() })?; + patch.patched_text } Err(e) => return Err(e.into()), }; - // Apply the replacement - current_content = apply_replacement(current_content, range, &operation, &content)?; - // SNAPSHOT COORDINATION: Always capture snapshot before modifying self.infra.insert_snapshot(path).await?;