catching up

This commit is contained in:
Michael Neale
2026-02-04 15:58:50 +11:00
parent c240f87d9e
commit 6eaca40717
9 changed files with 1018 additions and 38 deletions
Generated
+1
View File
@@ -4180,6 +4180,7 @@ dependencies = [
"lru",
"minijinja",
"mockall",
"mpatch",
"nanoid",
"once_cell",
"opentelemetry 0.27.1",
+1
View File
@@ -133,6 +133,7 @@ ignore = { workspace = true }
which = { workspace = true }
pctx_code_mode = "^0.2.2"
unbinder = "0.1.7"
mpatch = "=0.2.0"
[target.'cfg(target_os = "windows")'.dependencies]
winapi = { version = "0.3", features = ["wincred"] }
@@ -0,0 +1,89 @@
mod morphllm_editor;
mod openai_compatible_editor;
mod relace_editor;
use anyhow::Result;
pub use morphllm_editor::MorphLLMEditor;
pub use openai_compatible_editor::OpenAICompatibleEditor;
pub use relace_editor::RelaceEditor;
#[derive(Debug, Clone)]
pub enum EditorModel {
MorphLLM(MorphLLMEditor),
OpenAICompatible(OpenAICompatibleEditor),
Relace(RelaceEditor),
}
impl EditorModel {
pub async fn edit_code(
&self,
original_code: &str,
old_str: &str,
update_snippet: &str,
) -> Result<String, String> {
match self {
EditorModel::MorphLLM(editor) => {
editor
.edit_code(original_code, old_str, update_snippet)
.await
}
EditorModel::OpenAICompatible(editor) => {
editor
.edit_code(original_code, old_str, update_snippet)
.await
}
EditorModel::Relace(editor) => {
editor
.edit_code(original_code, old_str, update_snippet)
.await
}
}
}
pub fn get_str_replace_description(&self) -> &'static str {
match self {
EditorModel::MorphLLM(editor) => editor.get_str_replace_description(),
EditorModel::OpenAICompatible(editor) => editor.get_str_replace_description(),
EditorModel::Relace(editor) => editor.get_str_replace_description(),
}
}
}
#[allow(async_fn_in_trait)]
pub trait EditorModelImpl {
async fn edit_code(
&self,
original_code: &str,
old_str: &str,
update_snippet: &str,
) -> Result<String, String>;
fn get_str_replace_description(&self) -> &'static str;
}
pub fn create_editor_model() -> Option<EditorModel> {
if cfg!(test) {
return None;
}
let api_key = std::env::var("GOOSE_EDITOR_API_KEY").ok()?;
let host = std::env::var("GOOSE_EDITOR_HOST").ok()?;
let model = std::env::var("GOOSE_EDITOR_MODEL").ok()?;
if api_key.is_empty() || host.is_empty() || model.is_empty() {
return None;
}
if host.contains("relace.run") {
Some(EditorModel::Relace(RelaceEditor::new(api_key, host, model)))
} else if host.contains("api.morphllm") || model.contains("morph") {
Some(EditorModel::MorphLLM(MorphLLMEditor::new(
api_key, host, model,
)))
} else {
Some(EditorModel::OpenAICompatible(OpenAICompatibleEditor::new(
api_key, host, model,
)))
}
}
@@ -0,0 +1,158 @@
use super::EditorModelImpl;
use anyhow::Result;
use reqwest::Client;
use serde_json::{json, Value};
#[derive(Debug, Clone)]
pub struct MorphLLMEditor {
api_key: String,
host: String,
model: String,
}
impl MorphLLMEditor {
pub fn new(api_key: String, host: String, model: String) -> Self {
Self {
api_key,
host,
model,
}
}
fn extract_tag_content(text: &str, tag_name: &str) -> Option<String> {
let start_tag = format!("<{}>", tag_name);
let end_tag = format!("</{}>", tag_name);
if let (Some(start_pos), Some(end_pos)) = (text.find(&start_tag), text.find(&end_tag)) {
if start_pos < end_pos {
let content_start = start_pos + start_tag.len();
if let Some(content) = text.get(content_start..end_pos) {
return Some(content.trim().to_string());
}
}
}
None
}
fn format_user_prompt(original_code: &str, update_snippet: &str) -> String {
if let Some(code_content) = Self::extract_tag_content(update_snippet, "code") {
if let Some(instruction_content) =
Self::extract_tag_content(update_snippet, "instruction")
{
return format!(
"<instruction>{}</instruction>\n<code>{}</code>\n<update>{}</update>",
instruction_content, original_code, code_content
);
}
return format!(
"<code>{}</code>\n<update>{}</update>",
original_code, code_content
);
}
format!(
"<code>{}</code>\n<update>{}</update>",
original_code, update_snippet
)
}
}
impl EditorModelImpl for MorphLLMEditor {
async fn edit_code(
&self,
original_code: &str,
_old_str: &str,
update_snippet: &str,
) -> Result<String, String> {
let provider_url = if self.host.ends_with("/chat/completions") {
self.host.clone()
} else if self.host.ends_with('/') {
format!("{}chat/completions", self.host)
} else {
format!("{}/chat/completions", self.host)
};
let client = Client::new();
let user_prompt = Self::format_user_prompt(original_code, update_snippet);
let body = json!({
"model": self.model,
"messages": [
{
"role": "user",
"content": user_prompt
}
]
});
let response = match client
.post(&provider_url)
.header("Content-Type", "application/json")
.header("Authorization", format!("Bearer {}", self.api_key))
.json(&body)
.send()
.await
{
Ok(resp) => resp,
Err(e) => return Err(format!("Request error: {}", e)),
};
if !response.status().is_success() {
return Err(format!("API error: HTTP {}", response.status()));
}
let response_json: Value = match response.json().await {
Ok(json) => json,
Err(e) => return Err(format!("Failed to parse response: {}", e)),
};
let content = response_json
.get("choices")
.and_then(|choices| choices.get(0))
.and_then(|choice| choice.get("message"))
.and_then(|message| message.get("content"))
.and_then(|content| content.as_str())
.ok_or_else(|| "Invalid response format".to_string())?;
Ok(content.to_string())
}
fn get_str_replace_description(&self) -> &'static str {
"Use the edit_file to propose an edit to an existing file.
This will be read by a less intelligent model, which will quickly apply the edit. You should make it clear what the edit is, while also minimizing the unchanged code you write.
**IMPORTANT**: in the new_str parameter, you must also provide an `instruction` - a single sentence written in the first person describing what you are going to do for the sketched edit.
This instruction helps the less intelligent model understand and apply your edit correctly.
Examples of good instructions:
- I am adding error handling to the user authentication function and removing the old authentication method
- The instruction should be specific enough to disambiguate any uncertainty in your edit.
The format for new_str should be like this example:
<code>
new code here you want to add
</code>
<instruction>
adding new code with error handling
</instruction>
provide this to new_str as a single string.
When writing the edit, you should specify each edit in sequence, with the special comment // ... existing code ... to represent unchanged code in between edited lines.
For example:
// ... existing code ...
FIRST_EDIT
// ... existing code ...
SECOND_EDIT
// ... existing code ...
THIRD_EDIT
// ... existing code ...
You should bias towards repeating as few lines of the original file as possible to convey the change.
Each edit should contain sufficient context of unchanged lines around the code you're editing to resolve ambiguity.
If you plan on deleting a section, you must provide surrounding context to indicate the deletion.
DO NOT omit spans of pre-existing code without using the // ... existing code ... comment to indicate its absence.
"
}
}
@@ -0,0 +1,90 @@
use super::EditorModelImpl;
use anyhow::Result;
use reqwest::Client;
use serde_json::{json, Value};
#[derive(Debug, Clone)]
pub struct OpenAICompatibleEditor {
api_key: String,
host: String,
model: String,
}
impl OpenAICompatibleEditor {
pub fn new(api_key: String, host: String, model: String) -> Self {
Self {
api_key,
host,
model,
}
}
}
impl EditorModelImpl for OpenAICompatibleEditor {
async fn edit_code(
&self,
original_code: &str,
_old_str: &str,
update_snippet: &str,
) -> Result<String, String> {
let provider_url = if self.host.ends_with("/chat/completions") {
self.host.clone()
} else if self.host.ends_with('/') {
format!("{}chat/completions", self.host)
} else {
format!("{}/chat/completions", self.host)
};
let client = Client::new();
let user_prompt = format!(
"<code>{}</code>\n<update>{}</update>",
original_code, update_snippet
);
let body = json!({
"model": self.model,
"messages": [
{
"role": "user",
"content": user_prompt
}
]
});
let response = match client
.post(&provider_url)
.header("Content-Type", "application/json")
.header("Authorization", format!("Bearer {}", self.api_key))
.json(&body)
.send()
.await
{
Ok(resp) => resp,
Err(e) => return Err(format!("Request error: {}", e)),
};
if !response.status().is_success() {
return Err(format!("API error: HTTP {}", response.status()));
}
let response_json: Value = match response.json().await {
Ok(json) => json,
Err(e) => return Err(format!("Failed to parse response: {}", e)),
};
let content = response_json
.get("choices")
.and_then(|choices| choices.get(0))
.and_then(|choice| choice.get("message"))
.and_then(|message| message.get("content"))
.and_then(|content| content.as_str())
.ok_or_else(|| "Invalid response format".to_string())?;
Ok(content.to_string())
}
fn get_str_replace_description(&self) -> &'static str {
"Edit the file with the new content."
}
}
@@ -0,0 +1,88 @@
use super::EditorModelImpl;
use anyhow::Result;
use reqwest::Client;
use serde_json::{json, Value};
#[derive(Debug, Clone)]
pub struct RelaceEditor {
api_key: String,
host: String,
model: String,
}
impl RelaceEditor {
pub fn new(api_key: String, host: String, model: String) -> Self {
Self {
api_key,
host,
model,
}
}
}
impl EditorModelImpl for RelaceEditor {
async fn edit_code(
&self,
original_code: &str,
_old_str: &str,
update_snippet: &str,
) -> Result<String, String> {
let provider_url = if self.host.ends_with("/chat/completions") {
self.host.clone()
} else if self.host.ends_with('/') {
format!("{}chat/completions", self.host)
} else {
format!("{}/chat/completions", self.host)
};
let client = Client::new();
let body = json!({
"model": self.model,
"prediction": {
"content": original_code
},
"messages": [
{
"role": "user",
"content": update_snippet
}
]
});
let response = match client
.post(&provider_url)
.header("Content-Type", "application/json")
.header("Authorization", format!("Bearer {}", self.api_key))
.json(&body)
.send()
.await
{
Ok(resp) => resp,
Err(e) => return Err(format!("Request error: {}", e)),
};
if !response.status().is_success() {
return Err(format!("API error: HTTP {}", response.status()));
}
let response_json: Value = match response.json().await {
Ok(json) => json,
Err(e) => return Err(format!("Failed to parse response: {}", e)),
};
let content = response_json
.get("choices")
.and_then(|choices| choices.get(0))
.and_then(|choice| choice.get("message"))
.and_then(|message| message.get("content"))
.and_then(|content| content.as_str())
.ok_or_else(|| "Invalid response format".to_string())?;
Ok(content.to_string())
}
fn get_str_replace_description(&self) -> &'static str {
"edit_file will take the new_str and work out how to place old_str with it intelligently."
}
}
+39
View File
@@ -0,0 +1,39 @@
use std::path::Path;
#[allow(dead_code)]
pub fn get_language_identifier(path: &Path) -> &'static str {
match path.extension().and_then(|ext| ext.to_str()) {
Some("rs") => "rust",
Some("hs") => "haskell",
Some("rkt") | Some("scm") => "scheme",
Some("py") => "python",
Some("js") => "javascript",
Some("ts") => "typescript",
Some("json") => "json",
Some("toml") => "toml",
Some("yaml") | Some("yml") => "yaml",
Some("sh") => "bash",
Some("ps1") => "powershell",
Some("bat") | Some("cmd") => "batch",
Some("vbs") => "vbscript",
Some("go") => "go",
Some("md") => "markdown",
Some("html") => "html",
Some("css") => "css",
Some("sql") => "sql",
Some("java") => "java",
Some("cpp") | Some("cc") | Some("cxx") => "cpp",
Some("c") => "c",
Some("h") | Some("hpp") => "cpp",
Some("rb") => "ruby",
Some("php") => "php",
Some("swift") => "swift",
Some("kt") | Some("kts") => "kotlin",
Some("scala") => "scala",
Some("r") => "r",
Some("m") => "matlab",
Some("pl") => "perl",
Some("dockerfile") => "dockerfile",
_ => "",
}
}
+2
View File
@@ -4,12 +4,14 @@ mod builtin_skills;
pub(crate) mod chatrecall_extension;
pub(crate) mod code_execution_extension;
pub mod container;
pub mod editor_models;
pub mod execute_commands;
pub mod extension;
pub mod extension_malware_check;
pub mod extension_manager;
pub mod extension_manager_extension;
pub mod final_output_tool;
mod lang;
mod large_response_handler;
pub mod mcp_client;
pub mod moim;
+550 -38
View File
@@ -2,11 +2,15 @@
//!
//! Provides file viewing and editing capabilities as a platform extension.
use crate::agents::editor_models::{create_editor_model, EditorModel};
use crate::agents::extension::PlatformExtensionContext;
use crate::agents::mcp_client::{Error, McpClientTrait};
use anyhow::Result;
use async_trait::async_trait;
use etcetera::AppStrategy;
use ignore::gitignore::{Gitignore, GitignoreBuilder};
use indoc::indoc;
use mpatch::{apply_patch, parse_diffs, PatchError};
use rmcp::model::{
CallToolResult, Content, Implementation, InitializeResult, JsonObject, ListToolsResult,
ProtocolVersion, ServerCapabilities, Tool, ToolAnnotations, ToolsCapability,
@@ -16,7 +20,6 @@ use serde::Deserialize;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::sync::Mutex;
use tokio_util::sync::CancellationToken;
pub static EXTENSION_NAME: &str = "text_editor";
@@ -24,12 +27,31 @@ pub static EXTENSION_NAME: &str = "text_editor";
const MAX_VIEW_SIZE: u64 = 400 * 1024; // 400KB limit
const MAX_LINES_THRESHOLD: usize = 1000;
const SIMILARITY_THRESHOLD: f64 = 0.7;
const MAX_DIFF_SIZE: usize = 1024 * 1024; // 1MB max diff size
const MAX_FILES_IN_DIFF: usize = 100;
const MAX_DIR_ITEMS: usize = 50;
const DEFAULT_GOOSEIGNORE_CONTENT: &str = "\
# Default ignore patterns
.git/
.env
.env.*
*.pem
*.key
*_rsa
*_dsa
*_ecdsa
*_ed25519
node_modules/
__pycache__/
.DS_Store
";
#[derive(Debug, Deserialize, JsonSchema)]
struct TextEditorParams {
/// The command: "view", "write", "str_replace", "insert", or "undo_edit"
/// The command: "view", "write", "str_replace", "insert", "undo_edit", or "apply_diff"
command: String,
/// Absolute path to the file
/// Absolute path to the file (or base directory for apply_diff)
path: String,
/// For view: optional start line (1-indexed)
view_range_start: Option<i32>,
@@ -43,13 +65,18 @@ struct TextEditorParams {
new_str: Option<String>,
/// For insert: line number after which to insert (0 for beginning)
insert_line: Option<i32>,
/// For apply_diff or str_replace: unified diff content
diff: Option<String>,
}
pub struct TextEditorClient {
info: InitializeResult,
#[allow(dead_code)]
context: PlatformExtensionContext,
file_history: Arc<Mutex<HashMap<PathBuf, Vec<String>>>>,
file_history: Arc<std::sync::Mutex<HashMap<PathBuf, Vec<String>>>>,
editor_model: Option<EditorModel>,
ignore_patterns: Gitignore,
working_dir: PathBuf,
}
impl TextEditorClient {
@@ -80,13 +107,64 @@ impl TextEditorClient {
),
};
let working_dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let ignore_patterns = Self::build_ignore_patterns(&working_dir);
Ok(Self {
info,
context,
file_history: Arc::new(Mutex::new(HashMap::new())),
file_history: Arc::new(std::sync::Mutex::new(HashMap::new())),
editor_model: create_editor_model(),
ignore_patterns,
working_dir,
})
}
fn build_ignore_patterns(cwd: &Path) -> Gitignore {
let mut builder = GitignoreBuilder::new(cwd);
let local_ignore_path = cwd.join(".gooseignore");
let global_ignore_path = etcetera::choose_app_strategy(etcetera::AppStrategyArgs {
top_level_domain: "block".to_string(),
author: "Block".to_string(),
app_name: "goose".to_string(),
})
.map(|strategy| strategy.config_dir().join(".gooseignore"))
.ok();
let has_local_ignore = local_ignore_path.is_file();
let has_global_ignore = global_ignore_path
.as_ref()
.map(|p: &PathBuf| p.is_file())
.unwrap_or(false);
if !has_local_ignore && !has_global_ignore {
for pattern in DEFAULT_GOOSEIGNORE_CONTENT.lines() {
let trimmed = pattern.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
let _ = builder.add_line(None, trimmed);
}
}
if has_global_ignore {
let _ = builder.add(global_ignore_path.as_ref().unwrap());
}
if has_local_ignore {
let _ = builder.add(&local_ignore_path);
}
builder.build().unwrap_or_else(|_| Gitignore::empty())
}
fn is_ignored(&self, path: &Path) -> bool {
self.ignore_patterns
.matched(path, path.is_dir())
.is_ignore()
}
fn get_tools() -> Vec<Tool> {
let schema = schema_for!(TextEditorParams);
let schema_value =
@@ -100,11 +178,13 @@ impl TextEditorClient {
Commands:
- view: Read file content, optionally with line range [start, end]
- write: Create or overwrite a file with new content
- str_replace: Replace exact text (old_str->new_str)
- str_replace: Replace exact text (old_str->new_str), or pass diff for fuzzy patching
- insert: Insert text after a specific line number
- undo_edit: Revert the last edit to a file
- apply_diff: Apply a unified diff to one or more files
For str_replace, old_str must match EXACTLY (including whitespace).
For apply_diff, provide unified diff format with path as base directory.
"#}
.to_string(),
schema_value.as_object().unwrap().clone(),
@@ -124,6 +204,10 @@ impl TextEditorClient {
start: Option<i32>,
end: Option<i32>,
) -> Result<String, String> {
if path.is_dir() {
return self.list_directory_contents(path);
}
let metadata = tokio::fs::metadata(path)
.await
.map_err(|e| format!("Cannot access '{}': {}", path.display(), e))?;
@@ -199,15 +283,70 @@ impl TextEditorClient {
Ok(result)
}
fn list_directory_contents(&self, path: &Path) -> Result<String, String> {
let entries =
std::fs::read_dir(path).map_err(|e| format!("Failed to read directory: {}", e))?;
let mut files = Vec::new();
let mut dirs = Vec::new();
let mut total_count = 0;
for entry in entries {
let entry = entry.map_err(|e| format!("Failed to read directory entry: {}", e))?;
total_count += 1;
if dirs.len() + files.len() < MAX_DIR_ITEMS {
let metadata = entry
.metadata()
.map_err(|e| format!("Failed to read metadata: {}", e))?;
let name = entry.file_name().to_string_lossy().to_string();
if metadata.is_dir() {
dirs.push(format!("{}/", name));
} else {
files.push(name);
}
}
}
dirs.sort();
files.sort();
let mut output = format!("'{}' is a directory. Contents:\n\n", path.display());
if !dirs.is_empty() {
output.push_str("Directories:\n");
for dir in &dirs {
output.push_str(&format!(" {}\n", dir));
}
output.push('\n');
}
if !files.is_empty() {
output.push_str("Files:\n");
for file in &files {
output.push_str(&format!(" {}\n", file));
}
}
if dirs.is_empty() && files.is_empty() {
output.push_str(" (empty directory)\n");
}
if total_count > MAX_DIR_ITEMS {
output.push_str(&format!(
"\n... and {} more items (showing first {} items)\n",
total_count - MAX_DIR_ITEMS,
MAX_DIR_ITEMS
));
}
Ok(output)
}
async fn handle_write(&self, path: &Path, content: &str) -> Result<String, String> {
if path.exists() {
if let Ok(old_content) = tokio::fs::read_to_string(path).await {
let mut history = self.file_history.lock().await;
history
.entry(path.to_path_buf())
.or_default()
.push(old_content);
}
save_file_history(&path.to_path_buf(), &self.file_history)?;
}
if let Some(parent) = path.parent() {
@@ -229,6 +368,51 @@ impl TextEditorClient {
))
}
async fn handle_str_replace_with_editor(
&self,
path: &Path,
old_str: &str,
new_str: &str,
) -> Result<String, String> {
if !path.exists() {
return Err(format!(
"File '{}' does not exist, you can write a new file with the `write` command",
path.display()
));
}
let content = tokio::fs::read_to_string(path)
.await
.map_err(|e| format!("Cannot read '{}': {}", path.display(), e))?;
if let Some(ref editor) = self.editor_model {
save_file_history(&path.to_path_buf(), &self.file_history)?;
match editor.edit_code(&content, old_str, new_str).await {
Ok(updated_content) => {
let mut normalized_content = normalize_line_endings(&updated_content);
if !normalized_content.ends_with('\n') {
normalized_content.push('\n');
}
tokio::fs::write(path, &normalized_content)
.await
.map_err(|e| format!("Failed to write file: {}", e))?;
return Ok(format!("Successfully edited {}", path.display()));
}
Err(e) => {
tracing::debug!(
"Editor API call failed: {}, falling back to string replacement",
e
);
}
}
}
self.handle_str_replace(path, old_str, new_str).await
}
async fn handle_str_replace(
&self,
path: &Path,
@@ -244,8 +428,7 @@ impl TextEditorClient {
if matches.is_empty() {
if let Some(suggestion) = self.find_similar(&content, old_str) {
return Err(format!(
"No exact match found for old_str. Did you mean:
{}",
"No exact match found for old_str. Did you mean:\n{}",
suggestion
));
}
@@ -262,14 +445,11 @@ impl TextEditorClient {
));
}
save_file_history(&path.to_path_buf(), &self.file_history)?;
let new_content = content.replacen(old_str, new_str, 1);
{
let mut history = self.file_history.lock().await;
history.entry(path.to_path_buf()).or_default().push(content);
}
let new_content = normalize_line_endings(&new_content);
tokio::fs::write(path, &new_content)
.await
.map_err(|e| format!("Cannot write '{}': {}", path.display(), e))?;
@@ -301,13 +481,7 @@ impl TextEditorClient {
));
}
{
let mut history = self.file_history.lock().await;
history
.entry(path.to_path_buf())
.or_default()
.push(content.clone());
}
save_file_history(&path.to_path_buf(), &self.file_history)?;
let new_lines: Vec<&str> = text.lines().collect();
let mut result_lines: Vec<&str> = Vec::with_capacity(lines.len() + new_lines.len());
@@ -333,7 +507,7 @@ impl TextEditorClient {
async fn handle_undo(&self, path: &Path) -> Result<String, String> {
let previous = {
let mut history = self.file_history.lock().await;
let mut history = self.file_history.lock().unwrap();
history.get_mut(&path.to_path_buf()).and_then(|h| h.pop())
};
@@ -422,16 +596,69 @@ impl TextEditorClient {
}
fn resolve_path(&self, path_str: &str) -> Result<PathBuf, String> {
let cwd =
std::env::current_dir().map_err(|e| format!("Cannot get current directory: {}", e))?;
let expanded: String = shellexpand::tilde(path_str).into();
let path = Path::new(&expanded);
if path.is_absolute() {
Ok(path.to_path_buf())
let resolved = if path.is_absolute() {
path.to_path_buf()
} else {
Ok(cwd.join(path))
self.working_dir.join(path)
};
self.validate_path(&resolved)?;
Ok(resolved)
}
fn validate_path(&self, path: &Path) -> Result<(), String> {
if path
.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
{
return Err("Path traversal detected: paths cannot contain '..'".to_string());
}
if path.exists() {
if let (Ok(canonical_path), Ok(canonical_base)) =
(path.canonicalize(), self.working_dir.canonicalize())
{
if !canonical_path.starts_with(&canonical_base) {
return Err(format!(
"Path '{}' is outside the working directory",
path.display()
));
}
}
if let Ok(metadata) = path.symlink_metadata() {
if metadata.is_symlink() {
return Err(format!(
"Cannot modify symlink '{}'. Please operate on the actual file.",
path.display()
));
}
}
} else if let Some(parent) = path.parent() {
if let (Ok(canonical_parent), Ok(canonical_base)) =
(parent.canonicalize(), self.working_dir.canonicalize())
{
if !canonical_parent.starts_with(&canonical_base) {
return Err(format!(
"Path '{}' would be outside the working directory",
path.display()
));
}
}
}
if self.is_ignored(path) {
return Err(format!(
"Access to '{}' is restricted by .gooseignore",
path.display()
));
}
Ok(())
}
}
@@ -446,6 +673,239 @@ fn normalize_line_endings(content: &str) -> String {
}
}
#[derive(Debug, Default)]
struct DiffResults {
files_created: usize,
files_modified: usize,
lines_added: usize,
lines_removed: usize,
}
fn save_file_history(
path: &PathBuf,
file_history: &Arc<std::sync::Mutex<HashMap<PathBuf, Vec<String>>>>,
) -> Result<(), String> {
let mut history = file_history.lock().unwrap();
let content = if path.exists() {
std::fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?
} else {
String::new()
};
history.entry(path.clone()).or_default().push(content);
Ok(())
}
fn adjust_base_dir_for_overlap(base_dir: &Path, file_path: &Path) -> PathBuf {
let base_components: Vec<_> = base_dir.components().collect();
let file_components: Vec<_> = file_path.components().collect();
let min_len = base_components.len().min(file_components.len());
let max_k = (1..=min_len)
.rfind(|&k| file_components[0..k] == base_components[base_components.len() - k..])
.unwrap_or(0);
if max_k > 0 {
let adjusted_components = base_components[..base_components.len() - max_k].to_vec();
PathBuf::from_iter(adjusted_components)
} else {
base_dir.to_path_buf()
}
}
fn apply_single_patch(
patch: &mpatch::Patch,
base_dir: &Path,
file_history: &Arc<std::sync::Mutex<HashMap<PathBuf, Vec<String>>>>,
results: &mut DiffResults,
failed_hunks: &mut Vec<String>,
) -> Result<(), String> {
let adjusted_base_dir = adjust_base_dir_for_overlap(base_dir, &patch.file_path);
let file_path = adjusted_base_dir.join(&patch.file_path);
let file_existed = file_path.exists();
if file_existed {
save_file_history(&file_path, file_history)?;
}
let success = apply_patch(patch, &adjusted_base_dir, false, 0.7).map_err(|e| match e {
PatchError::Io { path, source } => {
format!("Failed to process '{}': {}", path.display(), source)
}
PatchError::PathTraversal(path) => {
format!(
"Security: Path '{}' would escape the base directory",
path.display()
)
}
PatchError::TargetNotFound(path) => {
format!(
"File '{}' not found and patch doesn't create it",
path.display()
)
}
PatchError::MissingFileHeader => "Invalid patch format".to_string(),
})?;
if !success {
let hunk_count = patch.hunks.len();
let context_preview = patch
.hunks
.first()
.and_then(|h| {
let match_block = h.get_match_block();
match_block.first().map(|s| s.to_string())
})
.unwrap_or_else(|| "(empty context)".to_string());
failed_hunks.push(format!(
"Failed to apply some hunks to '{}' ({} hunks total). First expected line: '{}'",
patch.file_path.display(),
hunk_count,
context_preview
));
}
if file_existed {
results.files_modified += 1;
} else {
results.files_created += 1;
}
Ok(())
}
fn parse_diff_content(diff_content: &str) -> Result<Vec<mpatch::Patch>, String> {
let wrapped_diff = if diff_content.contains("```diff") || diff_content.contains("```patch") {
diff_content.to_string()
} else {
format!("```diff\n{}\n```", diff_content)
};
parse_diffs(&wrapped_diff).map_err(|e| match e {
PatchError::MissingFileHeader => {
"Invalid diff format: Missing file header (e.g., '--- a/path/to/file')".to_string()
}
PatchError::Io { path, source } => {
format!("I/O error processing {}: {}", path.display(), source)
}
PatchError::PathTraversal(path) => {
format!(
"Security: Path '{}' would escape the base directory",
path.display()
)
}
PatchError::TargetNotFound(path) => {
format!("Target file not found: {}", path.display())
}
})
}
fn ensure_trailing_newlines(patches: &[mpatch::Patch], base_dir: &Path) -> Result<(), String> {
for patch in patches {
let adjusted_base_dir = adjust_base_dir_for_overlap(base_dir, &patch.file_path);
let file_path = adjusted_base_dir.join(&patch.file_path);
if file_path.exists() {
let content = std::fs::read_to_string(&file_path)
.map_err(|e| format!("Failed to read file for post-processing: {}", e))?;
if !content.ends_with('\n') {
let content_with_newline = format!("{}\n", content);
std::fs::write(&file_path, content_with_newline)
.map_err(|e| format!("Failed to add trailing newline: {}", e))?;
}
}
}
Ok(())
}
fn count_line_changes(diff_content: &str) -> (usize, usize) {
let lines_added = diff_content
.lines()
.filter(|l| l.starts_with('+') && !l.starts_with("+++"))
.count();
let lines_removed = diff_content
.lines()
.filter(|l| l.starts_with('-') && !l.starts_with("---"))
.count();
(lines_added, lines_removed)
}
async fn apply_diff(
base_path: &Path,
diff_content: &str,
file_history: &Arc<std::sync::Mutex<HashMap<PathBuf, Vec<String>>>>,
) -> Result<String, String> {
if diff_content.len() > MAX_DIFF_SIZE {
return Err(format!(
"Diff is too large ({} bytes). Maximum size is {} bytes (1MB).",
diff_content.len(),
MAX_DIFF_SIZE
));
}
let patches = parse_diff_content(diff_content)?;
if patches.len() > MAX_FILES_IN_DIFF {
return Err(format!(
"Too many files in diff ({}). Maximum is {} files.",
patches.len(),
MAX_FILES_IN_DIFF
));
}
let base_dir = if base_path.is_file() {
base_path.parent().unwrap_or(Path::new(".")).to_path_buf()
} else {
base_path.to_path_buf()
};
let mut results = DiffResults::default();
let mut failed_hunks = Vec::new();
for patch in &patches {
apply_single_patch(
patch,
&base_dir,
file_history,
&mut results,
&mut failed_hunks,
)?;
}
ensure_trailing_newlines(&patches, &base_dir)?;
if !failed_hunks.is_empty() {
tracing::warn!(
"Some patches were only partially applied: {}",
failed_hunks.join("\n")
);
}
let (lines_added, lines_removed) = count_line_changes(diff_content);
results.lines_added = lines_added;
results.lines_removed = lines_removed;
let summary = if patches.len() == 1 {
format!(
"Successfully applied diff to {}:\n• Lines added: {}\n• Lines removed: {}",
base_path.display(),
results.lines_added,
results.lines_removed
)
} else {
format!(
"Successfully applied multi-file diff:\n• Files created: {}\n• Files modified: {}\n• Lines added: {}\n• Lines removed: {}",
results.files_created,
results.files_modified,
results.lines_added,
results.lines_removed
)
};
Ok(summary)
}
#[async_trait]
impl McpClientTrait for TextEditorClient {
async fn list_tools(
@@ -510,9 +970,14 @@ impl McpClientTrait for TextEditorClient {
self.handle_write(&path, &text).await
}
"str_replace" => {
let old_str = params.old_str.unwrap_or_default();
let new_str = params.new_str.unwrap_or_default();
self.handle_str_replace(&path, &old_str, &new_str).await
if let Some(diff_content) = params.diff {
apply_diff(&path, &diff_content, &self.file_history).await
} else {
let old_str = params.old_str.unwrap_or_default();
let new_str = params.new_str.unwrap_or_default();
self.handle_str_replace_with_editor(&path, &old_str, &new_str)
.await
}
}
"insert" => {
let line = params.insert_line.unwrap_or(0);
@@ -520,8 +985,12 @@ impl McpClientTrait for TextEditorClient {
self.handle_insert(&path, line, &text).await
}
"undo_edit" => self.handle_undo(&path).await,
"apply_diff" => {
let diff_content = params.diff.unwrap_or_default();
apply_diff(&path, &diff_content, &self.file_history).await
}
cmd => Err(format!(
"Unknown command: '{}'. Use: view, write, str_replace, insert, undo_edit",
"Unknown command: '{}'. Use: view, write, str_replace, insert, undo_edit, apply_diff",
cmd
)),
};
@@ -608,4 +1077,47 @@ line 3",
let content = tokio::fs::read_to_string(&file_path).await.unwrap();
assert_eq!(content.trim(), "original");
}
#[tokio::test]
async fn test_apply_diff() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test.txt");
std::fs::write(&file_path, "line1\nline2\nline3\n").unwrap();
let diff = r#"--- a/test.txt
+++ b/test.txt
@@ -1,3 +1,3 @@
line1
-line2
+modified line2
line3
"#;
let file_history = Arc::new(std::sync::Mutex::new(HashMap::new()));
let result = apply_diff(temp_dir.path(), diff, &file_history).await;
assert!(result.is_ok(), "apply_diff failed: {:?}", result);
let content = std::fs::read_to_string(&file_path).unwrap();
assert!(content.contains("modified line2"));
assert!(!content.contains("\nline2\n"));
}
#[tokio::test]
async fn test_view_directory() {
let temp_dir = TempDir::new().unwrap();
std::fs::create_dir(temp_dir.path().join("subdir")).unwrap();
std::fs::write(temp_dir.path().join("file1.txt"), "content").unwrap();
std::fs::write(temp_dir.path().join("file2.txt"), "content").unwrap();
let client = create_test_client();
let result = client.handle_view(temp_dir.path(), None, None).await;
assert!(result.is_ok());
let output = result.unwrap();
assert!(output.contains("is a directory"));
assert!(output.contains("subdir/"));
assert!(output.contains("file1.txt"));
assert!(output.contains("file2.txt"));
}
}