Skip to content
16 changes: 16 additions & 0 deletions crates/forge_domain/src/repo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -232,3 +238,13 @@ pub trait FuzzySearchRepository: Send + Sync {
search_all: bool,
) -> Result<Vec<SearchMatch>>;
}

#[async_trait::async_trait]
pub trait TextPatchRepository: Send + Sync {
async fn build_text_patch(
&self,
haystack: &str,
old_string: &str,
new_string: &str,
) -> Result<TextPatchBlock>;
}
14 changes: 14 additions & 0 deletions crates/forge_repo/proto/forge.proto
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down
25 changes: 24 additions & 1 deletion crates/forge_repo/src/forge_repo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -628,6 +628,29 @@ impl<F: GrpcInfra + Send + Sync> FuzzySearchRepository for ForgeRepo<F> {
}
}

#[async_trait::async_trait]
impl<F: GrpcInfra + Send + Sync> TextPatchRepository for ForgeRepo<F> {
async fn build_text_patch(
&self,
haystack: &str,
old_string: &str,
new_string: &str,
) -> anyhow::Result<TextPatchBlock> {
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<F: GrpcInfra> GrpcInfra for ForgeRepo<F> {
fn channel(&self) -> anyhow::Result<tonic::transport::Channel> {
self.infra.channel()
Expand Down
4 changes: 3 additions & 1 deletion crates/forge_services/src/forge_services.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -199,6 +200,7 @@ impl<
+ WorkspaceIndexRepository
+ ValidationRepository
+ FuzzySearchRepository
+ TextPatchRepository
+ Clone
+ 'static,
> Services for ForgeServices<F>
Expand Down
44 changes: 23 additions & 21 deletions crates/forge_services/src/tool_services/fs_patch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -371,8 +376,13 @@ impl<F> ForgeFsPatch<F> {
}

#[async_trait::async_trait]
impl<F: FileWriterInfra + SnapshotRepository + ValidationRepository + FuzzySearchRepository>
FsPatchService for ForgeFsPatch<F>
impl<
F: FileWriterInfra
+ SnapshotRepository
+ ValidationRepository
+ FuzzySearchRepository
+ TextPatchRepository,
> FsPatchService for ForgeFsPatch<F>
{
async fn patch(
&self,
Expand Down Expand Up @@ -400,36 +410,28 @@ impl<F: FileWriterInfra + SnapshotRepository + ValidationRepository + FuzzySearc
// Save the old content before modification for diff generation
let old_content = current_content.clone();

// Compute range from search if provided
let range = match compute_range(&current_content, Some(&search), &operation) {
Ok(r) => r,
current_content = match compute_range(&current_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(&current_content, &search_text);
let normalized_content =
Range::normalize_search_line_endings(&current_content, &content);
let patch = self
.infra
.fuzzy_search(&search_text, &current_content, false)
.build_text_patch(&current_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(&current_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?;

Expand Down
Loading