diff --git a/Cargo.lock b/Cargo.lock index c5409c4e14a0..a8d1de182015 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4258,7 +4258,6 @@ dependencies = [ "etcetera 0.11.0", "fs2", "futures", - "goose-mcp", "goose-test-support", "hf-hub", "ignore", diff --git a/crates/goose-acp/src/server.rs b/crates/goose-acp/src/server.rs index 3f953ab6adea..7010add88439 100644 --- a/crates/goose-acp/src/server.rs +++ b/crates/goose-acp/src/server.rs @@ -102,6 +102,10 @@ fn create_tool_location(path: &str, line: Option) -> ToolCallLocation { loc } +fn is_developer_file_tool(tool_name: &str) -> bool { + matches!(tool_name, "write" | "edit") +} + fn extract_tool_locations( tool_request: &goose::conversation::message::ToolRequest, tool_response: &goose::conversation::message::ToolResponse, @@ -109,10 +113,11 @@ fn extract_tool_locations( let mut locations = Vec::new(); if let Ok(tool_call) = &tool_request.tool_call { - if tool_call.name != "developer__text_editor" { + if !is_developer_file_tool(tool_call.name.as_ref()) { return locations; } + let tool_name = tool_call.name.as_ref(); let path_str = tool_call .arguments .as_ref() @@ -120,6 +125,11 @@ fn extract_tool_locations( .and_then(|p| p.as_str()); if let Some(path_str) = path_str { + if matches!(tool_name, "write" | "edit") { + locations.push(create_tool_location(path_str, Some(1))); + return locations; + } + let command = tool_call .arguments .as_ref() @@ -1432,10 +1442,7 @@ print(\"hello, world\") #[test] fn test_format_tool_name_with_extension() { - assert_eq!( - format_tool_name("developer__text_editor"), - "Developer: Text Editor" - ); + assert_eq!(format_tool_name("developer__edit"), "Developer: Edit"); assert_eq!( format_tool_name("platform__manage_extensions"), "Platform: Manage Extensions" diff --git a/crates/goose-acp/tests/common_tests/mod.rs b/crates/goose-acp/tests/common_tests/mod.rs index 7305b7b8f647..a2209c2c0466 100644 --- a/crates/goose-acp/tests/common_tests/mod.rs +++ b/crates/goose-acp/tests/common_tests/mod.rs @@ -313,7 +313,7 @@ pub async fn run_prompt_basic() { pub async fn run_prompt_codemode() { let expected_session_id = ExpectedSessionId::default(); let prompt = - "Search for getCode and textEditor tools. Use them to save the code to /tmp/result.txt."; + "Search for getCode and write tools. Use them to save the code to /tmp/result.txt."; let mcp = McpFixture::new(Some(expected_session_id.clone())).await; let openai = OpenAiFixture::new( vec![ @@ -326,7 +326,7 @@ pub async fn run_prompt_codemode() { include_str!("../test_data/openai_builtin_execute.txt"), ), ( - r#"Successfully wrote to /tmp/result.txt"#.into(), + r#"Created /tmp/result.txt"#.into(), include_str!("../test_data/openai_builtin_final.txt"), ), ], @@ -352,7 +352,7 @@ pub async fn run_prompt_codemode() { } let result = fs::read_to_string("/tmp/result.txt").unwrap_or_default(); - assert_eq!(result, format!("{FAKE_CODE}\n")); + assert_eq!(result, FAKE_CODE); expected_session_id.assert_matches(&session.session_id().0); } diff --git a/crates/goose-acp/tests/test_data/openai_builtin_execute.txt b/crates/goose-acp/tests/test_data/openai_builtin_execute.txt index 0aebbd29ea03..bf6d35bd7935 100644 --- a/crates/goose-acp/tests/test_data/openai_builtin_execute.txt +++ b/crates/goose-acp/tests/test_data/openai_builtin_execute.txt @@ -322,9 +322,9 @@ data: {"id":"chatcmpl-D64NZp69RkEyXdUoDaCBj7fSYll8J","object":"chat.completion.c data: {"id":"chatcmpl-D64NZp69RkEyXdUoDaCBj7fSYll8J","object":"chat.completion.chunk","created":1770339173,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":" Developer"}}]},"finish_reason":null}],"usage":null,"obfuscation":"G6t"} -data: {"id":"chatcmpl-D64NZp69RkEyXdUoDaCBj7fSYll8J","object":"chat.completion.chunk","created":1770339173,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":".text"}}]},"finish_reason":null}],"usage":null,"obfuscation":"OOxdzNJq"} +data: {"id":"chatcmpl-D64NZp69RkEyXdUoDaCBj7fSYll8J","object":"chat.completion.chunk","created":1770339173,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"."}}]},"finish_reason":null}],"usage":null,"obfuscation":"OOxdzNJq"} -data: {"id":"chatcmpl-D64NZp69RkEyXdUoDaCBj7fSYll8J","object":"chat.completion.chunk","created":1770339173,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"Editor"}}]},"finish_reason":null}],"usage":null,"obfuscation":"MiMZRWA"} +data: {"id":"chatcmpl-D64NZp69RkEyXdUoDaCBj7fSYll8J","object":"chat.completion.chunk","created":1770339173,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"write"}}]},"finish_reason":null}],"usage":null,"obfuscation":"MiMZRWA"} data: {"id":"chatcmpl-D64NZp69RkEyXdUoDaCBj7fSYll8J","object":"chat.completion.chunk","created":1770339173,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"({"}}]},"finish_reason":null}],"usage":null,"obfuscation":"7sQdVn1KZH3"} @@ -354,7 +354,7 @@ data: {"id":"chatcmpl-D64NZp69RkEyXdUoDaCBj7fSYll8J","object":"chat.completion.c data: {"id":"chatcmpl-D64NZp69RkEyXdUoDaCBj7fSYll8J","object":"chat.completion.chunk","created":1770339173,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":" "}}]},"finish_reason":null}],"usage":null,"obfuscation":"XurvUHlgwc"} -data: {"id":"chatcmpl-D64NZp69RkEyXdUoDaCBj7fSYll8J","object":"chat.completion.chunk","created":1770339173,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":" command"}}]},"finish_reason":null}],"usage":null,"obfuscation":"ZsYLy"} +data: {"id":"chatcmpl-D64NZp69RkEyXdUoDaCBj7fSYll8J","object":"chat.completion.chunk","created":1770339173,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":" // command"}}]},"finish_reason":null}],"usage":null,"obfuscation":"ZsYLy"} data: {"id":"chatcmpl-D64NZp69RkEyXdUoDaCBj7fSYll8J","object":"chat.completion.chunk","created":1770339173,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":":"}}]},"finish_reason":null}],"usage":null,"obfuscation":"PFlue8D49Rzx"} @@ -370,9 +370,9 @@ data: {"id":"chatcmpl-D64NZp69RkEyXdUoDaCBj7fSYll8J","object":"chat.completion.c data: {"id":"chatcmpl-D64NZp69RkEyXdUoDaCBj7fSYll8J","object":"chat.completion.chunk","created":1770339173,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":" "}}]},"finish_reason":null}],"usage":null,"obfuscation":"xVJI6wFQLA"} -data: {"id":"chatcmpl-D64NZp69RkEyXdUoDaCBj7fSYll8J","object":"chat.completion.chunk","created":1770339173,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":" file"}}]},"finish_reason":null}],"usage":null,"obfuscation":"aYkuCMJQ"} +data: {"id":"chatcmpl-D64NZp69RkEyXdUoDaCBj7fSYll8J","object":"chat.completion.chunk","created":1770339173,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":" content"}}]},"finish_reason":null}],"usage":null,"obfuscation":"aYkuCMJQ"} -data: {"id":"chatcmpl-D64NZp69RkEyXdUoDaCBj7fSYll8J","object":"chat.completion.chunk","created":1770339173,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"_text"}}]},"finish_reason":null}],"usage":null,"obfuscation":"DQ5IKXUC"} +data: {"id":"chatcmpl-D64NZp69RkEyXdUoDaCBj7fSYll8J","object":"chat.completion.chunk","created":1770339173,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":""}}]},"finish_reason":null}],"usage":null,"obfuscation":"DQ5IKXUC"} data: {"id":"chatcmpl-D64NZp69RkEyXdUoDaCBj7fSYll8J","object":"chat.completion.chunk","created":1770339173,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":":"}}]},"finish_reason":null}],"usage":null,"obfuscation":"YaxTVILdGh6I"} @@ -466,9 +466,9 @@ data: {"id":"chatcmpl-D64NZp69RkEyXdUoDaCBj7fSYll8J","object":"chat.completion.c data: {"id":"chatcmpl-D64NZp69RkEyXdUoDaCBj7fSYll8J","object":"chat.completion.chunk","created":1770339173,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"Developer"}}]},"finish_reason":null}],"usage":null,"obfuscation":"jzRU"} -data: {"id":"chatcmpl-D64NZp69RkEyXdUoDaCBj7fSYll8J","object":"chat.completion.chunk","created":1770339173,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":".text"}}]},"finish_reason":null}],"usage":null,"obfuscation":"zeeCDR1q"} +data: {"id":"chatcmpl-D64NZp69RkEyXdUoDaCBj7fSYll8J","object":"chat.completion.chunk","created":1770339173,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"."}}]},"finish_reason":null}],"usage":null,"obfuscation":"zeeCDR1q"} -data: {"id":"chatcmpl-D64NZp69RkEyXdUoDaCBj7fSYll8J","object":"chat.completion.chunk","created":1770339173,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"Editor"}}]},"finish_reason":null}],"usage":null,"obfuscation":"8YZ1VtI"} +data: {"id":"chatcmpl-D64NZp69RkEyXdUoDaCBj7fSYll8J","object":"chat.completion.chunk","created":1770339173,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"write"}}]},"finish_reason":null}],"usage":null,"obfuscation":"8YZ1VtI"} data: {"id":"chatcmpl-D64NZp69RkEyXdUoDaCBj7fSYll8J","object":"chat.completion.chunk","created":1770339173,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\",\""}}]},"finish_reason":null}],"usage":null,"obfuscation":"R15EQTSl"} diff --git a/crates/goose-acp/tests/test_data/openai_builtin_search.txt b/crates/goose-acp/tests/test_data/openai_builtin_search.txt index 4220c58ef8ab..7a47e4f4d2bc 100644 --- a/crates/goose-acp/tests/test_data/openai_builtin_search.txt +++ b/crates/goose-acp/tests/test_data/openai_builtin_search.txt @@ -24,11 +24,11 @@ data: {"id":"chatcmpl-D64NHpAses8hYgIt8xQfDCmg3PoHQ","object":"chat.completion.c data: {"id":"chatcmpl-D64NHpAses8hYgIt8xQfDCmg3PoHQ","object":"chat.completion.chunk","created":1770339155,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"usage":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"function":{"arguments":"evelop"}}]},"finish_reason":null}],"obfuscation":"YdjzlvJ"} -data: {"id":"chatcmpl-D64NHpAses8hYgIt8xQfDCmg3PoHQ","object":"chat.completion.chunk","created":1770339155,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"usage":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"function":{"arguments":"er.t"}}]},"finish_reason":null}],"obfuscation":"Kv1vRc0to"} +data: {"id":"chatcmpl-D64NHpAses8hYgIt8xQfDCmg3PoHQ","object":"chat.completion.chunk","created":1770339155,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"usage":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"function":{"arguments":"er.w"}}]},"finish_reason":null}],"obfuscation":"Kv1vRc0to"} -data: {"id":"chatcmpl-D64NHpAses8hYgIt8xQfDCmg3PoHQ","object":"chat.completion.chunk","created":1770339155,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"usage":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"function":{"arguments":"extEd"}}]},"finish_reason":null}],"obfuscation":"4sRF9L7t"} +data: {"id":"chatcmpl-D64NHpAses8hYgIt8xQfDCmg3PoHQ","object":"chat.completion.chunk","created":1770339155,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"usage":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"function":{"arguments":"rite"}}]},"finish_reason":null}],"obfuscation":"4sRF9L7t"} -data: {"id":"chatcmpl-D64NHpAses8hYgIt8xQfDCmg3PoHQ","object":"chat.completion.chunk","created":1770339155,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"usage":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"function":{"arguments":"itor\"]"}}]},"finish_reason":null}],"obfuscation":"SmXF9J"} +data: {"id":"chatcmpl-D64NHpAses8hYgIt8xQfDCmg3PoHQ","object":"chat.completion.chunk","created":1770339155,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"usage":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"function":{"arguments":"\"]"}}]},"finish_reason":null}],"obfuscation":"SmXF9J"} data: {"id":"chatcmpl-D64NHpAses8hYgIt8xQfDCmg3PoHQ","object":"chat.completion.chunk","created":1770339155,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"usage":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"function":{"arguments":"}"}}]},"finish_reason":null}],"obfuscation":"kO5yFNBeMAXW"} diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index 080e8975e683..c42d81e6e6b2 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -6,9 +6,7 @@ use goose::config::Config; use goose::posthog::get_telemetry_choice; use goose::recipe::Recipe; use goose_mcp::mcp_server_runner::{serve, McpCommand}; -use goose_mcp::{ - AutoVisualiserRouter, ComputerControllerServer, DeveloperServer, MemoryServer, TutorialServer, -}; +use goose_mcp::{AutoVisualiserRouter, ComputerControllerServer, MemoryServer, TutorialServer}; use crate::commands::configure::{configure_telemetry_consent_dialog, handle_configure}; use crate::commands::info::handle_info; @@ -1018,7 +1016,6 @@ async fn handle_mcp_command(server: McpCommand) -> Result<()> { McpCommand::ComputerController => serve(ComputerControllerServer::new()).await?, McpCommand::Memory => serve(MemoryServer::new()).await?, McpCommand::Tutorial => serve(TutorialServer::new()).await?, - McpCommand::Developer => serve(DeveloperServer::new()).await?, } Ok(()) } diff --git a/crates/goose-cli/src/commands/configure.rs b/crates/goose-cli/src/commands/configure.rs index e4fa7d980f36..1a4b5fee4489 100644 --- a/crates/goose-cli/src/commands/configure.rs +++ b/crates/goose-cli/src/commands/configure.rs @@ -1,7 +1,7 @@ use crate::recipes::github_recipe::GOOSE_RECIPE_GITHUB_REPO_CONFIG_KEY; use cliclack::spinner; use console::style; -use goose::agents::extension::ToolInfo; +use goose::agents::extension::{ToolInfo, PLATFORM_EXTENSIONS}; use goose::agents::extension_manager::get_parameter_names; use goose::agents::Agent; use goose::agents::{extension::Envs, ExtensionConfig}; @@ -983,24 +983,35 @@ fn configure_builtin_extension() -> anyhow::Result<()> { select = select.item(id, name, desc); } let extension = select.interact()?.to_string(); - let timeout = prompt_extension_timeout()?; - let (display_name, description) = extensions .iter() .find(|(id, _, _)| id == &extension) .map(|(_, name, desc)| (name.to_string(), desc.to_string())) .unwrap_or_else(|| (extension.clone(), extension.clone())); - set_extension(ExtensionEntry { - enabled: true, - config: ExtensionConfig::Builtin { + let config = if PLATFORM_EXTENSIONS.contains_key(extension.as_str()) { + ExtensionConfig::Platform { + name: extension.clone(), + description, + display_name: Some(display_name), + bundled: Some(true), + available_tools: Vec::new(), + } + } else { + let timeout = prompt_extension_timeout()?; + ExtensionConfig::Builtin { name: extension.clone(), display_name: Some(display_name), timeout: Some(timeout), bundled: Some(true), description, available_tools: Vec::new(), - }, + } + }; + + set_extension(ExtensionEntry { + enabled: true, + config, }); cliclack::outro(format!("Enabled {} extension", style(extension).green()))?; @@ -1741,12 +1752,11 @@ pub async fn handle_openrouter_auth() -> anyhow::Result<()> { if !has_developer { set_extension(ExtensionEntry { enabled: true, - config: ExtensionConfig::Builtin { + config: ExtensionConfig::Platform { name: "developer".to_string(), + description: "Developer extension".to_string(), display_name: Some(goose::config::DEFAULT_DISPLAY_NAME.to_string()), - timeout: Some(goose::config::DEFAULT_EXTENSION_TIMEOUT), bundled: Some(true), - description: "Developer extension".to_string(), available_tools: Vec::new(), }, }); @@ -1811,12 +1821,11 @@ pub async fn handle_tetrate_auth() -> anyhow::Result<()> { if !has_developer { set_extension(ExtensionEntry { enabled: true, - config: ExtensionConfig::Builtin { + config: ExtensionConfig::Platform { name: "developer".to_string(), + description: "Developer extension".to_string(), display_name: Some(goose::config::DEFAULT_DISPLAY_NAME.to_string()), - timeout: Some(goose::config::DEFAULT_EXTENSION_TIMEOUT), bundled: Some(true), - description: "Developer extension".to_string(), available_tools: Vec::new(), }, }); diff --git a/crates/goose-cli/src/session/export.rs b/crates/goose-cli/src/session/export.rs index e05daecc21f2..9b525a2730a0 100644 --- a/crates/goose-cli/src/session/export.rs +++ b/crates/goose-cli/src/session/export.rs @@ -112,6 +112,14 @@ fn value_to_markdown(value: &Value, depth: usize, export_full_strings: bool) -> md_string } +fn is_shell_tool_name(tool_name: &str) -> bool { + matches!(tool_name, "shell") +} + +fn is_developer_file_tool_name(tool_name: &str) -> bool { + matches!(tool_name, "write" | "edit") +} + pub fn tool_request_to_markdown(req: &ToolRequest, export_all_content: bool) -> String { let mut md = String::new(); match &req.tool_call { @@ -119,6 +127,10 @@ pub fn tool_request_to_markdown(req: &ToolRequest, export_all_content: bool) -> let parts: Vec<_> = call.name.rsplitn(2, "__").collect(); let (namespace, tool_name_only) = if parts.len() == 2 { (parts[1], parts[0]) + } else if is_shell_tool_name(call.name.as_ref()) + || is_developer_file_tool_name(call.name.as_ref()) + { + ("developer", parts[0]) } else { ("Tool", parts[0]) }; @@ -130,7 +142,7 @@ pub fn tool_request_to_markdown(req: &ToolRequest, export_all_content: bool) -> md.push_str("**Arguments:**\n"); match call.name.as_ref() { - "developer__shell" => { + name if is_shell_tool_name(name) => { if let Some(Value::String(command)) = call.arguments.as_ref().and_then(|args| args.get("command")) { @@ -157,39 +169,25 @@ pub fn tool_request_to_markdown(req: &ToolRequest, export_all_content: bool) -> )); } } - "developer__text_editor" => { + name if is_developer_file_tool_name(name) => { if let Some(Value::String(path)) = call.arguments.as_ref().and_then(|args| args.get("path")) { md.push_str(&format!("* **path**: `{}`\n", path)); } - if let Some(Value::String(code_edit)) = call - .arguments - .as_ref() - .and_then(|args| args.get("code_edit")) - { - md.push_str(&format!( - "* **code_edit**:\n ```\n{}\n ```\n", - code_edit - )); - } - let other_args: serde_json::Map = call - .arguments - .as_ref() - .map(|obj| { - obj.iter() - .filter(|(k, _)| k.as_str() != "path" && k.as_str() != "code_edit") - .map(|(k, v)| (k.clone(), v.clone())) - .collect() - }) - .unwrap_or_default(); - if !other_args.is_empty() { - md.push_str(&value_to_markdown( - &Value::Object(other_args), - 0, - export_all_content, - )); + if let Some(args) = &call.arguments { + let mut other_args = args.clone(); + other_args.remove("path"); + if !other_args.is_empty() { + md.push_str(&value_to_markdown( + &Value::Object(other_args), + 0, + export_all_content, + )); + } + } else { + md.push_str("*No arguments*\n"); } } _ => { @@ -529,7 +527,7 @@ mod tests { let tool_call = CallToolRequestParams { meta: None, task: None, - name: "developer__shell".into(), + name: "shell".into(), arguments: Some(object!({ "command": "ls -la", "working_dir": "/home/user" @@ -552,14 +550,15 @@ mod tests { } #[test] - fn test_tool_request_to_markdown_text_editor() { + fn test_tool_request_to_markdown_edit() { let tool_call = CallToolRequestParams { meta: None, task: None, - name: "developer__text_editor".into(), + name: "edit".into(), arguments: Some(object!({ "path": "/path/to/file.txt", - "code_edit": "print('Hello World')" + "before": "Hello", + "after": "World" })), }; let tool_request = ToolRequest { @@ -570,10 +569,11 @@ mod tests { }; let result = tool_request_to_markdown(&tool_request, true); - assert!(result.contains("#### Tool Call: `text_editor`")); + assert!(result.contains("#### Tool Call: `edit`")); + assert!(result.contains("namespace: `developer`")); assert!(result.contains("**path**: `/path/to/file.txt`")); - assert!(result.contains("**code_edit**:")); - assert!(result.contains("print('Hello World')")); + assert!(result.contains("**before**")); + assert!(result.contains("**after**")); } #[test] @@ -702,7 +702,7 @@ mod tests { let tool_call = CallToolRequestParams { meta: None, task: None, - name: "developer__shell".into(), + name: "shell".into(), arguments: Some(object!({ "command": "cat main.py" })), @@ -758,7 +758,7 @@ if __name__ == "__main__": let git_status_call = CallToolRequestParams { meta: None, task: None, - name: "developer__shell".into(), + name: "shell".into(), arguments: Some(object!({ "command": "git status --porcelain" })), @@ -806,7 +806,7 @@ if __name__ == "__main__": let cargo_build_call = CallToolRequestParams { meta: None, task: None, - name: "developer__shell".into(), + name: "shell".into(), arguments: Some(object!({ "command": "cargo build" })), @@ -860,7 +860,7 @@ warning: unused variable `x` let curl_call = CallToolRequestParams { meta: None, task: None, - name: "developer__shell".into(), + name: "shell".into(), arguments: Some(object!({ "command": "curl -s https://api.github.com/repos/microsoft/vscode/releases/latest" })), @@ -912,15 +912,14 @@ warning: unused variable `x` } #[test] - fn test_text_editor_tool_with_code_creation() { + fn test_write_tool_with_code_creation() { let editor_call = CallToolRequestParams { meta: None, task: None, - name: "developer__text_editor".into(), + name: "write".into(), arguments: Some(object!({ - "command": "write", "path": "/tmp/fibonacci.js", - "file_text": "function fibonacci(n) {\n if (n <= 1) return n;\n return fibonacci(n - 1) + fibonacci(n - 2);\n}\n\nconsole.log(fibonacci(10));" + "content": "function fibonacci(n) {\n if (n <= 1) return n;\n return fibonacci(n - 1) + fibonacci(n - 2);\n}\n\nconsole.log(fibonacci(10));" })), }; let tool_request = ToolRequest { @@ -951,10 +950,10 @@ warning: unused variable `x` let request_result = tool_request_to_markdown(&tool_request, true); let response_result = tool_response_to_markdown(&tool_response, true); - // Check request formatting - should format code in file_text properly - assert!(request_result.contains("#### Tool Call: `text_editor`")); + // Check request formatting - should format code in content properly + assert!(request_result.contains("#### Tool Call: `write`")); assert!(request_result.contains("**path**: `/tmp/fibonacci.js`")); - assert!(request_result.contains("**file_text**:")); + assert!(request_result.contains("**content**:")); assert!(request_result.contains("function fibonacci(n)")); assert!(request_result.contains("return fibonacci(n - 1)")); @@ -962,72 +961,12 @@ warning: unused variable `x` assert!(response_result.contains("File created successfully")); } - #[test] - fn test_text_editor_tool_view_code() { - let editor_call = CallToolRequestParams { - meta: None, - task: None, - name: "developer__text_editor".into(), - arguments: Some(object!({ - "command": "view", - "path": "/src/utils.py" - })), - }; - let _tool_request = ToolRequest { - id: "editor-view".to_string(), - tool_call: Ok(editor_call), - metadata: None, - tool_meta: None, - }; - - let python_code = r#"import os -import json -from typing import Dict, List, Optional - -def load_config(config_path: str) -> Dict: - """Load configuration from JSON file.""" - if not os.path.exists(config_path): - raise FileNotFoundError(f"Config file not found: {config_path}") - - with open(config_path, 'r') as f: - return json.load(f) - -def process_data(data: List[Dict]) -> List[Dict]: - """Process a list of data dictionaries.""" - return [item for item in data if item.get('active', False)]"#; - - let text_content = TextContent { - raw: RawTextContent { - text: python_code.to_string(), - meta: None, - }, - annotations: None, - }; - let tool_response = ToolResponse { - metadata: None, - id: "editor-view".to_string(), - tool_result: Ok(rmcp::model::CallToolResult { - content: vec![Content::text(text_content.raw.text)], - structured_content: None, - is_error: Some(false), - meta: None, - }), - }; - - let response_result = tool_response_to_markdown(&tool_response, true); - - // Text content is output as plain text - assert!(response_result.contains("import os")); - assert!(response_result.contains("def load_config")); - assert!(response_result.contains("typing import Dict")); - } - #[test] fn test_shell_tool_with_error_output() { let error_call = CallToolRequestParams { meta: None, task: None, - name: "developer__shell".into(), + name: "shell".into(), arguments: Some(object!({ "command": "python nonexistent_script.py" })), @@ -1072,7 +1011,7 @@ Command failed with exit code 2"#; let script_call = CallToolRequestParams { meta: None, task: None, - name: "developer__shell".into(), + name: "shell".into(), arguments: Some(object!({ "command": "python -c \"import sys; print(f'Python {sys.version}'); [print(f'{i}^2 = {i**2}') for i in range(1, 6)]\"" })), @@ -1128,7 +1067,7 @@ Command failed with exit code 2"#; let multi_call = CallToolRequestParams { meta: None, task: None, - name: "developer__shell".into(), + name: "shell".into(), arguments: Some(object!({ "command": "cd /tmp && ls -la | head -5 && pwd" })), @@ -1182,7 +1121,7 @@ drwx------ 3 user staff 96 Dec 6 16:20 com.apple.launchd.abc let grep_call = CallToolRequestParams { meta: None, task: None, - name: "developer__shell".into(), + name: "shell".into(), arguments: Some(object!({ "command": "rg 'async fn' --type rust -n" })), @@ -1235,7 +1174,7 @@ src/middleware.rs:12:async fn auth_middleware(req: Request, next: Next) -> Resul let tool_call = CallToolRequestParams { meta: None, task: None, - name: "developer__shell".into(), + name: "shell".into(), arguments: Some(object!({ "command": "echo '{\"test\": \"json\"}'" })), @@ -1279,7 +1218,7 @@ src/middleware.rs:12:async fn auth_middleware(req: Request, next: Next) -> Resul let npm_call = CallToolRequestParams { meta: None, task: None, - name: "developer__shell".into(), + name: "shell".into(), arguments: Some(object!({ "command": "npm install express typescript @types/node --save-dev" })), diff --git a/crates/goose-cli/src/session/output.rs b/crates/goose-cli/src/session/output.rs index c458744b092f..c4801c4182ca 100644 --- a/crates/goose-cli/src/session/output.rs +++ b/crates/goose-cli/src/session/output.rs @@ -485,8 +485,8 @@ fn render_thinking_streaming( fn render_tool_request(req: &ToolRequest, theme: Theme, debug: bool) { match &req.tool_call { Ok(call) => match call.name.to_string().as_str() { - "developer__text_editor" => render_text_editor_request(call, debug), - "developer__shell" => render_shell_request(call, debug), + name if is_shell_tool_name(name) => render_shell_request(call, debug), + name if is_file_tool_name(name) => render_text_editor_request(call, debug), "execute" | "execute_code" => render_execute_code_request(call, debug), "delegate" => render_delegate_request(call, debug), "subagent" => render_delegate_request(call, debug), @@ -534,6 +534,14 @@ fn render_tool_response(resp: &ToolResponse, theme: Theme, debug: bool) { } } +fn is_shell_tool_name(name: &str) -> bool { + matches!(name, "shell") +} + +fn is_file_tool_name(name: &str) -> bool { + matches!(name, "write" | "edit") +} + pub fn render_error(message: &str) { println!("\n {} {}\n", style("error:").red().bold(), message); } diff --git a/crates/goose-cli/static/script.js b/crates/goose-cli/static/script.js index cab2afc4b2b2..ba0504ebdd21 100644 --- a/crates/goose-cli/static/script.js +++ b/crates/goose-cli/static/script.js @@ -269,11 +269,18 @@ function handleToolRequest(data) { const contentDiv = document.createElement('div'); contentDiv.className = 'tool-content'; + const isShellTool = data.tool_name === 'shell'; + const isDeveloperFileTool = [ + 'read', + 'write', + 'edit' + ].includes(data.tool_name); + // Format the arguments - if (data.tool_name === 'developer__shell' && data.arguments.command) { + if (isShellTool && data.arguments.command) { contentDiv.innerHTML = `
${escapeHtml(data.arguments.command)}
`; - } else if (data.tool_name === 'developer__text_editor') { - const action = data.arguments.command || 'unknown'; + } else if (isDeveloperFileTool) { + const action = data.arguments.command || data.tool_name; const path = data.arguments.path || 'unknown'; contentDiv.innerHTML = `
action: ${action}
`; contentDiv.innerHTML += `
path: ${escapeHtml(path)}
`; diff --git a/crates/goose-mcp/src/developer/analyze/cache.rs b/crates/goose-mcp/src/developer/analyze/cache.rs deleted file mode 100644 index 3c855ce4f825..000000000000 --- a/crates/goose-mcp/src/developer/analyze/cache.rs +++ /dev/null @@ -1,100 +0,0 @@ -use lru::LruCache; -use std::num::NonZeroUsize; -use std::path::PathBuf; -use std::sync::{Arc, Mutex}; -use std::time::SystemTime; - -use super::lock_or_recover; -use crate::developer::analyze::types::{AnalysisMode, AnalysisResult}; - -#[derive(Clone)] -pub struct AnalysisCache { - cache: Arc>>>, - #[allow(dead_code)] - max_size: usize, -} - -#[derive(Hash, Eq, PartialEq, Debug, Clone)] -struct CacheKey { - path: PathBuf, - modified: SystemTime, - mode: AnalysisMode, -} - -impl AnalysisCache { - pub fn new(max_size: usize) -> Self { - tracing::info!("Initializing analysis cache with size {}", max_size); - - let size = NonZeroUsize::new(max_size).unwrap_or_else(|| { - tracing::warn!("Invalid cache size {}, using default 100", max_size); - NonZeroUsize::new(100).unwrap() - }); - - Self { - cache: Arc::new(Mutex::new(LruCache::new(size))), - max_size, - } - } - - pub fn get( - &self, - path: &PathBuf, - modified: SystemTime, - mode: &AnalysisMode, - ) -> Option { - let mut cache = lock_or_recover(&self.cache, |c| c.clear()); - let key = CacheKey { - path: path.clone(), - modified, - mode: *mode, - }; - - if let Some(result) = cache.get(&key) { - tracing::trace!("Cache hit for {:?} in {:?} mode", path, mode); - Some((**result).clone()) - } else { - tracing::trace!("Cache miss for {:?} in {:?} mode", path, mode); - None - } - } - - pub fn put( - &self, - path: PathBuf, - modified: SystemTime, - mode: &AnalysisMode, - result: AnalysisResult, - ) { - let mut cache = lock_or_recover(&self.cache, |c| c.clear()); - let key = CacheKey { - path: path.clone(), - modified, - mode: *mode, - }; - - tracing::trace!("Caching result for {:?} in {:?} mode", path, mode); - cache.put(key, Arc::new(result)); - } - - pub fn clear(&self) { - let mut cache = lock_or_recover(&self.cache, |c| c.clear()); - cache.clear(); - tracing::debug!("Cache cleared"); - } - - pub fn len(&self) -> usize { - let cache = lock_or_recover(&self.cache, |c| c.clear()); - cache.len() - } - - pub fn is_empty(&self) -> bool { - let cache = lock_or_recover(&self.cache, |c| c.clear()); - cache.is_empty() - } -} - -impl Default for AnalysisCache { - fn default() -> Self { - Self::new(100) - } -} diff --git a/crates/goose-mcp/src/developer/analyze/formatter.rs b/crates/goose-mcp/src/developer/analyze/formatter.rs deleted file mode 100644 index 90529a06c702..000000000000 --- a/crates/goose-mcp/src/developer/analyze/formatter.rs +++ /dev/null @@ -1,753 +0,0 @@ -use crate::developer::analyze::types::{ - AnalysisMode, AnalysisResult, CallChain, EntryType, FocusedAnalysisData, -}; -use crate::developer::lang; -use rmcp::model::{Content, Role}; -use std::collections::{HashMap, HashSet}; -use std::path::{Path, PathBuf}; - -fn safe_truncate(s: &str, max_chars: usize) -> String { - if s.chars().count() <= max_chars { - s.to_string() - } else { - let truncated: String = s.chars().take(max_chars.saturating_sub(3)).collect(); - format!("{}...", truncated) - } -} - -pub struct Formatter; - -impl Formatter { - pub fn format_results(output: String) -> Vec { - vec![ - Content::text(output.clone()).with_audience(vec![Role::Assistant]), - Content::text(output) - .with_audience(vec![Role::User]) - .with_priority(0.0), - ] - } - - /// Format analysis result based on mode - pub fn format_analysis_result( - path: &Path, - result: &AnalysisResult, - mode: &AnalysisMode, - ) -> String { - tracing::debug!("Formatting result for {:?} in {:?} mode", path, mode); - - match mode { - AnalysisMode::Structure => Self::format_structure_overview(path, result), - AnalysisMode::Semantic => Self::format_semantic_result(path, result), - AnalysisMode::Focused => { - // Focused mode is handled separately - tracing::warn!("format_analysis_result called with Focused mode"); - String::new() - } - } - } - - /// Format structure overview (compact format) - pub fn format_structure_overview(path: &Path, result: &AnalysisResult) -> String { - let mut output = String::new(); - - // Format as: path [LOC, FUNCTIONS, CLASSES] - output.push_str(&format!("{} [{}L", path.display(), result.line_count)); - - if result.function_count > 0 { - output.push_str(&format!(", {}F", result.function_count)); - } - - if result.class_count > 0 { - output.push_str(&format!(", {}C", result.class_count)); - } - - output.push(']'); - - // Add FLAGS if any - if let Some(main_line) = result.main_line { - output.push_str(&format!(" main:{}", main_line)); - } - - output.push('\n'); - output - } - - /// Format semantic analysis result (dense matrix format) - pub fn format_semantic_result(path: &Path, result: &AnalysisResult) -> String { - let mut output = format!( - "FILE: {} [{}L, {}F, {}C]\n\n", - path.display(), - result.line_count, - result.function_count, - result.class_count - ); - - // Classes on single/multiple lines with colon-separated line numbers - if !result.classes.is_empty() { - output.push_str("C: "); - let class_strs: Vec = result - .classes - .iter() - .map(|c| format!("{}:{}", c.name, c.line)) - .collect(); - output.push_str(&class_strs.join(" ")); - output.push_str("\n\n"); - } - - // Functions with call counts where significant - if !result.functions.is_empty() { - output.push_str("F: "); - - // Count how many times each function is called - let mut call_counts: HashMap = HashMap::new(); - for call in &result.calls { - *call_counts.entry(call.callee_name.clone()).or_insert(0) += 1; - } - - let func_strs: Vec = result - .functions - .iter() - .map(|f| { - let count = call_counts.get(&f.name).unwrap_or(&0); - if *count > 3 { - format!("{}:{}•{}", f.name, f.line, count) - } else { - format!("{}:{}", f.name, f.line) - } - }) - .collect(); - - // Format functions, wrapping at reasonable line length - let mut line_len = 3; // "F: " - for (i, func_str) in func_strs.iter().enumerate() { - if i > 0 && line_len + func_str.len() + 1 > 100 { - output.push_str("\n "); - line_len = 3; - } - if i > 0 { - output.push(' '); - line_len += 1; - } - output.push_str(func_str); - line_len += func_str.len(); - } - output.push_str("\n\n"); - } - - // Condensed imports - if !result.imports.is_empty() { - output.push_str("I: "); - - // Group imports by module/package - let mut grouped_imports: HashMap> = HashMap::new(); - for import in &result.imports { - // Simple heuristic: first word/module is the group - let group = if import.starts_with("use ") { - import.split("::").next().unwrap_or("use").to_string() - } else if import.starts_with("import ") { - import - .split_whitespace() - .nth(1) - .unwrap_or("import") - .to_string() - } else if import.starts_with("from ") { - import - .split_whitespace() - .nth(1) - .unwrap_or("from") - .to_string() - } else { - import.split_whitespace().next().unwrap_or("").to_string() - }; - grouped_imports - .entry(group) - .or_default() - .push(import.clone()); - } - - // Show condensed import summary - let import_summary: Vec = grouped_imports - .iter() - .map(|(group, imports)| { - if imports.len() > 1 { - format!("{}({})", group, imports.len()) - } else { - safe_truncate(&imports[0], 40) - } - }) - .collect(); - - output.push_str(&import_summary.join("; ")); - output.push('\n'); - } - - // References (type tracking) - only show if present - if !result.references.is_empty() { - Self::append_references(&mut output, result); - } - - output - } - - /// Append reference tracking information (method-to-type associations, type usage) - fn append_references(output: &mut String, result: &AnalysisResult) { - use crate::developer::analyze::types::ReferenceType; - - // Group references by type - let mut method_defs = Vec::new(); - let mut type_inst = Vec::new(); - let mut field_types = Vec::new(); - let mut var_types = Vec::new(); - let mut param_types = Vec::new(); - - for ref_info in &result.references { - match ref_info.ref_type { - ReferenceType::MethodDefinition => method_defs.push(ref_info), - ReferenceType::TypeInstantiation => type_inst.push(ref_info), - ReferenceType::FieldType => field_types.push(ref_info), - ReferenceType::VariableType => var_types.push(ref_info), - ReferenceType::ParameterType => param_types.push(ref_info), - ReferenceType::Call | ReferenceType::Definition | ReferenceType::Import => {} - } - } - - // Only show section if we have non-call references - if method_defs.is_empty() - && type_inst.is_empty() - && field_types.is_empty() - && var_types.is_empty() - && param_types.is_empty() - { - return; - } - - output.push_str("\nR: "); - - let mut sections = Vec::new(); - - // Method definitions (methods associated with types) - if !method_defs.is_empty() { - let mut method_strs: Vec = method_defs - .iter() - .map(|r| { - if let Some(type_name) = &r.associated_type { - format!("{}({})", r.symbol, type_name) - } else { - r.symbol.clone() - } - }) - .collect(); - method_strs.sort(); - method_strs.dedup(); - sections.push(format!("methods[{}]", method_strs.join(" "))); - } - - // Type instantiations (struct literals) - if !type_inst.is_empty() { - let mut type_names: Vec = type_inst.iter().map(|r| r.symbol.clone()).collect(); - type_names.sort(); - type_names.dedup(); - sections.push(format!("types[{}]", type_names.join(" "))); - } - - // Field types (only show unique types, not all occurrences) - if !field_types.is_empty() { - let mut field_type_names: Vec = - field_types.iter().map(|r| r.symbol.clone()).collect(); - field_type_names.sort(); - field_type_names.dedup(); - sections.push(format!("fields[{}]", field_type_names.join(" "))); - } - - // Variable types (only show unique types) - if !var_types.is_empty() { - let mut var_type_names: Vec = - var_types.iter().map(|r| r.symbol.clone()).collect(); - var_type_names.sort(); - var_type_names.dedup(); - sections.push(format!("vars[{}]", var_type_names.join(" "))); - } - - // Parameter types (only show unique types) - if !param_types.is_empty() { - let mut param_type_names: Vec = - param_types.iter().map(|r| r.symbol.clone()).collect(); - param_type_names.sort(); - param_type_names.dedup(); - sections.push(format!("params[{}]", param_type_names.join(" "))); - } - - output.push_str(§ions.join("; ")); - output.push('\n'); - } - - /// Format directory structure with summary - pub fn format_directory_structure( - base_path: &Path, - results: &[(PathBuf, EntryType)], - max_depth: u32, - ) -> String { - let mut output = String::new(); - - // Add summary section - Self::append_summary(&mut output, results, max_depth); - - output.push_str("\nPATH [LOC, FUNCTIONS, CLASSES] \n"); - - // Add tree structure - Self::append_tree_structure(&mut output, base_path, results); - - output - } - - /// Append summary section with statistics - fn append_summary(output: &mut String, results: &[(PathBuf, EntryType)], max_depth: u32) { - // Calculate totals (only from files) - let files: Vec<&AnalysisResult> = results - .iter() - .filter_map(|(_, entry)| match entry { - EntryType::File(result) => Some(result), - _ => None, - }) - .collect(); - - let total_files = files.len(); - let total_lines: usize = files.iter().map(|r| r.line_count).sum(); - let total_functions: usize = files.iter().map(|r| r.function_count).sum(); - let total_classes: usize = files.iter().map(|r| r.class_count).sum(); - - // Format summary with depth indicator - output.push_str("SUMMARY:\n"); - if max_depth == 0 { - output.push_str(&format!( - "Shown: {} files, {}L, {}F, {}C (unlimited depth)\n", - total_files, total_lines, total_functions, total_classes - )); - } else { - output.push_str(&format!( - "Shown: {} files, {}L, {}F, {}C (max_depth={})\n", - total_files, total_lines, total_functions, total_classes, max_depth - )); - } - - // Add language distribution - Self::append_language_stats(output, results, total_lines); - } - - /// Append language statistics - fn append_language_stats( - output: &mut String, - results: &[(PathBuf, EntryType)], - total_lines: usize, - ) { - // Calculate language distribution - let mut language_lines: HashMap = HashMap::new(); - for (path, entry) in results { - if let EntryType::File(result) = entry { - let lang = lang::get_language_identifier(path); - if !lang.is_empty() && result.line_count > 0 { - *language_lines.entry(lang.to_string()).or_insert(0) += result.line_count; - } - } - } - - // Format language percentages - if !language_lines.is_empty() && total_lines > 0 { - let mut languages: Vec<_> = language_lines.iter().collect(); - languages.sort_by(|a, b| b.1.cmp(a.1)); // Sort by lines descending - - let lang_str: Vec = languages - .iter() - .map(|(lang, lines)| { - let percentage = (**lines as f64 / total_lines as f64 * 100.0) as u32; - format!("{} ({}%)", lang, percentage) - }) - .collect(); - - output.push_str(&format!("Languages: {}\n", lang_str.join(", "))); - } - } - - /// Append tree structure for directory contents - fn append_tree_structure( - output: &mut String, - base_path: &Path, - results: &[(PathBuf, EntryType)], - ) { - // Sort results by path for consistent output - let mut sorted_results = results.to_vec(); - sorted_results.sort_by(|a, b| a.0.cmp(&b.0)); - - // Track which directories we've already printed to avoid duplicates - let mut printed_dirs = HashSet::new(); - - // Format each entry with tree-style indentation - for (path, entry) in sorted_results { - Self::format_tree_entry(output, base_path, &path, &entry, &mut printed_dirs); - } - } - - /// Format a single tree entry - fn format_tree_entry( - output: &mut String, - base_path: &Path, - path: &Path, - entry: &EntryType, - printed_dirs: &mut HashSet, - ) { - // Make path relative to base_path - let relative_path = path.strip_prefix(base_path).unwrap_or(path); - - // Get path components for determining structure - let components: Vec<_> = relative_path.components().collect(); - if components.is_empty() { - return; - } - - // Print parent directories if not already printed - for i in 0..components.len().saturating_sub(1) { - let parent_path: PathBuf = components[..=i].iter().collect(); - if !printed_dirs.contains(&parent_path) { - let indent = " ".repeat(i); - let dir_name = components[i].as_os_str().to_string_lossy(); - output.push_str(&format!("{}{}/\n", indent, dir_name)); - printed_dirs.insert(parent_path); - } - } - - // Determine indentation level for this entry - let indent_level = components.len().saturating_sub(1); - let indent = " ".repeat(indent_level); - - // Get the file/directory name (last component) - let name = components - .last() - .map(|c| c.as_os_str().to_string_lossy().to_string()) - .unwrap_or_else(|| relative_path.display().to_string()); - - // Format based on entry type - Self::format_entry_line( - output, - &indent, - &name, - entry, - base_path, - relative_path, - printed_dirs, - ); - } - - /// Format the line for a specific entry type - fn format_entry_line( - output: &mut String, - indent: &str, - name: &str, - entry: &EntryType, - base_path: &Path, - relative_path: &Path, - printed_dirs: &mut HashSet, - ) { - match entry { - EntryType::File(result) => { - output.push_str(&format!("{}{} [{}L", indent, name, result.line_count)); - if result.function_count > 0 { - output.push_str(&format!(", {}F", result.function_count)); - } - if result.class_count > 0 { - output.push_str(&format!(", {}C", result.class_count)); - } - output.push(']'); - if let Some(main_line) = result.main_line { - output.push_str(&format!(" main:{}", main_line)); - } - output.push('\n'); - } - EntryType::Directory => { - // Only print if not already printed as a parent - if !printed_dirs.contains(relative_path) { - output.push_str(&format!("{}{}/\n", indent, name)); - printed_dirs.insert(relative_path.to_path_buf()); - } - } - EntryType::SymlinkDir(target) | EntryType::SymlinkFile(target) => { - let is_dir = matches!(entry, EntryType::SymlinkDir(_)); - let target_display = if target.is_relative() { - target.display().to_string() - } else if let Ok(rel) = target.strip_prefix(base_path) { - rel.display().to_string() - } else { - target.display().to_string() - }; - let suffix = if is_dir { "/" } else { "" }; - output.push_str(&format!( - "{}{}{} -> {}\n", - indent, name, suffix, target_display - )); - } - } - } - - /// Format focused analysis output with call chains - pub fn format_focused_output(focus_data: &FocusedAnalysisData) -> String { - let mut output = format!("FOCUSED ANALYSIS: {}\n\n", focus_data.focus_symbol); - - // Build file alias mapping - let (file_map, sorted_files) = Self::build_file_aliases( - focus_data.definitions, - focus_data.incoming_chains, - focus_data.outgoing_chains, - ); - - // Section 1: Definitions - Self::append_definitions( - &mut output, - focus_data.definitions, - &file_map, - focus_data.focus_symbol, - ); - - // Section 2: Incoming Call Chains - Self::append_call_chains( - &mut output, - focus_data.incoming_chains, - &file_map, - focus_data.follow_depth, - true, - ); - - // Section 3: Outgoing Call Chains - Self::append_call_chains( - &mut output, - focus_data.outgoing_chains, - &file_map, - focus_data.follow_depth, - false, - ); - - // Section 4: Summary Statistics - Self::append_statistics( - &mut output, - focus_data.files_analyzed, - focus_data.definitions, - focus_data.incoming_chains, - focus_data.outgoing_chains, - focus_data.follow_depth, - ); - - // Section 5: File Legend - Self::append_file_legend( - &mut output, - &file_map, - &sorted_files, - focus_data.definitions, - focus_data.incoming_chains, - focus_data.outgoing_chains, - ); - - if focus_data.definitions.is_empty() - && focus_data.incoming_chains.is_empty() - && focus_data.outgoing_chains.is_empty() - { - output = format!( - "Symbol '{}' not found in any analyzed files.\n", - focus_data.focus_symbol - ); - } - - output - } - - /// Build file alias mapping for focused output - fn build_file_aliases( - definitions: &[(PathBuf, usize)], - incoming_chains: &[CallChain], - outgoing_chains: &[CallChain], - ) -> (HashMap, Vec) { - let mut all_files = HashSet::new(); - - for (file, _) in definitions { - all_files.insert(file.clone()); - } - - for chain in incoming_chains.iter().chain(outgoing_chains.iter()) { - for (file, _, _, _) in &chain.path { - all_files.insert(file.clone()); - } - } - - let mut sorted_files: Vec<_> = all_files.into_iter().collect(); - sorted_files.sort(); - - let mut file_map = HashMap::new(); - for (index, file) in sorted_files.iter().enumerate() { - let alias = if sorted_files.len() == 1 { - file.file_name() - .and_then(|n| n.to_str()) - .unwrap_or("unknown") - .to_string() - } else { - format!("F{}", index + 1) - }; - file_map.insert(file.clone(), alias); - } - - (file_map, sorted_files) - } - - /// Append definitions section to output - fn append_definitions( - output: &mut String, - definitions: &[(PathBuf, usize)], - file_map: &HashMap, - focus_symbol: &str, - ) { - if !definitions.is_empty() { - output.push_str("DEFINITIONS:\n"); - for (file, line) in definitions { - let alias = file_map.get(file).cloned().unwrap_or_else(|| { - file.file_name() - .and_then(|n| n.to_str()) - .unwrap_or("unknown") - .to_string() - }); - output.push_str(&format!("{}:{} - {}\n", alias, line, focus_symbol)); - } - output.push('\n'); - } - } - - /// Append call chains section to output - fn append_call_chains( - output: &mut String, - chains: &[CallChain], - file_map: &HashMap, - follow_depth: u32, - is_incoming: bool, - ) { - if !chains.is_empty() { - let chain_type = if is_incoming { "INCOMING" } else { "OUTGOING" }; - output.push_str(&format!( - "{} CALL CHAINS (depth={}):\n", - chain_type, follow_depth - )); - - let mut unique_chains = HashSet::new(); - for chain in chains { - let chain_str = Self::format_chain_path(&chain.path, file_map); - unique_chains.insert(chain_str); - } - - let mut sorted_chains: Vec<_> = unique_chains.into_iter().collect(); - sorted_chains.sort(); - - for chain in sorted_chains { - output.push_str(&format!("{}\n", chain)); - } - output.push('\n'); - } - } - - /// Format a single chain path - fn format_chain_path( - path: &[(PathBuf, usize, String, String)], - file_map: &HashMap, - ) -> String { - path.iter() - .map(|(file, line, from, to)| { - let alias = file_map.get(file).cloned().unwrap_or_else(|| { - file.file_name() - .and_then(|n| n.to_str()) - .unwrap_or("unknown") - .to_string() - }); - format!("{}:{} ({} -> {})", alias, line, from, to) - }) - .collect::>() - .join(" -> ") - } - - /// Append statistics section to output - fn append_statistics( - output: &mut String, - files_analyzed: &[PathBuf], - definitions: &[(PathBuf, usize)], - incoming_chains: &[CallChain], - outgoing_chains: &[CallChain], - follow_depth: u32, - ) { - output.push_str("STATISTICS:\n"); - output.push_str(&format!(" Files analyzed: {}\n", files_analyzed.len())); - output.push_str(&format!(" Definitions found: {}\n", definitions.len())); - output.push_str(&format!(" Incoming chains: {}\n", incoming_chains.len())); - output.push_str(&format!(" Outgoing chains: {}\n", outgoing_chains.len())); - output.push_str(&format!(" Follow depth: {}\n", follow_depth)); - } - - /// Append file legend section to output - fn append_file_legend( - output: &mut String, - file_map: &HashMap, - sorted_files: &[PathBuf], - definitions: &[(PathBuf, usize)], - incoming_chains: &[CallChain], - outgoing_chains: &[CallChain], - ) { - if !file_map.is_empty() - && (sorted_files.len() > 1 - || !incoming_chains.is_empty() - || !outgoing_chains.is_empty() - || !definitions.is_empty()) - { - output.push_str("\nFILES:\n"); - let mut legend_entries: Vec<_> = file_map.iter().collect(); - legend_entries.sort_by_key(|(_, alias)| alias.as_str()); - - for (file_path, alias) in legend_entries { - if sorted_files.len() == 1 - && alias == file_path.file_name().and_then(|n| n.to_str()).unwrap_or("") - { - continue; - } - output.push_str(&format!(" {}: {}\n", alias, file_path.display())); - } - } - } - - /// Filter output by focus symbol - pub fn filter_by_focus(output: &str, focus: &str) -> String { - let mut filtered = String::new(); - let mut include_section = false; - - for line in output.lines() { - if line.starts_with("##") { - include_section = false; - } - - if line.contains(focus) { - include_section = true; - // Include the file header - if let Some(header_line) = output - .lines() - .rev() - .find(|l| l.starts_with("##") && l.get(3..).is_some_and(|s| line.contains(s))) - { - if !filtered.contains(header_line) { - filtered.push_str(header_line); - filtered.push('\n'); - } - } - } - - if include_section || line.starts_with('#') { - filtered.push_str(line); - filtered.push('\n'); - } - } - - if filtered.is_empty() { - format!("No results found for symbol: {}", focus) - } else { - filtered - } - } -} diff --git a/crates/goose-mcp/src/developer/analyze/graph.rs b/crates/goose-mcp/src/developer/analyze/graph.rs deleted file mode 100644 index d87c72fd1d43..000000000000 --- a/crates/goose-mcp/src/developer/analyze/graph.rs +++ /dev/null @@ -1,245 +0,0 @@ -use std::collections::{HashMap, HashSet, VecDeque}; -use std::path::PathBuf; - -use crate::developer::analyze::types::{AnalysisResult, CallChain}; - -/// Sentinel value used to represent type references (instantiation, field types, etc.) -/// as callers in the call graph, since they don't have an actual caller function. -const REFERENCE_CALLER: &str = ""; - -#[derive(Debug, Clone, Default)] -pub struct CallGraph { - callers: HashMap>, - callees: HashMap>, - pub definitions: HashMap>, -} - -impl CallGraph { - pub fn new() -> Self { - Self::default() - } - - pub fn build_from_results(results: &[(PathBuf, AnalysisResult)]) -> Self { - tracing::debug!("Building call graph from {} files", results.len()); - let mut graph = Self::new(); - - for (file_path, result) in results { - // Record definitions - for func in &result.functions { - graph - .definitions - .entry(func.name.clone()) - .or_default() - .push((file_path.clone(), func.line)); - } - - for class in &result.classes { - graph - .definitions - .entry(class.name.clone()) - .or_default() - .push((file_path.clone(), class.line)); - } - - // Record call relationships - for call in &result.calls { - let caller = call - .caller_name - .clone() - .unwrap_or_else(|| "".to_string()); - - // Add to callers map (who calls this function) - graph - .callers - .entry(call.callee_name.clone()) - .or_default() - .push((file_path.clone(), call.line, caller.clone())); - - // Add to callees map (what this function calls) - if caller != "" { - graph.callees.entry(caller).or_default().push(( - file_path.clone(), - call.line, - call.callee_name.clone(), - )); - } - } - - for reference in &result.references { - use crate::developer::analyze::types::ReferenceType; - - match &reference.ref_type { - ReferenceType::MethodDefinition => { - if let Some(type_name) = &reference.associated_type { - tracing::trace!( - "Linking method {} to type {}", - reference.symbol, - type_name - ); - graph.callees.entry(type_name.clone()).or_default().push(( - file_path.clone(), - reference.line, - reference.symbol.clone(), - )); - } - } - ReferenceType::TypeInstantiation - | ReferenceType::FieldType - | ReferenceType::VariableType - | ReferenceType::ParameterType => { - graph - .callers - .entry(reference.symbol.clone()) - .or_default() - .push(( - file_path.clone(), - reference.line, - REFERENCE_CALLER.to_string(), - )); - } - ReferenceType::Definition | ReferenceType::Call | ReferenceType::Import => { - // These are handled elsewhere or not relevant for type tracking - } - } - } - } - - tracing::trace!( - "Graph built: {} definitions, {} caller entries, {} callee entries", - graph.definitions.len(), - graph.callers.len(), - graph.callees.len() - ); - - graph - } - - pub fn find_incoming_chains(&self, symbol: &str, max_depth: u32) -> Vec { - tracing::trace!( - "Finding incoming chains for {} with depth {}", - symbol, - max_depth - ); - - if max_depth == 0 { - return vec![]; - } - - let mut chains = Vec::new(); - let mut visited = HashSet::new(); - let mut queue = VecDeque::new(); - - // Start with direct callers - if let Some(direct_callers) = self.callers.get(symbol) { - for (file, line, caller) in direct_callers { - let initial_path = vec![(file.clone(), *line, caller.clone(), symbol.to_string())]; - - if max_depth == 1 { - chains.push(CallChain { path: initial_path }); - } else { - queue.push_back((caller.clone(), initial_path, 1)); - } - } - } - - // BFS to find deeper chains - while let Some((current_symbol, path, depth)) = queue.pop_front() { - if depth >= max_depth { - chains.push(CallChain { path }); - continue; - } - - // Avoid cycles - if visited.contains(¤t_symbol) { - chains.push(CallChain { path }); // Still record the path we found - continue; - } - visited.insert(current_symbol.clone()); - - // Find who calls the current symbol - if let Some(callers) = self.callers.get(¤t_symbol) { - for (file, line, caller) in callers { - let mut new_path = - vec![(file.clone(), *line, caller.clone(), current_symbol.clone())]; - new_path.extend(path.clone()); - - if depth + 1 >= max_depth { - chains.push(CallChain { path: new_path }); - } else { - queue.push_back((caller.clone(), new_path, depth + 1)); - } - } - } else { - // No more callers, this is a chain end - chains.push(CallChain { path }); - } - } - - tracing::trace!("Found {} incoming chains", chains.len()); - chains - } - - pub fn find_outgoing_chains(&self, symbol: &str, max_depth: u32) -> Vec { - tracing::trace!( - "Finding outgoing chains for {} with depth {}", - symbol, - max_depth - ); - - if max_depth == 0 { - return vec![]; - } - - let mut chains = Vec::new(); - let mut visited = HashSet::new(); - let mut queue = VecDeque::new(); - - // Start with what this symbol calls - if let Some(direct_callees) = self.callees.get(symbol) { - for (file, line, callee) in direct_callees { - let initial_path = vec![(file.clone(), *line, symbol.to_string(), callee.clone())]; - - if max_depth == 1 { - chains.push(CallChain { path: initial_path }); - } else { - queue.push_back((callee.clone(), initial_path, 1)); - } - } - } - - // BFS to find deeper chains - while let Some((current_symbol, path, depth)) = queue.pop_front() { - if depth >= max_depth { - chains.push(CallChain { path }); - continue; - } - - // Avoid cycles - if visited.contains(¤t_symbol) { - chains.push(CallChain { path }); - continue; - } - visited.insert(current_symbol.clone()); - - // Find what the current symbol calls - if let Some(callees) = self.callees.get(¤t_symbol) { - for (file, line, callee) in callees { - let mut new_path = path.clone(); - new_path.push((file.clone(), *line, current_symbol.clone(), callee.clone())); - - if depth + 1 >= max_depth { - chains.push(CallChain { path: new_path }); - } else { - queue.push_back((callee.clone(), new_path, depth + 1)); - } - } - } else { - // No more callees, this is a chain end - chains.push(CallChain { path }); - } - } - - tracing::trace!("Found {} outgoing chains", chains.len()); - chains - } -} diff --git a/crates/goose-mcp/src/developer/analyze/languages/go.rs b/crates/goose-mcp/src/developer/analyze/languages/go.rs deleted file mode 100644 index cc6b8c545bc2..000000000000 --- a/crates/goose-mcp/src/developer/analyze/languages/go.rs +++ /dev/null @@ -1,98 +0,0 @@ -/// Tree-sitter query for extracting Go code elements -pub const ELEMENT_QUERY: &str = r#" - (function_declaration name: (identifier) @func) - (method_declaration name: (field_identifier) @func) - (type_declaration (type_spec name: (type_identifier) @struct)) - (const_declaration (const_spec name: (identifier) @const)) - (import_declaration) @import -"#; - -/// Tree-sitter query for extracting Go function calls and identifier references -pub const CALL_QUERY: &str = r#" - ; Function calls - (call_expression - function: (identifier) @function.call) - - ; Method calls - (call_expression - function: (selector_expression - field: (field_identifier) @method.call)) - - ; Identifier references in various expression contexts - ; This captures constants/variables used in arguments, comparisons, returns, assignments, etc. - (argument_list (identifier) @identifier.reference) - (binary_expression left: (identifier) @identifier.reference) - (binary_expression right: (identifier) @identifier.reference) - (unary_expression operand: (identifier) @identifier.reference) - (return_statement (expression_list (identifier) @identifier.reference)) - (assignment_statement right: (expression_list (identifier) @identifier.reference)) -"#; - -/// Tree-sitter query for extracting Go struct references and usage patterns -pub const REFERENCE_QUERY: &str = r#" - ; Method receivers - pointer type - (method_declaration - receiver: (parameter_list - (parameter_declaration - type: (pointer_type (type_identifier) @method.receiver)))) - - ; Method receivers - value type - (method_declaration - receiver: (parameter_list - (parameter_declaration - type: (type_identifier) @method.receiver))) - - ; Struct literals - simple - (composite_literal - type: (type_identifier) @struct.literal) - - ; Struct literals - qualified (package.Type) - (composite_literal - type: (qualified_type - name: (type_identifier) @struct.literal)) - - ; Field declarations in structs - simple type - (field_declaration - type: (type_identifier) @field.type) - - ; Field declarations - pointer type - (field_declaration - type: (pointer_type - (type_identifier) @field.type)) - - ; Field declarations - qualified type (package.Type) - (field_declaration - type: (qualified_type - name: (type_identifier) @field.type)) - - ; Field declarations - pointer to qualified type - (field_declaration - type: (pointer_type - (qualified_type - name: (type_identifier) @field.type))) -"#; - -/// Find the method name for a method receiver node in Go -/// -/// This walks up the tree to find the method_declaration parent and extracts -/// the method name, used for associating methods with their receiver types. -pub fn find_method_for_receiver( - receiver_node: &tree_sitter::Node, - source: &str, - _ast_recursion_limit: Option, -) -> Option { - let mut current = *receiver_node; - while let Some(parent) = current.parent() { - if parent.kind() == "method_declaration" { - for i in 0..parent.child_count() as u32 { - if let Some(child) = parent.child(i) { - if child.kind() == "field_identifier" { - return source.get(child.byte_range()).map(|s| s.to_string()); - } - } - } - } - current = parent; - } - None -} diff --git a/crates/goose-mcp/src/developer/analyze/languages/java.rs b/crates/goose-mcp/src/developer/analyze/languages/java.rs deleted file mode 100644 index 11e616dc2df1..000000000000 --- a/crates/goose-mcp/src/developer/analyze/languages/java.rs +++ /dev/null @@ -1,17 +0,0 @@ -/// Tree-sitter query for extracting Java code elements -pub const ELEMENT_QUERY: &str = r#" - (method_declaration name: (identifier) @func) - (class_declaration name: (identifier) @class) - (import_declaration) @import -"#; - -/// Tree-sitter query for extracting Java function calls -pub const CALL_QUERY: &str = r#" - ; Method invocations - (method_invocation - name: (identifier) @method.call) - - ; Constructor calls - (object_creation_expression - type: (type_identifier) @constructor.call) -"#; diff --git a/crates/goose-mcp/src/developer/analyze/languages/javascript.rs b/crates/goose-mcp/src/developer/analyze/languages/javascript.rs deleted file mode 100644 index 48d923dca2ef..000000000000 --- a/crates/goose-mcp/src/developer/analyze/languages/javascript.rs +++ /dev/null @@ -1,22 +0,0 @@ -/// Tree-sitter query for extracting JavaScript/TypeScript code elements -pub const ELEMENT_QUERY: &str = r#" - (function_declaration name: (identifier) @func) - (class_declaration name: (identifier) @class) - (import_statement) @import -"#; - -/// Tree-sitter query for extracting JavaScript/TypeScript function calls -pub const CALL_QUERY: &str = r#" - ; Function calls - (call_expression - function: (identifier) @function.call) - - ; Method calls - (call_expression - function: (member_expression - property: (property_identifier) @method.call)) - - ; Constructor calls - (new_expression - constructor: (identifier) @constructor.call) -"#; diff --git a/crates/goose-mcp/src/developer/analyze/languages/kotlin.rs b/crates/goose-mcp/src/developer/analyze/languages/kotlin.rs deleted file mode 100644 index e031efdc1f9e..000000000000 --- a/crates/goose-mcp/src/developer/analyze/languages/kotlin.rs +++ /dev/null @@ -1,26 +0,0 @@ -/// Tree-sitter query for extracting Kotlin code elements -pub const ELEMENT_QUERY: &str = r#" - ; Functions - (function_declaration name: (identifier) @func) - - ; Classes - (class_declaration name: (identifier) @class) - - ; Objects (singleton classes) - (object_declaration name: (identifier) @class) - - ; Imports - (import) @import -"#; - -/// Tree-sitter query for extracting Kotlin function calls -pub const CALL_QUERY: &str = r#" - ; Simple function calls - (call_expression - (identifier) @function.call) - - ; Method calls with navigation (obj.method()) - (call_expression - (navigation_expression - (identifier) @method.call)) -"#; diff --git a/crates/goose-mcp/src/developer/analyze/languages/mod.rs b/crates/goose-mcp/src/developer/analyze/languages/mod.rs deleted file mode 100644 index c9bae7bc5a78..000000000000 --- a/crates/goose-mcp/src/developer/analyze/languages/mod.rs +++ /dev/null @@ -1,168 +0,0 @@ -//! Language-specific analysis implementations -//! -//! This module contains language-specific parsing logic and tree-sitter queries -//! for the analyze tool. Each language has its own submodule with query definitions -//! and optional helper functions. -//! -//! ## Adding a New Language -//! -//! To add support for a new language: -//! -//! 1. Create a new file `languages/yourlang.rs` -//! 2. Define `ELEMENT_QUERY` and `CALL_QUERY` constants -//! 3. Optionally define `REFERENCE_QUERY` for advanced type tracking -//! 4. Add `pub mod yourlang;` below -//! 5. Add language configuration to registry in `get_language_info()` -//! -//! ## Optional Features -//! -//! Languages can opt into additional features by implementing: -//! -//! - Reference tracking: Define `REFERENCE_QUERY` to track type instantiation, -//! field types, and method-to-type associations (see Go and Ruby) -//! - Custom function naming: Implement `extract_function_name_for_kind()` for -//! special cases like Swift's init/deinit or Rust's impl blocks -//! - Method receiver lookup: Implement `find_method_for_receiver()` to associate -//! methods with their containing types (see Go and Ruby) - -pub mod go; -pub mod java; -pub mod javascript; -pub mod kotlin; -pub mod python; -pub mod ruby; -pub mod rust; -pub mod swift; - -/// Handler for extracting function names from special node kinds -type ExtractFunctionNameHandler = fn(&tree_sitter::Node, &str, &str) -> Option; - -/// Handler for finding method names from receiver nodes -/// Takes: (receiver_node, source, ast_recursion_limit) -type FindMethodForReceiverHandler = fn(&tree_sitter::Node, &str, Option) -> Option; - -/// Handler for finding the receiver type from a receiver node -/// Takes: (receiver_node, source) -type FindReceiverTypeHandler = fn(&tree_sitter::Node, &str) -> Option; - -/// Language configuration containing all language-specific information -/// -/// This struct serves as a single source of truth for language support. -/// All language-specific queries and handlers are defined here. -#[derive(Copy, Clone)] -pub struct LanguageInfo { - /// Tree-sitter query for extracting code elements (functions, classes, imports) - pub element_query: &'static str, - /// Tree-sitter query for extracting function calls - pub call_query: &'static str, - /// Tree-sitter query for extracting type references (optional) - pub reference_query: &'static str, - /// Node kinds that represent function-like constructs - pub function_node_kinds: &'static [&'static str], - /// Node kinds that represent function name identifiers - pub function_name_kinds: &'static [&'static str], - /// Optional handler for language-specific function name extraction - pub extract_function_name_handler: Option, - /// Optional handler for finding method names from receiver nodes - pub find_method_for_receiver_handler: Option, - /// Optional handler for finding receiver type from receiver nodes - pub find_receiver_type_handler: Option, -} - -/// Get language configuration for a given language -/// -/// Returns `Some(LanguageInfo)` if the language is supported, `None` otherwise. -pub fn get_language_info(language: &str) -> Option { - match language { - "python" => Some(LanguageInfo { - element_query: python::ELEMENT_QUERY, - call_query: python::CALL_QUERY, - reference_query: "", - function_node_kinds: &["function_definition"], - function_name_kinds: &["identifier", "field_identifier", "property_identifier"], - extract_function_name_handler: None, - find_method_for_receiver_handler: None, - find_receiver_type_handler: None, - }), - "rust" => Some(LanguageInfo { - element_query: rust::ELEMENT_QUERY, - call_query: rust::CALL_QUERY, - reference_query: rust::REFERENCE_QUERY, - function_node_kinds: &["function_item", "impl_item"], - function_name_kinds: &["identifier", "field_identifier", "property_identifier"], - extract_function_name_handler: Some(rust::extract_function_name_for_kind), - find_method_for_receiver_handler: Some(rust::find_method_for_receiver), - find_receiver_type_handler: Some(rust::find_receiver_type), - }), - "javascript" | "typescript" => Some(LanguageInfo { - element_query: javascript::ELEMENT_QUERY, - call_query: javascript::CALL_QUERY, - reference_query: "", - function_node_kinds: &[ - "function_declaration", - "method_definition", - "arrow_function", - ], - function_name_kinds: &["identifier", "field_identifier", "property_identifier"], - extract_function_name_handler: None, - find_method_for_receiver_handler: None, - find_receiver_type_handler: None, - }), - "go" => Some(LanguageInfo { - element_query: go::ELEMENT_QUERY, - call_query: go::CALL_QUERY, - reference_query: go::REFERENCE_QUERY, - function_node_kinds: &["function_declaration", "method_declaration"], - function_name_kinds: &["identifier", "field_identifier", "property_identifier"], - extract_function_name_handler: None, - find_method_for_receiver_handler: Some(go::find_method_for_receiver), - find_receiver_type_handler: None, - }), - "java" => Some(LanguageInfo { - element_query: java::ELEMENT_QUERY, - call_query: java::CALL_QUERY, - reference_query: "", - function_node_kinds: &["method_declaration", "constructor_declaration"], - function_name_kinds: &["identifier", "field_identifier", "property_identifier"], - extract_function_name_handler: None, - find_method_for_receiver_handler: None, - find_receiver_type_handler: None, - }), - "kotlin" => Some(LanguageInfo { - element_query: kotlin::ELEMENT_QUERY, - call_query: kotlin::CALL_QUERY, - reference_query: "", - function_node_kinds: &["function_declaration", "class_body"], - function_name_kinds: &["identifier", "field_identifier", "property_identifier"], - extract_function_name_handler: None, - find_method_for_receiver_handler: None, - find_receiver_type_handler: None, - }), - "swift" => Some(LanguageInfo { - element_query: swift::ELEMENT_QUERY, - call_query: swift::CALL_QUERY, - reference_query: "", - function_node_kinds: &[ - "function_declaration", - "init_declaration", - "deinit_declaration", - "subscript_declaration", - ], - function_name_kinds: &["simple_identifier"], - extract_function_name_handler: Some(swift::extract_function_name_for_kind), - find_method_for_receiver_handler: None, - find_receiver_type_handler: None, - }), - "ruby" => Some(LanguageInfo { - element_query: ruby::ELEMENT_QUERY, - call_query: ruby::CALL_QUERY, - reference_query: ruby::REFERENCE_QUERY, - function_node_kinds: &["method", "singleton_method"], - function_name_kinds: &["identifier", "field_identifier", "property_identifier"], - extract_function_name_handler: None, - find_method_for_receiver_handler: Some(ruby::find_method_for_receiver), - find_receiver_type_handler: None, - }), - _ => None, - } -} diff --git a/crates/goose-mcp/src/developer/analyze/languages/python.rs b/crates/goose-mcp/src/developer/analyze/languages/python.rs deleted file mode 100644 index 3dd117d75dec..000000000000 --- a/crates/goose-mcp/src/developer/analyze/languages/python.rs +++ /dev/null @@ -1,25 +0,0 @@ -/// Tree-sitter query for extracting Python code elements -pub const ELEMENT_QUERY: &str = r#" - (function_definition name: (identifier) @func) - (class_definition name: (identifier) @class) - (import_statement) @import - (import_from_statement) @import - (aliased_import) @import - (assignment left: (identifier) @class) -"#; - -/// Tree-sitter query for extracting Python function calls -pub const CALL_QUERY: &str = r#" - ; Function calls - (call - function: (identifier) @function.call) - - ; Method calls - (call - function: (attribute - attribute: (identifier) @method.call)) - - ; Decorator applications - (decorator (identifier) @function.call) - (decorator (attribute attribute: (identifier) @method.call)) -"#; diff --git a/crates/goose-mcp/src/developer/analyze/languages/ruby.rs b/crates/goose-mcp/src/developer/analyze/languages/ruby.rs deleted file mode 100644 index 04a0ab97ccd4..000000000000 --- a/crates/goose-mcp/src/developer/analyze/languages/ruby.rs +++ /dev/null @@ -1,151 +0,0 @@ -/// Tree-sitter query for extracting Ruby code elements. -/// -/// This query captures: -/// - Method definitions (def) -/// - Class and module definitions -/// - Constants -/// - Common attr_* declarations (attr_accessor, attr_reader, attr_writer) -/// - Import statements (require, require_relative, load) -pub const ELEMENT_QUERY: &str = r#" - ; Method definitions - (method name: (identifier) @func) - - ; Class and module definitions - (class name: (constant) @class) - (module name: (constant) @class) - - ; Constant assignments - (assignment left: (constant) @const) - - ; Attr declarations as functions - (call method: (identifier) @func (#eq? @func "attr_accessor")) - (call method: (identifier) @func (#eq? @func "attr_reader")) - (call method: (identifier) @func (#eq? @func "attr_writer")) - - ; Require statements - (call method: (identifier) @import (#eq? @import "require")) - (call method: (identifier) @import (#eq? @import "require_relative")) - (call method: (identifier) @import (#eq? @import "load")) -"#; - -/// Tree-sitter query for extracting Ruby function calls. -/// -/// This query captures: -/// - Direct method calls -/// - Method calls with receivers (object.method) -/// - Calls to constants (typically constructors like ClassName.new) -/// - Identifier and constant references in various expression contexts -pub const CALL_QUERY: &str = r#" - ; Method calls - (call method: (identifier) @method.call) - - ; Method calls with receiver - (call receiver: (_) method: (identifier) @method.call) - - ; Calls to constants (typically constructors) - (call receiver: (constant) @function.call) - - ; Identifier and constant references in argument lists - (argument_list (identifier) @identifier.reference) - (argument_list (constant) @identifier.reference) - - ; Binary expressions - (binary left: (identifier) @identifier.reference) - (binary right: (identifier) @identifier.reference) - (binary left: (constant) @identifier.reference) - (binary right: (constant) @identifier.reference) - - ; Assignment expressions - (assignment right: (identifier) @identifier.reference) - (assignment right: (constant) @identifier.reference) -"#; - -/// Tree-sitter query for extracting Ruby type references and usage patterns. -/// -/// This query captures: -/// - Method-to-class associations (instance and class methods) -/// - Class instantiation (ClassName.new) -/// - Type references in various contexts -pub const REFERENCE_QUERY: &str = r#" - ; Instance methods within a class - capture class name, will find method via receiver lookup - (class - name: (constant) @method.receiver - (body_statement (method))) - - ; Class instantiation (ClassName.new) - (call - receiver: (constant) @struct.literal - method: (identifier) @method.name (#eq? @method.name "new")) - - ; Constant references as receivers (type usage) - (call - receiver: (constant) @field.type - method: (identifier)) -"#; - -/// Find the method name for a method receiver node in Ruby -/// -/// For Ruby, the receiver_node is the class constant. This finds methods -/// within that class node, used for associating methods with their classes. -pub fn find_method_for_receiver( - receiver_node: &tree_sitter::Node, - source: &str, - ast_recursion_limit: Option, -) -> Option { - let max_depth = ast_recursion_limit.unwrap_or(10); - - // For Ruby, receiver_node is the class constant - if receiver_node.kind() == "constant" { - let mut current = *receiver_node; - while let Some(parent) = current.parent() { - if parent.kind() == "class" { - return find_first_method_in_class(&parent, source, max_depth); - } - current = parent; - } - } - None -} - -/// Find the first method name within a Ruby class node -fn find_first_method_in_class( - class_node: &tree_sitter::Node, - source: &str, - max_depth: usize, -) -> Option { - for i in 0..class_node.child_count() as u32 { - if let Some(child) = class_node.child(i) { - if child.kind() == "body_statement" { - return find_method_in_body_with_depth(&child, source, 0, max_depth); - } - } - } - None -} - -/// Recursively find a method within a body_statement node with depth limit -fn find_method_in_body_with_depth( - node: &tree_sitter::Node, - source: &str, - depth: usize, - max_depth: usize, -) -> Option { - if depth >= max_depth { - return None; - } - - for i in 0..node.child_count() as u32 { - if let Some(child) = node.child(i) { - if child.kind() == "method" { - for j in 0..child.child_count() as u32 { - if let Some(name_node) = child.child(j) { - if name_node.kind() == "identifier" { - return source.get(name_node.byte_range()).map(|s| s.to_string()); - } - } - } - } - } - } - None -} diff --git a/crates/goose-mcp/src/developer/analyze/languages/rust.rs b/crates/goose-mcp/src/developer/analyze/languages/rust.rs deleted file mode 100644 index 98bfabc6ed60..000000000000 --- a/crates/goose-mcp/src/developer/analyze/languages/rust.rs +++ /dev/null @@ -1,146 +0,0 @@ -/// Tree-sitter query for extracting Rust code elements -pub const ELEMENT_QUERY: &str = r#" - (function_item name: (identifier) @func) - (impl_item type: (type_identifier) @class) - (struct_item name: (type_identifier) @struct) - (use_declaration) @import -"#; - -/// Tree-sitter query for extracting Rust function calls -pub const CALL_QUERY: &str = r#" - ; Function calls - (call_expression - function: (identifier) @function.call) - - ; Method calls - (call_expression - function: (field_expression - field: (field_identifier) @method.call)) - - ; Associated function calls (e.g., Type::method()) - ; Now captures the full Type::method instead of just method - (call_expression - function: (scoped_identifier) @scoped.call) - - ; Macro calls (often contain function-like behavior) - (macro_invocation - macro: (identifier) @macro.call) -"#; - -/// Tree-sitter query for extracting Rust type references and usage patterns -pub const REFERENCE_QUERY: &str = r#" - ; Method receivers - capture self parameters to associate methods with impl types - (self_parameter) @method.receiver - - ; Struct instantiation - struct literals - (struct_expression - name: (type_identifier) @struct.literal) - - ; Field type declarations in structs - (field_declaration - type: (type_identifier) @field.type) - - ; Field with reference type - (field_declaration - type: (reference_type - (type_identifier) @field.type)) - - ; Field with generic type - (field_declaration - type: (generic_type - type: (type_identifier) @field.type)) - - ; Variable type annotations - (let_declaration - type: (type_identifier) @var.type) - - ; Variable with reference type - (let_declaration - type: (reference_type - (type_identifier) @var.type)) - - ; Function parameter types - (parameter - type: (type_identifier) @param.type) - - ; Parameter with reference type - (parameter - type: (reference_type - (type_identifier) @param.type)) -"#; - -/// Extract function name for Rust-specific node kinds -/// -/// Rust has special cases like impl_item blocks that should be -/// formatted as "impl TypeName" instead of extracting a simple name. -pub fn extract_function_name_for_kind( - node: &tree_sitter::Node, - source: &str, - kind: &str, -) -> Option { - if kind == "impl_item" { - // For impl blocks, find the type being implemented - for i in 0..node.child_count() as u32 { - if let Some(child) = node.child(i) { - if child.kind() == "type_identifier" { - return source - .get(child.byte_range()) - .map(|s| format!("impl {}", s)); - } - } - } - } - None -} - -/// Find the method name for a method receiver node in Rust -/// -/// The receiver_node is a self_parameter. This walks up to find the -/// containing function_item and returns the method name. -pub fn find_method_for_receiver( - receiver_node: &tree_sitter::Node, - source: &str, - _ast_recursion_limit: Option, -) -> Option { - // Walk up to find the function_item that contains this self_parameter - let mut current = *receiver_node; - - while let Some(parent) = current.parent() { - if parent.kind() == "function_item" { - // Found the function, get its name - for i in 0..parent.child_count() as u32 { - if let Some(child) = parent.child(i) { - if child.kind() == "identifier" { - return source.get(child.byte_range()).map(|s| s.to_string()); - } - } - } - } - current = parent; - } - None -} - -/// Find the receiver type for a self parameter in Rust -/// -/// In Rust, self parameters are special - they don't explicitly state their type. -/// This function walks up from a self_parameter node to find the impl block -/// and extracts the type being implemented. -pub fn find_receiver_type(node: &tree_sitter::Node, source: &str) -> Option { - // Walk up from self_parameter to find the impl_item - let mut current = *node; - while let Some(parent) = current.parent() { - if parent.kind() == "impl_item" { - // Find the type_identifier in the impl block - for i in 0..parent.child_count() as u32 { - if let Some(child) = parent.child(i) { - if child.kind() == "type_identifier" { - return source.get(child.byte_range()).map(|s| s.to_string()); - } - } - } - } - current = parent; - } - None -} diff --git a/crates/goose-mcp/src/developer/analyze/languages/swift.rs b/crates/goose-mcp/src/developer/analyze/languages/swift.rs deleted file mode 100644 index dd8ca8136a16..000000000000 --- a/crates/goose-mcp/src/developer/analyze/languages/swift.rs +++ /dev/null @@ -1,72 +0,0 @@ -/// Tree-sitter query for extracting Swift code elements -pub const ELEMENT_QUERY: &str = r#" - ; Functions - (function_declaration name: (simple_identifier) @func) - - ; Classes - (class_declaration name: (type_identifier) @class) - - ; Protocols (interfaces) - (protocol_declaration name: (type_identifier) @class) - - ; Imports - (import_declaration) @import -"#; - -/// Tree-sitter query for extracting Swift function calls -pub const CALL_QUERY: &str = r#" - ; Function calls - (call_expression - (simple_identifier) @function.call) - - ; Method calls with navigation - (call_expression - (navigation_expression - target: (_) - suffix: (navigation_suffix - suffix: (simple_identifier) @method.call))) - - ; Constructor calls - (constructor_expression - (user_type - (type_identifier) @constructor.call)) - - ; Async function calls - (await_expression - (call_expression - (simple_identifier) @function.call)) - - ; Async method calls - (await_expression - (call_expression - (navigation_expression - suffix: (navigation_suffix - suffix: (simple_identifier) @method.call)))) - - ; Static method calls (Type.method()) - (call_expression - (navigation_expression - target: (user_type) - suffix: (navigation_suffix - suffix: (simple_identifier) @scoped.call))) - - ; Closure calls - (call_expression - (navigation_expression) @function.call) -"#; - -/// Extract function name for Swift-specific node kinds -/// -/// Swift has special cases like init_declaration and deinit_declaration -/// that should return fixed names instead of extracting from children. -pub fn extract_function_name_for_kind( - _node: &tree_sitter::Node, - _source: &str, - kind: &str, -) -> Option { - match kind { - "init_declaration" => Some("init".to_string()), - "deinit_declaration" => Some("deinit".to_string()), - _ => None, - } -} diff --git a/crates/goose-mcp/src/developer/analyze/mod.rs b/crates/goose-mcp/src/developer/analyze/mod.rs deleted file mode 100644 index 9b7691b102fe..000000000000 --- a/crates/goose-mcp/src/developer/analyze/mod.rs +++ /dev/null @@ -1,332 +0,0 @@ -pub mod cache; -pub mod formatter; -pub mod graph; -pub mod languages; -pub mod parser; -pub mod traversal; -pub mod types; - -#[cfg(test)] -mod tests; - -use ignore::gitignore::Gitignore; -use rmcp::model::{CallToolResult, ErrorCode, ErrorData}; -use std::path::{Path, PathBuf}; - -use crate::developer::lang; - -use self::cache::AnalysisCache; -use self::formatter::Formatter; -use self::graph::CallGraph; -use self::parser::{ElementExtractor, ParserManager}; -use self::traversal::FileTraverser; -use self::types::{AnalysisMode, AnalysisResult, AnalyzeParams, FocusedAnalysisData}; - -/// Helper to safely lock a mutex with poison recovery -/// The recovery function is called on the mutex contents if the lock was poisoned -pub(crate) fn lock_or_recover( - mutex: &std::sync::Mutex, - recovery: F, -) -> std::sync::MutexGuard<'_, T> -where - F: FnOnce(&mut T), -{ - mutex.lock().unwrap_or_else(|poisoned| { - let mut guard = poisoned.into_inner(); - recovery(&mut guard); - tracing::warn!("Recovered from poisoned lock"); - guard - }) -} - -/// Code analyzer with caching and tree-sitter parsing -#[derive(Clone)] -pub struct CodeAnalyzer { - parser_manager: ParserManager, - cache: AnalysisCache, -} - -impl Default for CodeAnalyzer { - fn default() -> Self { - Self::new() - } -} - -impl CodeAnalyzer { - pub fn new() -> Self { - tracing::debug!("Initializing CodeAnalyzer"); - Self { - parser_manager: ParserManager::new(), - cache: AnalysisCache::new(100), - } - } - - pub fn analyze( - &self, - params: AnalyzeParams, - path: PathBuf, - ignore_patterns: &Gitignore, - ) -> Result { - tracing::info!("Starting analysis of {:?} with params {:?}", path, params); - - let traverser = FileTraverser::new(ignore_patterns); - - traverser.validate_path(&path)?; - - let mode = self.determine_mode(¶ms, &path); - - tracing::debug!("Using analysis mode: {:?}", mode); - - let mut output = match mode { - AnalysisMode::Focused => self.analyze_focused(&path, ¶ms, &traverser)?, - AnalysisMode::Semantic => { - if path.is_file() { - let result = self.analyze_file(&path, &mode, ¶ms)?; - Formatter::format_analysis_result(&path, &result, &mode) - } else { - self.analyze_directory(&path, ¶ms, &traverser, &mode)? - } - } - AnalysisMode::Structure => { - if path.is_file() { - let result = self.analyze_file(&path, &mode, ¶ms)?; - Formatter::format_analysis_result(&path, &result, &mode) - } else { - self.analyze_directory(&path, ¶ms, &traverser, &mode)? - } - } - }; - - // If focus is specified with non-focused mode, filter results - if let Some(focus) = ¶ms.focus { - if mode != AnalysisMode::Focused { - output = Formatter::filter_by_focus(&output, focus); - } - } - - const OUTPUT_LIMIT: usize = 1000; - if !params.force { - let line_count = output.lines().count(); - if line_count > OUTPUT_LIMIT { - let warning = format!( - "LARGE OUTPUT WARNING\n\n\ - The analysis would produce {} lines (~{} tokens).\n\ - This exceeds the {} line limit.\n\n\ - To proceed anyway, add 'force: true' to your parameters:\n\ - analyze path=\"{}\" force=true{}\n\n\ - Or narrow your scope by:\n\ - • Analyzing a subdirectory instead\n\ - • Using focus mode: focus=\"symbol_name\"\n\ - • Reducing depth: max_depth=1", - line_count, - line_count * 10, // rough token estimate - OUTPUT_LIMIT, - path.display(), - if let Some(f) = ¶ms.focus { - format!(" focus=\"{}\"", f) - } else { - String::new() - } - ); - return Ok(CallToolResult::success(vec![rmcp::model::Content::text( - warning, - )])); - } - } - - tracing::info!("Analysis complete"); - Ok(CallToolResult::success(Formatter::format_results(output))) - } - - fn determine_mode(&self, params: &AnalyzeParams, path: &Path) -> AnalysisMode { - if params.focus.is_some() { - return AnalysisMode::Focused; - } - - if path.is_file() { - AnalysisMode::Semantic - } else { - AnalysisMode::Structure - } - } - - fn analyze_file( - &self, - path: &Path, - mode: &AnalysisMode, - params: &AnalyzeParams, - ) -> Result { - tracing::debug!("Analyzing file {:?} in {:?} mode", path, mode); - - let metadata = std::fs::metadata(path).map_err(|e| { - tracing::error!("Failed to get file metadata for {:?}: {}", path, e); - ErrorData::new( - ErrorCode::INTERNAL_ERROR, - format!("Failed to get metadata for '{}': {}", path.display(), e), - None, - ) - })?; - - let modified = metadata.modified().map_err(|e| { - ErrorData::new( - ErrorCode::INTERNAL_ERROR, - format!( - "Failed to get modification time for '{}': {}", - path.display(), - e - ), - None, - ) - })?; - - if let Some(cached) = self.cache.get(&path.to_path_buf(), modified, mode) { - tracing::trace!("Using cached result for {:?}", path); - return Ok(cached); - } - - let content = match std::fs::read_to_string(path) { - Ok(content) => content, - Err(e) => { - tracing::trace!("Skipping binary/non-UTF-8 file {:?}: {}", path, e); - return Ok(AnalysisResult::empty(0)); - } - }; - - let line_count = content.lines().count(); - - let language = lang::get_language_identifier(path); - if language.is_empty() { - tracing::trace!("Unsupported file type: {:?}", path); - return Ok(AnalysisResult::empty(line_count)); - } - - // Check if we support this language for parsing - // A language is supported if it has query definitions - let language_supported = languages::get_language_info(language) - .map(|info| !info.element_query.is_empty()) - .unwrap_or(false); - - if !language_supported { - tracing::trace!("Language {} not supported for parsing", language); - return Ok(AnalysisResult::empty(line_count)); - } - - let tree = self.parser_manager.parse(&content, language)?; - - let depth = mode.as_str(); - let mut result = ElementExtractor::extract_with_depth( - &tree, - &content, - language, - depth, - params.ast_recursion_limit, - )?; - - result.line_count = line_count; - - self.cache - .put(path.to_path_buf(), modified, mode, result.clone()); - - Ok(result) - } - - fn analyze_directory( - &self, - path: &Path, - params: &AnalyzeParams, - traverser: &FileTraverser<'_>, - mode: &AnalysisMode, - ) -> Result { - tracing::debug!("Analyzing directory {:?} in {:?} mode", path, mode); - - let mode = *mode; - - let results = traverser.collect_directory_results(path, params.max_depth, |file_path| { - self.analyze_file(file_path, &mode, params) - })?; - - Ok(Formatter::format_directory_structure( - path, - &results, - params.max_depth, - )) - } - - fn analyze_focused( - &self, - path: &Path, - params: &AnalyzeParams, - traverser: &FileTraverser<'_>, - ) -> Result { - let focus_symbol = params.focus.as_ref().ok_or_else(|| { - ErrorData::new( - ErrorCode::INVALID_PARAMS, - "Focused mode requires 'focus' parameter to specify the symbol to track" - .to_string(), - None, - ) - })?; - - tracing::info!("Running focused analysis for symbol '{}'", focus_symbol); - - let files_to_analyze = if path.is_file() { - vec![path.to_path_buf()] - } else { - traverser.collect_files_for_focused(path, params.max_depth)? - }; - - tracing::debug!( - "Analyzing {} files for focused analysis", - files_to_analyze.len() - ); - - use rayon::prelude::*; - let all_results: Result, _> = files_to_analyze - .par_iter() - .map(|file_path| { - self.analyze_file(file_path, &AnalysisMode::Semantic, params) - .map(|result| (file_path.clone(), result)) - }) - .collect(); - let all_results = all_results?; - - let graph = CallGraph::build_from_results(&all_results); - - let incoming_chains = if params.follow_depth > 0 { - graph.find_incoming_chains(focus_symbol, params.follow_depth) - } else { - vec![] - }; - - let outgoing_chains = if params.follow_depth > 0 { - graph.find_outgoing_chains(focus_symbol, params.follow_depth) - } else { - vec![] - }; - - let definitions = graph - .definitions - .get(focus_symbol) - .cloned() - .unwrap_or_default(); - - let focus_data = FocusedAnalysisData { - focus_symbol, - follow_depth: params.follow_depth, - files_analyzed: &files_to_analyze, - definitions: &definitions, - incoming_chains: &incoming_chains, - outgoing_chains: &outgoing_chains, - }; - - let mut output = Formatter::format_focused_output(&focus_data); - - if path.is_file() { - let hint = "NOTE: Focus mode works best with directory paths. \ - Use a parent directory in the path for cross-file analysis.\n\n"; - output = format!("{}{}", hint, output); - } - - Ok(output) - } -} diff --git a/crates/goose-mcp/src/developer/analyze/parser.rs b/crates/goose-mcp/src/developer/analyze/parser.rs deleted file mode 100644 index 1caebacbc3e3..000000000000 --- a/crates/goose-mcp/src/developer/analyze/parser.rs +++ /dev/null @@ -1,525 +0,0 @@ -use rmcp::model::{ErrorCode, ErrorData}; -use std::collections::HashMap; -use std::sync::{Arc, Mutex}; -use tree_sitter::{Language, Parser, StreamingIterator, Tree}; - -use super::lock_or_recover; -use crate::developer::analyze::types::{ - AnalysisResult, CallInfo, ClassInfo, ElementQueryResult, FunctionInfo, ReferenceInfo, - ReferenceType, -}; - -#[derive(Clone)] -pub struct ParserManager { - parsers: Arc>>>>, -} - -impl ParserManager { - pub fn new() -> Self { - tracing::debug!("Initializing ParserManager"); - Self { - parsers: Arc::new(Mutex::new(HashMap::new())), - } - } - - pub fn get_or_create_parser(&self, language: &str) -> Result>, ErrorData> { - let mut cache = lock_or_recover(&self.parsers, |c| c.clear()); - - if let Some(parser) = cache.get(language) { - tracing::trace!("Reusing cached parser for {}", language); - return Ok(Arc::clone(parser)); - } - - tracing::debug!("Creating new parser for {}", language); - let mut parser = Parser::new(); - let language_config: Language = match language { - "python" => tree_sitter_python::LANGUAGE.into(), - "rust" => tree_sitter_rust::LANGUAGE.into(), - "javascript" | "typescript" => tree_sitter_javascript::LANGUAGE.into(), - "go" => tree_sitter_go::LANGUAGE.into(), - "java" => tree_sitter_java::LANGUAGE.into(), - "kotlin" => tree_sitter_kotlin_ng::LANGUAGE.into(), - "swift" => tree_sitter_swift::LANGUAGE.into(), - "ruby" => tree_sitter_ruby::LANGUAGE.into(), - _ => { - tracing::warn!("Unsupported language: {}", language); - return Err(ErrorData::new( - ErrorCode::INVALID_PARAMS, - format!("Unsupported language: {}", language), - None, - )); - } - }; - - parser.set_language(&language_config).map_err(|e| { - tracing::error!("Failed to set language for {}: {}", language, e); - ErrorData::new( - ErrorCode::INTERNAL_ERROR, - format!("Failed to set language: {}", e), - None, - ) - })?; - - let parser_arc = Arc::new(Mutex::new(parser)); - cache.insert(language.to_string(), Arc::clone(&parser_arc)); - Ok(parser_arc) - } - - pub fn parse(&self, content: &str, language: &str) -> Result { - let parser_arc = self.get_or_create_parser(language)?; - let mut parser = lock_or_recover(&parser_arc, |_| {}); - - parser.parse(content, None).ok_or_else(|| { - tracing::error!("Failed to parse content as {}", language); - ErrorData::new( - ErrorCode::INTERNAL_ERROR, - format!("Failed to parse file as {}", language), - None, - ) - }) - } -} - -impl Default for ParserManager { - fn default() -> Self { - Self::new() - } -} - -pub struct ElementExtractor; - -impl ElementExtractor { - fn find_child_by_kind<'a>( - node: &'a tree_sitter::Node, - kinds: &[&str], - ) -> Option> { - (0..node.child_count() as u32) - .filter_map(|i| node.child(i)) - .find(|child| kinds.contains(&child.kind())) - } - - fn extract_text_from_child( - node: &tree_sitter::Node, - source: &str, - kinds: &[&str], - ) -> Option { - Self::find_child_by_kind(node, kinds) - .and_then(|child| source.get(child.byte_range()).map(|s| s.to_string())) - } - - pub fn extract_with_depth( - tree: &Tree, - source: &str, - language: &str, - depth: &str, - ast_recursion_limit: Option, - ) -> Result { - use crate::developer::analyze::languages; - - tracing::trace!( - "Extracting elements from {} code with depth {}", - language, - depth - ); - - let mut result = Self::extract_elements(tree, source, language)?; - - if depth == "structure" { - result.functions.clear(); - result.classes.clear(); - result.imports.clear(); - } else if depth == "semantic" { - let calls = Self::extract_calls(tree, source, language)?; - result.calls = calls; - - for call in &result.calls { - result.references.push(ReferenceInfo { - symbol: call.callee_name.clone(), - ref_type: ReferenceType::Call, - line: call.line, - context: call.context.clone(), - associated_type: None, - }); - } - - // Languages can opt-in to advanced reference tracking by providing a REFERENCE_QUERY - // in their language definition. This enables tracking of: - // - Type instantiation (struct literals, object creation) - // - Field/variable/parameter type references - // - Method-to-type associations - if let Some(info) = languages::get_language_info(language) { - if !info.reference_query.is_empty() { - let references = - Self::extract_references(tree, source, language, ast_recursion_limit)?; - result.references.extend(references); - } - } - } - - Ok(result) - } - - pub fn extract_elements( - tree: &Tree, - source: &str, - language: &str, - ) -> Result { - use crate::developer::analyze::languages; - - let info = match languages::get_language_info(language) { - Some(info) if !info.element_query.is_empty() => info, - _ => return Ok(Self::empty_analysis_result()), - }; - - let query_str = info.element_query; - - let (functions, classes, imports) = Self::process_element_query(tree, source, query_str)?; - - let main_line = functions.iter().find(|f| f.name == "main").map(|f| f.line); - - Ok(AnalysisResult { - function_count: functions.len(), - class_count: classes.len(), - import_count: imports.len(), - functions, - classes, - imports, - calls: vec![], - references: vec![], - line_count: 0, - main_line, - }) - } - - fn process_element_query( - tree: &Tree, - source: &str, - query_str: &str, - ) -> Result { - use tree_sitter::{Query, QueryCursor}; - - let mut functions = Vec::new(); - let mut classes = Vec::new(); - let mut imports = Vec::new(); - - let query = Query::new(&tree.language(), query_str).map_err(|e| { - tracing::error!("Failed to create query: {}", e); - ErrorData::new( - ErrorCode::INTERNAL_ERROR, - format!("Failed to create query: {}", e), - None, - ) - })?; - - let mut cursor = QueryCursor::new(); - let mut matches = cursor.matches(&query, tree.root_node(), source.as_bytes()); - - while let Some(match_) = matches.next() { - for capture in match_.captures { - let node = capture.node; - let Some(text) = source.get(node.byte_range()) else { - continue; - }; - let line = source - .get(..node.start_byte()) - .map(|s: &str| s.lines().count() + 1) - .unwrap_or(1); - - match query.capture_names()[capture.index as usize] { - "func" | "const" => { - functions.push(FunctionInfo { - name: text.to_string(), - line, - params: vec![], // Simplified for now - }); - } - "class" | "struct" => { - classes.push(ClassInfo { - name: text.to_string(), - line, - methods: vec![], // Simplified for now - }); - } - "import" => { - imports.push(text.to_string()); - } - _ => {} - } - } - } - - tracing::trace!( - "Extracted {} functions, {} classes, {} imports", - functions.len(), - classes.len(), - imports.len() - ); - - Ok((functions, classes, imports)) - } - - fn extract_calls( - tree: &Tree, - source: &str, - language: &str, - ) -> Result, ErrorData> { - use crate::developer::analyze::languages; - use tree_sitter::{Query, QueryCursor}; - - let mut calls = Vec::new(); - - let info = match languages::get_language_info(language) { - Some(info) if !info.call_query.is_empty() => info, - _ => return Ok(calls), - }; - - let query_str = info.call_query; - - let query = Query::new(&tree.language(), query_str).map_err(|e| { - tracing::error!("Failed to create call query: {}", e); - ErrorData::new( - ErrorCode::INTERNAL_ERROR, - format!("Failed to create call query: {}", e), - None, - ) - })?; - - let mut cursor = QueryCursor::new(); - let mut matches = cursor.matches(&query, tree.root_node(), source.as_bytes()); - - while let Some(match_) = matches.next() { - for capture in match_.captures { - let node = capture.node; - let Some(text) = source.get(node.byte_range()) else { - continue; - }; - let start_pos = node.start_position(); - - let line_start = source - .get(..node.start_byte()) - .and_then(|s: &str| s.rfind('\n')) - .map(|i| i + 1) - .unwrap_or(0); - let line_end = source - .get(node.end_byte()..) - .and_then(|s: &str| s.find('\n')) - .map(|i| node.end_byte() + i) - .unwrap_or(source.len()); - let context = source - .get(line_start..line_end) - .map(|s: &str| s.trim().to_string()) - .unwrap_or_default(); - - let caller_name = Self::find_containing_function(&node, source, language); - - match query.capture_names()[capture.index as usize] { - "function.call" - | "method.call" - | "scoped.call" - | "macro.call" - | "constructor.call" - | "identifier.reference" => { - calls.push(CallInfo { - caller_name, - callee_name: text.to_string(), - line: start_pos.row + 1, - column: start_pos.column, - context, - }); - } - _ => {} - } - } - } - - tracing::trace!("Extracted {} calls", calls.len()); - Ok(calls) - } - - fn extract_references( - tree: &Tree, - source: &str, - language: &str, - ast_recursion_limit: Option, - ) -> Result, ErrorData> { - use crate::developer::analyze::languages; - use tree_sitter::{Query, QueryCursor}; - - let mut references = Vec::new(); - - let info = match languages::get_language_info(language) { - Some(info) if !info.reference_query.is_empty() => info, - _ => return Ok(references), - }; - - let query_str = info.reference_query; - - let query = Query::new(&tree.language(), query_str).map_err(|e| { - tracing::error!("Failed to create reference query: {}", e); - ErrorData::new( - ErrorCode::INTERNAL_ERROR, - format!("Failed to create reference query: {}", e), - None, - ) - })?; - - let mut cursor = QueryCursor::new(); - let mut matches = cursor.matches(&query, tree.root_node(), source.as_bytes()); - - while let Some(match_) = matches.next() { - for capture in match_.captures { - let node = capture.node; - let Some(text) = source.get(node.byte_range()) else { - continue; - }; - let start_pos = node.start_position(); - - let line_start = source - .get(..node.start_byte()) - .and_then(|s: &str| s.rfind('\n')) - .map(|i| i + 1) - .unwrap_or(0); - let line_end = source - .get(node.end_byte()..) - .and_then(|s: &str| s.find('\n')) - .map(|i| node.end_byte() + i) - .unwrap_or(source.len()); - let context = source - .get(line_start..line_end) - .map(|s: &str| s.trim().to_string()) - .unwrap_or_default(); - - let capture_name = query.capture_names()[capture.index as usize]; - - let (ref_type, symbol, associated_type) = match capture_name { - "method.receiver" => { - let method_name = Self::find_method_name_for_receiver( - &node, - source, - language, - ast_recursion_limit, - ); - if let Some(method_name) = method_name { - // Use language-specific handler to find receiver type, or fall back to text - let type_name = Self::find_receiver_type(&node, source, language) - .or_else(|| Some(text.to_string())); - - if let Some(type_name) = type_name { - ( - ReferenceType::MethodDefinition, - method_name, - Some(type_name), - ) - } else { - continue; - } - } else { - continue; - } - } - "struct.literal" => (ReferenceType::TypeInstantiation, text.to_string(), None), - "field.type" => (ReferenceType::FieldType, text.to_string(), None), - "param.type" => (ReferenceType::ParameterType, text.to_string(), None), - "var.type" | "shortvar.type" => { - (ReferenceType::VariableType, text.to_string(), None) - } - "type.assertion" | "type.conversion" => { - (ReferenceType::Call, text.to_string(), None) - } - _ => continue, - }; - - references.push(ReferenceInfo { - symbol, - ref_type, - line: start_pos.row + 1, - context, - associated_type, - }); - } - } - - tracing::trace!("Extracted {} struct references", references.len()); - Ok(references) - } - - fn find_method_name_for_receiver( - receiver_node: &tree_sitter::Node, - source: &str, - language: &str, - ast_recursion_limit: Option, - ) -> Option { - use crate::developer::analyze::languages; - - languages::get_language_info(language) - .and_then(|info| info.find_method_for_receiver_handler) - .and_then(|handler| handler(receiver_node, source, ast_recursion_limit)) - } - - fn find_receiver_type( - receiver_node: &tree_sitter::Node, - source: &str, - language: &str, - ) -> Option { - use crate::developer::analyze::languages; - - languages::get_language_info(language) - .and_then(|info| info.find_receiver_type_handler) - .and_then(|handler| handler(receiver_node, source)) - } - - fn find_containing_function( - node: &tree_sitter::Node, - source: &str, - language: &str, - ) -> Option { - use crate::developer::analyze::languages; - - let info = languages::get_language_info(language)?; - - let mut current = *node; - - while let Some(parent) = current.parent() { - let kind = parent.kind(); - - // Check if this is a function-like node - if info.function_node_kinds.contains(&kind) { - // Two-step extraction process: - // 1. Try language-specific extraction for special cases (e.g., Rust impl blocks, Swift init/deinit) - // 2. Fall back to generic extraction using standard identifier node kinds - // This pattern allows languages to override default behavior when needed - if let Some(handler) = info.extract_function_name_handler { - if let Some(name) = handler(&parent, source, kind) { - return Some(name); - } - } - - // Standard extraction: find first child matching expected identifier kinds - if let Some(name) = - Self::extract_text_from_child(&parent, source, info.function_name_kinds) - { - return Some(name); - } - } - - current = parent; - } - - None - } - - fn empty_analysis_result() -> AnalysisResult { - AnalysisResult { - functions: vec![], - classes: vec![], - imports: vec![], - calls: vec![], - references: vec![], - function_count: 0, - class_count: 0, - line_count: 0, - import_count: 0, - main_line: None, - } - } -} diff --git a/crates/goose-mcp/src/developer/analyze/tests/cache_tests.rs b/crates/goose-mcp/src/developer/analyze/tests/cache_tests.rs deleted file mode 100644 index 84eaefdde23b..000000000000 --- a/crates/goose-mcp/src/developer/analyze/tests/cache_tests.rs +++ /dev/null @@ -1,140 +0,0 @@ -// Tests for the cache module - -use crate::developer::analyze::cache::AnalysisCache; -use crate::developer::analyze::types::{AnalysisMode, AnalysisResult, FunctionInfo}; -use std::path::PathBuf; -use std::time::SystemTime; - -fn create_test_result() -> AnalysisResult { - AnalysisResult { - functions: vec![FunctionInfo { - name: "test_func".to_string(), - line: 1, - params: vec![], - }], - classes: vec![], - imports: vec![], - calls: vec![], - references: vec![], - function_count: 1, - class_count: 0, - line_count: 10, - import_count: 0, - main_line: None, - } -} - -#[test] -fn test_cache_hit_miss() { - let cache = AnalysisCache::new(10); - let path = PathBuf::from("test.rs"); - let time = SystemTime::now(); - let result = create_test_result(); - - // Initial miss - assert!(cache.get(&path, time, &AnalysisMode::Semantic).is_none()); - - // Store and hit - cache.put(path.clone(), time, &AnalysisMode::Semantic, result.clone()); - assert!(cache.get(&path, time, &AnalysisMode::Semantic).is_some()); - - // Different time = miss - let later = time + std::time::Duration::from_secs(1); - assert!(cache.get(&path, later, &AnalysisMode::Semantic).is_none()); -} - -#[test] -fn test_cache_eviction() { - let cache = AnalysisCache::new(2); - let result = create_test_result(); - let time = SystemTime::now(); - - // Fill cache - cache.put( - PathBuf::from("file1.rs"), - time, - &AnalysisMode::Semantic, - result.clone(), - ); - cache.put( - PathBuf::from("file2.rs"), - time, - &AnalysisMode::Semantic, - result.clone(), - ); - assert_eq!(cache.len(), 2); - - // Add third item, should evict first - cache.put( - PathBuf::from("file3.rs"), - time, - &AnalysisMode::Semantic, - result.clone(), - ); - assert_eq!(cache.len(), 2); - - // First item should be evicted - assert!(cache - .get(&PathBuf::from("file1.rs"), time, &AnalysisMode::Semantic) - .is_none()); - assert!(cache - .get(&PathBuf::from("file2.rs"), time, &AnalysisMode::Semantic) - .is_some()); - assert!(cache - .get(&PathBuf::from("file3.rs"), time, &AnalysisMode::Semantic) - .is_some()); -} - -#[test] -fn test_cache_clear() { - let cache = AnalysisCache::new(10); - let path = PathBuf::from("test.rs"); - let time = SystemTime::now(); - let result = create_test_result(); - - cache.put(path.clone(), time, &AnalysisMode::Semantic, result); - assert!(!cache.is_empty()); - - cache.clear(); - assert!(cache.is_empty()); - assert!(cache.get(&path, time, &AnalysisMode::Semantic).is_none()); -} - -#[test] -fn test_cache_default() { - let cache = AnalysisCache::default(); - assert!(cache.is_empty()); - - // Default cache should work normally - let path = PathBuf::from("test.rs"); - let time = SystemTime::now(); - let result = create_test_result(); - - cache.put(path.clone(), time, &AnalysisMode::Semantic, result); - assert!(cache.get(&path, time, &AnalysisMode::Semantic).is_some()); -} - -#[test] -fn test_cache_mode_separation() { - let cache = AnalysisCache::new(10); - let path = PathBuf::from("test.rs"); - let time = SystemTime::now(); - let result = create_test_result(); - - // Store in structure mode - cache.put(path.clone(), time, &AnalysisMode::Structure, result.clone()); - assert!(cache.get(&path, time, &AnalysisMode::Structure).is_some()); - - // Different mode should be a miss - assert!(cache.get(&path, time, &AnalysisMode::Semantic).is_none()); - - // Store in semantic mode - cache.put(path.clone(), time, &AnalysisMode::Semantic, result.clone()); - - // Both modes should now have cached results - assert!(cache.get(&path, time, &AnalysisMode::Structure).is_some()); - assert!(cache.get(&path, time, &AnalysisMode::Semantic).is_some()); - - // Cache should contain 2 entries (one per mode) - assert_eq!(cache.len(), 2); -} diff --git a/crates/goose-mcp/src/developer/analyze/tests/fixtures.rs b/crates/goose-mcp/src/developer/analyze/tests/fixtures.rs deleted file mode 100644 index 3c8d7d26be1d..000000000000 --- a/crates/goose-mcp/src/developer/analyze/tests/fixtures.rs +++ /dev/null @@ -1,87 +0,0 @@ -// Shared test fixtures and utilities - -use crate::developer::analyze::types::{AnalysisResult, CallInfo, ClassInfo, FunctionInfo}; -use ignore::gitignore::Gitignore; - -/// Create a test AnalysisResult with sample data -pub fn create_test_result() -> AnalysisResult { - AnalysisResult { - functions: vec![ - FunctionInfo { - name: "main".to_string(), - line: 10, - params: vec![], - }, - FunctionInfo { - name: "helper".to_string(), - line: 20, - params: vec![], - }, - ], - classes: vec![ClassInfo { - name: "TestClass".to_string(), - line: 5, - methods: vec![], - }], - imports: vec!["use std::fs".to_string()], - calls: vec![], - references: vec![], - function_count: 2, - class_count: 1, - line_count: 100, - import_count: 1, - main_line: Some(10), - } -} - -/// Create a test result with specific functions and call relationships -pub fn create_test_result_with_calls( - functions: Vec<&str>, - calls: Vec<(&str, &str)>, -) -> AnalysisResult { - AnalysisResult { - functions: functions - .into_iter() - .map(|name| FunctionInfo { - name: name.to_string(), - line: 1, - params: vec![], - }) - .collect(), - classes: vec![], - imports: vec![], - calls: calls - .into_iter() - .map(|(caller, callee)| CallInfo { - caller_name: Some(caller.to_string()), - callee_name: callee.to_string(), - line: 1, - column: 0, - context: String::new(), - }) - .collect(), - references: vec![], - function_count: 0, - class_count: 0, - line_count: 0, - import_count: 0, - main_line: None, - } -} - -/// Create a simple test gitignore -pub fn create_test_gitignore() -> Gitignore { - let mut builder = ignore::gitignore::GitignoreBuilder::new("."); - builder.add_line(None, "*.log").unwrap(); - builder.add_line(None, "node_modules/").unwrap(); - builder.build().unwrap() -} - -/// Create a test gitignore with custom base path -#[allow(dead_code)] -pub fn create_test_gitignore_at(base_path: &std::path::Path) -> Gitignore { - let mut builder = ignore::gitignore::GitignoreBuilder::new(base_path); - builder.add_line(None, "*.log").unwrap(); - builder.add_line(None, "node_modules/").unwrap(); - builder.build().unwrap() -} diff --git a/crates/goose-mcp/src/developer/analyze/tests/formatter_tests.rs b/crates/goose-mcp/src/developer/analyze/tests/formatter_tests.rs deleted file mode 100644 index ee315f194e63..000000000000 --- a/crates/goose-mcp/src/developer/analyze/tests/formatter_tests.rs +++ /dev/null @@ -1,151 +0,0 @@ -// Tests for the formatter module - -use crate::developer::analyze::formatter::Formatter; -use crate::developer::analyze::tests::fixtures::create_test_result; -use crate::developer::analyze::types::{AnalysisMode, CallChain, EntryType, FocusedAnalysisData}; -use std::path::{Path, PathBuf}; - -#[test] -fn test_format_structure_overview() { - let result = create_test_result(); - let output = Formatter::format_structure_overview(Path::new("test.rs"), &result); - - assert!(output.contains("[100L, 2F, 1C]")); - assert!(output.contains("main:10")); -} - -#[test] -fn test_format_semantic_result() { - let result = create_test_result(); - let output = Formatter::format_semantic_result(Path::new("test.rs"), &result); - - assert!(output.contains("FILE: test.rs")); - assert!(output.contains("C: TestClass:5")); - assert!(output.contains("F: main:10 helper:20")); - assert!(output.contains("I: use std::fs")); -} - -#[test] -fn test_filter_by_focus() { - // The filter_by_focus function includes the whole section when it finds a match - // This is the expected behavior - if a symbol is found in a file, show the whole file section - let output = "## test.rs\nfunction main at line 10\nfunction helper at line 20\n## other.rs\nfunction foo at line 5\n"; - let filtered = Formatter::filter_by_focus(output, "main"); - - assert!(filtered.contains("main")); - // When we find 'main' in test.rs, we include the whole test.rs section including 'helper' - assert!(filtered.contains("helper")); - assert!(!filtered.contains("foo")); // But we don't include other.rs -} - -#[test] -fn test_format_analysis_result_modes() { - let result = create_test_result(); - let path = Path::new("test.rs"); - - // Test structure mode - let output = Formatter::format_analysis_result(path, &result, &AnalysisMode::Structure); - assert!(output.contains("[100L, 2F, 1C]")); - - // Test semantic mode - let output = Formatter::format_analysis_result(path, &result, &AnalysisMode::Semantic); - assert!(output.contains("FILE: test.rs")); - assert!(output.contains("C: TestClass:5")); - - // Test focused mode (should return empty string with warning) - let output = Formatter::format_analysis_result(path, &result, &AnalysisMode::Focused); - assert_eq!(output, ""); -} - -#[test] -fn test_format_directory_structure() { - let base_path = Path::new("/test"); - let result1 = create_test_result(); - let mut result2 = create_test_result(); - result2.line_count = 200; - - let results = vec![ - (PathBuf::from("/test/file1.rs"), EntryType::File(result1)), - (PathBuf::from("/test/dir"), EntryType::Directory), - ( - PathBuf::from("/test/dir/file2.rs"), - EntryType::File(result2), - ), - ]; - - let output = Formatter::format_directory_structure(base_path, &results, 2); - - // Check summary - assert!(output.contains("SUMMARY:")); - assert!(output.contains("2 files, 300L, 4F, 2C")); - assert!(output.contains("Languages: rust (100%)")); - - // Check file entries - assert!(output.contains("file1.rs [100L, 2F, 1C]")); - assert!(output.contains("file2.rs [200L, 2F, 1C]")); -} - -#[test] -fn test_format_focused_output() { - let focus_data = FocusedAnalysisData { - focus_symbol: "test_func", - definitions: &[(PathBuf::from("test.rs"), 10)], - incoming_chains: &[CallChain { - path: vec![( - PathBuf::from("test.rs"), - 20, - "caller".to_string(), - "test_func".to_string(), - )], - }], - outgoing_chains: &[CallChain { - path: vec![( - PathBuf::from("test.rs"), - 30, - "test_func".to_string(), - "callee".to_string(), - )], - }], - files_analyzed: &[PathBuf::from("test.rs")], - follow_depth: 2, - }; - - let output = Formatter::format_focused_output(&focus_data); - - assert!(output.contains("FOCUSED ANALYSIS: test_func")); - assert!(output.contains("DEFINITIONS:")); - assert!(output.contains("INCOMING CALL CHAINS")); - assert!(output.contains("OUTGOING CALL CHAINS")); - assert!(output.contains("STATISTICS:")); -} - -#[test] -fn test_format_focused_output_empty() { - let focus_data = FocusedAnalysisData { - focus_symbol: "nonexistent", - definitions: &[], - incoming_chains: &[], - outgoing_chains: &[], - files_analyzed: &[PathBuf::from("test.rs")], - follow_depth: 2, - }; - - let output = Formatter::format_focused_output(&focus_data); - - assert!(output.contains("Symbol 'nonexistent' not found")); -} - -#[test] -fn test_format_results_wrapper() { - let text = "Test output"; - let contents = Formatter::format_results(text.to_string()); - - assert_eq!(contents.len(), 2); - - // Check that both assistant and user content are created - let assistant_content = contents[0].as_text().unwrap(); - assert_eq!(assistant_content.text, "Test output"); - - let user_content = contents[1].as_text().unwrap(); - assert_eq!(user_content.text, "Test output"); -} diff --git a/crates/goose-mcp/src/developer/analyze/tests/go_test.rs b/crates/goose-mcp/src/developer/analyze/tests/go_test.rs deleted file mode 100644 index 2d7fc4881a60..000000000000 --- a/crates/goose-mcp/src/developer/analyze/tests/go_test.rs +++ /dev/null @@ -1,115 +0,0 @@ -use crate::developer::analyze::graph::CallGraph; -use crate::developer::analyze::parser::{ElementExtractor, ParserManager}; -use crate::developer::analyze::types::{AnalysisResult, ReferenceType}; -use std::collections::HashSet; -use std::path::PathBuf; - -fn parse_and_extract(code: &str) -> AnalysisResult { - let manager = ParserManager::new(); - let tree = manager.parse(code, "go").unwrap(); - ElementExtractor::extract_with_depth(&tree, code, "go", "semantic", None).unwrap() -} - -fn build_test_graph(files: Vec<(&str, &str)>) -> CallGraph { - let manager = ParserManager::new(); - let results: Vec<_> = files - .iter() - .map(|(path, code)| { - let tree = manager.parse(code, "go").unwrap(); - let result = - ElementExtractor::extract_with_depth(&tree, code, "go", "semantic", None).unwrap(); - (PathBuf::from(*path), result) - }) - .collect(); - CallGraph::build_from_results(&results) -} - -#[test] -fn test_go_struct_and_method_tracking() { - let code = r#" -package main - -import "myapp/pkg/service" - -type Config struct { - Host string - Port int -} - -type Handler struct { - Cfg *Config - Svc *service.Widget -} - -func (h *Handler) Start() error { - return nil -} - -func (h *Handler) Stop() error { - return nil -} - -func main() { - cfg := Config{Host: "localhost", Port: 8080} - handler := Handler{Cfg: &cfg} - _ = handler.Start() -} -"#; - - let result = parse_and_extract(code); - let graph = build_test_graph(vec![("test.go", code)]); - - assert_eq!(result.class_count, 2); - let struct_names: HashSet<_> = result.classes.iter().map(|c| c.name.as_str()).collect(); - assert!(struct_names.contains("Config")); - assert!(struct_names.contains("Handler")); - - assert_eq!(result.function_count, 3); - let method_names: HashSet<_> = result.functions.iter().map(|f| f.name.as_str()).collect(); - assert!(method_names.contains("Start")); - assert!(method_names.contains("Stop")); - assert!(method_names.contains("main")); - - let handler_methods: Vec<_> = result - .references - .iter() - .filter(|r| { - r.ref_type == ReferenceType::MethodDefinition - && r.associated_type.as_deref() == Some("Handler") - }) - .collect(); - assert!( - handler_methods.len() >= 2, - "Expected at least 2 methods on Handler, found {}", - handler_methods.len() - ); - - let field_type_refs: Vec<_> = result - .references - .iter() - .filter(|r| r.ref_type == ReferenceType::FieldType) - .collect(); - assert!( - !field_type_refs.is_empty(), - "Expected to find field type references" - ); - - let config_literals: Vec<_> = result - .references - .iter() - .filter(|r| r.symbol == "Config" && r.ref_type == ReferenceType::TypeInstantiation) - .collect(); - assert!( - !config_literals.is_empty(), - "Expected to find Config struct literals" - ); - - let incoming = graph.find_incoming_chains("Handler", 1); - assert!( - !incoming.is_empty(), - "Expected to find incoming references to Handler" - ); - - let outgoing = graph.find_outgoing_chains("Handler", 1); - assert!(!outgoing.is_empty(), "Expected to find methods on Handler"); -} diff --git a/crates/goose-mcp/src/developer/analyze/tests/graph_tests.rs b/crates/goose-mcp/src/developer/analyze/tests/graph_tests.rs deleted file mode 100644 index 47b1ed04f65a..000000000000 --- a/crates/goose-mcp/src/developer/analyze/tests/graph_tests.rs +++ /dev/null @@ -1,116 +0,0 @@ -// Tests for the graph module - -use crate::developer::analyze::graph::CallGraph; -use crate::developer::analyze::tests::fixtures::create_test_result_with_calls; -use std::path::PathBuf; - -#[test] -fn test_simple_call_chain() { - let results = vec![( - PathBuf::from("test.rs"), - create_test_result_with_calls(vec!["a", "b", "c"], vec![("a", "b"), ("b", "c")]), - )]; - - let graph = CallGraph::build_from_results(&results); - - // Test incoming chains for 'c' - let chains = graph.find_incoming_chains("c", 2); - assert_eq!(chains.len(), 1); - assert_eq!(chains[0].path.len(), 2); // b->c, a->b - - // Test outgoing chains for 'a' - let chains = graph.find_outgoing_chains("a", 2); - assert_eq!(chains.len(), 1); - assert_eq!(chains[0].path.len(), 2); // a->b, b->c -} - -#[test] -fn test_circular_dependency() { - let results = vec![( - PathBuf::from("test.rs"), - create_test_result_with_calls(vec!["a", "b"], vec![("a", "b"), ("b", "a")]), - )]; - - let graph = CallGraph::build_from_results(&results); - - // Should handle cycles without infinite loop - let chains = graph.find_incoming_chains("a", 3); - assert!(!chains.is_empty()); -} - -#[test] -fn test_empty_graph() { - let graph = CallGraph::new(); - - // Should return empty results for nonexistent symbols - let chains = graph.find_incoming_chains("nonexistent", 2); - assert!(chains.is_empty()); - - let chains = graph.find_outgoing_chains("nonexistent", 2); - assert!(chains.is_empty()); -} - -#[test] -fn test_max_depth_zero() { - let results = vec![( - PathBuf::from("test.rs"), - create_test_result_with_calls(vec!["a", "b"], vec![("a", "b")]), - )]; - - let graph = CallGraph::build_from_results(&results); - - // max_depth of 0 should return empty results - let chains = graph.find_incoming_chains("b", 0); - assert!(chains.is_empty()); - - let chains = graph.find_outgoing_chains("a", 0); - assert!(chains.is_empty()); -} - -#[test] -fn test_multiple_callers() { - let results = vec![( - PathBuf::from("test.rs"), - create_test_result_with_calls( - vec!["a", "b", "c", "target"], - vec![("a", "target"), ("b", "target"), ("c", "target")], - ), - )]; - - let graph = CallGraph::build_from_results(&results); - - // Should find all three callers - let chains = graph.find_incoming_chains("target", 1); - assert_eq!(chains.len(), 3); - - // Each chain should have exactly one call - for chain in chains { - assert_eq!(chain.path.len(), 1); - } -} - -#[test] -fn test_deep_chain() { - let results = vec![( - PathBuf::from("test.rs"), - create_test_result_with_calls( - vec!["a", "b", "c", "d", "e"], - vec![("a", "b"), ("b", "c"), ("c", "d"), ("d", "e")], - ), - )]; - - let graph = CallGraph::build_from_results(&results); - - // Test various depths - let chains = graph.find_incoming_chains("e", 1); - assert_eq!(chains.len(), 1); - assert_eq!(chains[0].path.len(), 1); // Just d->e - - let chains = graph.find_incoming_chains("e", 2); - assert_eq!(chains.len(), 1); - assert_eq!(chains[0].path.len(), 2); // c->d, d->e - - let chains = graph.find_incoming_chains("e", 4); - assert_eq!(chains.len(), 1); - assert_eq!(chains[0].path.len(), 4); // Full chain a->b->c->d->e -} diff --git a/crates/goose-mcp/src/developer/analyze/tests/integration_tests.rs b/crates/goose-mcp/src/developer/analyze/tests/integration_tests.rs deleted file mode 100644 index 4db4a23dc6b6..000000000000 --- a/crates/goose-mcp/src/developer/analyze/tests/integration_tests.rs +++ /dev/null @@ -1,244 +0,0 @@ -// Integration tests for the analyze module - -use crate::developer::analyze::tests::fixtures::create_test_gitignore; -use crate::developer::analyze::{types::AnalyzeParams, CodeAnalyzer}; -use std::fs; -use tempfile::TempDir; - -#[test] -fn test_analyze_python_file() { - let temp_dir = TempDir::new().unwrap(); - let file_path = temp_dir.path().join("test.py"); - fs::write(&file_path, "def main():\n pass").unwrap(); - - let analyzer = CodeAnalyzer::new(); - let params = AnalyzeParams { - path: file_path.to_string_lossy().to_string(), - focus: None, - follow_depth: 2, - max_depth: 3, - ast_recursion_limit: None, - force: false, - }; - - let ignore = create_test_gitignore(); - let result = analyzer.analyze(params, file_path, &ignore); - - assert!(result.is_ok()); - let result = result.unwrap(); - - // Check that we got content back - assert!(!result.content.is_empty()); -} - -#[test] -fn test_analyze_directory() { - let temp_dir = TempDir::new().unwrap(); - let dir_path = temp_dir.path(); - - // Create test files - fs::write(dir_path.join("test1.rs"), "fn main() {}").unwrap(); - fs::write(dir_path.join("test2.py"), "def test(): pass").unwrap(); - - let analyzer = CodeAnalyzer::new(); - let params = AnalyzeParams { - path: dir_path.to_string_lossy().to_string(), - focus: None, - follow_depth: 2, - max_depth: 3, - ast_recursion_limit: None, - force: false, - }; - - let ignore = create_test_gitignore(); - let result = analyzer.analyze(params, dir_path.to_path_buf(), &ignore); - - assert!(result.is_ok()); - let result = result.unwrap(); - - // Check that we got content back - assert!(!result.content.is_empty()); - - // Extract text content and verify it contains expected information - if let Some(text_content) = result.content[0].as_text() { - assert!(text_content.text.contains("SUMMARY:")); - assert!(text_content.text.contains("test1.rs")); - assert!(text_content.text.contains("test2.py")); - } -} - -#[test] -fn test_focused_analysis() { - let temp_dir = TempDir::new().unwrap(); - let file_path = temp_dir.path().join("test.py"); - fs::write( - &file_path, - "def main():\n helper()\n\ndef helper():\n pass", - ) - .unwrap(); - - let analyzer = CodeAnalyzer::new(); - let params = AnalyzeParams { - path: file_path.to_string_lossy().to_string(), - focus: Some("helper".to_string()), - follow_depth: 1, - max_depth: 3, - ast_recursion_limit: None, - force: false, - }; - - let ignore = create_test_gitignore(); - let result = analyzer.analyze(params, file_path, &ignore); - - assert!(result.is_ok()); - let result = result.unwrap(); - - // Check that focused analysis output is generated - if let Some(text_content) = result.content[0].as_text() { - assert!(text_content.text.contains("FOCUSED ANALYSIS: helper")); - assert!(text_content.text.contains("DEFINITIONS:")); - } -} - -#[test] -fn test_analyze_with_cache() { - let temp_dir = TempDir::new().unwrap(); - let file_path = temp_dir.path().join("test.rs"); - fs::write(&file_path, "fn main() {\n println!(\"Hello\");\n}").unwrap(); - - let analyzer = CodeAnalyzer::new(); - let params = AnalyzeParams { - path: file_path.to_string_lossy().to_string(), - focus: None, - follow_depth: 2, - max_depth: 3, - ast_recursion_limit: None, - force: false, - }; - - let ignore = create_test_gitignore(); - - // First analysis - should cache - let result1 = analyzer.analyze(params.clone(), file_path.clone(), &ignore); - assert!(result1.is_ok()); - - // Second analysis - should use cache - let result2 = analyzer.analyze(params, file_path, &ignore); - assert!(result2.is_ok()); - - // Results should be identical - let content1 = result1.unwrap().content[0].as_text().unwrap().text.clone(); - let content2 = result2.unwrap().content[0].as_text().unwrap().text.clone(); - assert_eq!(content1, content2); -} - -#[test] -fn test_analyze_unsupported_file() { - let temp_dir = TempDir::new().unwrap(); - let file_path = temp_dir.path().join("test.txt"); - fs::write(&file_path, "This is not code").unwrap(); - - let analyzer = CodeAnalyzer::new(); - let params = AnalyzeParams { - path: file_path.to_string_lossy().to_string(), - focus: None, - follow_depth: 2, - max_depth: 3, - ast_recursion_limit: None, - force: false, - }; - - let ignore = create_test_gitignore(); - let result = analyzer.analyze(params, file_path, &ignore); - - // Should succeed but return minimal information - assert!(result.is_ok()); -} - -#[test] -fn test_analyze_nonexistent_path() { - let analyzer = CodeAnalyzer::new(); - let params = AnalyzeParams { - path: "/nonexistent/path".to_string(), - focus: None, - follow_depth: 2, - max_depth: 3, - ast_recursion_limit: None, - force: false, - }; - - let ignore = create_test_gitignore(); - let result = analyzer.analyze(params, "/nonexistent/path".into(), &ignore); - - // Should return an error - assert!(result.is_err()); -} - -#[test] -fn test_focused_without_symbol() { - let temp_dir = TempDir::new().unwrap(); - let file_path = temp_dir.path().join("test.py"); - fs::write(&file_path, "def main(): pass").unwrap(); - - let analyzer = CodeAnalyzer::new(); - - // This should trigger focused mode due to having focus parameter - let params = AnalyzeParams { - path: file_path.to_string_lossy().to_string(), - focus: Some("nonexistent_symbol".to_string()), - follow_depth: 1, - max_depth: 3, - ast_recursion_limit: None, - force: false, - }; - - let ignore = create_test_gitignore(); - let result = analyzer.analyze(params, file_path, &ignore); - - assert!(result.is_ok()); - let result = result.unwrap(); - - // Should indicate symbol not found - if let Some(text_content) = result.content[0].as_text() { - assert!(text_content - .text - .contains("Symbol 'nonexistent_symbol' not found")); - } -} - -#[test] -fn test_nested_directory_analysis() { - let temp_dir = TempDir::new().unwrap(); - let dir_path = temp_dir.path(); - - // Create nested structure - let src_dir = dir_path.join("src"); - fs::create_dir(&src_dir).unwrap(); - fs::write(src_dir.join("main.rs"), "fn main() {}").unwrap(); - - let lib_dir = src_dir.join("lib"); - fs::create_dir(&lib_dir).unwrap(); - fs::write(lib_dir.join("utils.rs"), "pub fn util() {}").unwrap(); - - let analyzer = CodeAnalyzer::new(); - let params = AnalyzeParams { - path: dir_path.to_string_lossy().to_string(), - focus: None, - follow_depth: 2, - max_depth: 3, // Increase max_depth to ensure we reach nested files - ast_recursion_limit: None, - force: false, - }; - - let ignore = create_test_gitignore(); - let result = analyzer.analyze(params, dir_path.to_path_buf(), &ignore); - - assert!(result.is_ok()); - let result = result.unwrap(); - - if let Some(text_content) = result.content[0].as_text() { - assert!(text_content.text.contains("main.rs")); - // The directory structure analysis should show both files - assert!(text_content.text.contains("src")); - } -} diff --git a/crates/goose-mcp/src/developer/analyze/tests/large_output_tests.rs b/crates/goose-mcp/src/developer/analyze/tests/large_output_tests.rs deleted file mode 100644 index 5df17ddb79b3..000000000000 --- a/crates/goose-mcp/src/developer/analyze/tests/large_output_tests.rs +++ /dev/null @@ -1,140 +0,0 @@ -use super::fixtures::create_test_gitignore; -use crate::developer::analyze::{types::AnalyzeParams, CodeAnalyzer}; -use std::fs; -use tempfile::TempDir; - -#[test] -fn test_large_output_warning() { - let analyzer = CodeAnalyzer::new(); - let gitignore = create_test_gitignore(); - - // Create a temp directory with many files to trigger the warning - let temp_dir = TempDir::new().unwrap(); - - // Create many Python files with lots of functions to ensure we exceed 1000 lines - // Each file generates about 1 line in structure mode, so we need 1000+ files - for i in 0..1100 { - let file_path = temp_dir.path().join(format!("file{}.py", i)); - // Each file will have multiple functions to generate more output - let mut content = String::new(); - for j in 0..10 { - content.push_str(&format!("def function_{}_{}():\n pass\n\n", i, j)); - } - for j in 0..5 { - content.push_str(&format!( - "class Class_{}_{}:\n def method(self):\n pass\n\n", - i, j - )); - } - fs::write(&file_path, content).unwrap(); - } - - let params = AnalyzeParams { - path: temp_dir.path().to_str().unwrap().to_string(), - focus: None, - follow_depth: 2, - max_depth: 3, - ast_recursion_limit: None, - force: false, // Should trigger warning - }; - - let result = analyzer - .analyze(params, temp_dir.path().to_path_buf(), &gitignore) - .unwrap(); - - // Check that we got a warning, not the actual analysis - assert_eq!(result.content.len(), 1); - if let Some(text_content) = result.content[0].as_text() { - assert!(text_content.text.contains("LARGE OUTPUT WARNING")); - assert!(text_content.text.contains("force=true")); - assert!(text_content.text.contains("exceed")); - } else { - panic!("Expected text content"); - } -} - -#[test] -fn test_force_flag_bypasses_warning() { - let analyzer = CodeAnalyzer::new(); - let gitignore = create_test_gitignore(); - - // Create a temp directory with many files - let temp_dir = TempDir::new().unwrap(); - - // Create many Python files with lots of functions to ensure we exceed 1000 lines - for i in 0..50 { - let file_path = temp_dir.path().join(format!("file{}.py", i)); - // Each file will have multiple functions to generate more output - let mut content = String::new(); - for j in 0..10 { - content.push_str(&format!("def function_{}_{}():\n pass\n\n", i, j)); - } - for j in 0..5 { - content.push_str(&format!( - "class Class_{}_{}:\n def method(self):\n pass\n\n", - i, j - )); - } - fs::write(&file_path, content).unwrap(); - } - - let params = AnalyzeParams { - path: temp_dir.path().to_str().unwrap().to_string(), - focus: None, - follow_depth: 2, - max_depth: 3, - ast_recursion_limit: None, - force: true, // Should bypass warning - }; - - let result = analyzer - .analyze(params, temp_dir.path().to_path_buf(), &gitignore) - .unwrap(); - - // Check that we got the actual analysis, not a warning - if let Some(text_content) = result.content[0].as_text() { - assert!(!text_content.text.contains("LARGE OUTPUT WARNING")); - // Should contain actual file analysis - assert!(text_content.text.contains("file0.py")); - assert!(text_content.text.contains("file29.py")); - } else { - panic!("Expected text content"); - } -} - -#[test] -fn test_small_output_no_warning() { - let analyzer = CodeAnalyzer::new(); - let gitignore = create_test_gitignore(); - - // Create a temp directory with just a few files - let temp_dir = TempDir::new().unwrap(); - - // Create only 2 Python files - should not trigger warning - for i in 0..2 { - let file_path = temp_dir.path().join(format!("file{}.py", i)); - fs::write(&file_path, format!("def function_{}():\n pass\n", i)).unwrap(); - } - - let params = AnalyzeParams { - path: temp_dir.path().to_str().unwrap().to_string(), - focus: None, - follow_depth: 2, - max_depth: 3, - ast_recursion_limit: None, - force: false, // Shouldn't matter for small output - }; - - let result = analyzer - .analyze(params, temp_dir.path().to_path_buf(), &gitignore) - .unwrap(); - - // Check that we got the actual analysis, not a warning - if let Some(text_content) = result.content[0].as_text() { - assert!(!text_content.text.contains("LARGE OUTPUT WARNING")); - assert!(text_content.text.contains("file0.py")); - assert!(text_content.text.contains("file1.py")); - } else { - panic!("Expected text content"); - } -} diff --git a/crates/goose-mcp/src/developer/analyze/tests/mod.rs b/crates/goose-mcp/src/developer/analyze/tests/mod.rs deleted file mode 100644 index 6da0e66d1c26..000000000000 --- a/crates/goose-mcp/src/developer/analyze/tests/mod.rs +++ /dev/null @@ -1,13 +0,0 @@ -// Test modules for the analyze tool - -pub mod cache_tests; -pub mod fixtures; -pub mod formatter_tests; -pub mod go_test; -pub mod graph_tests; -pub mod integration_tests; -pub mod large_output_tests; -pub mod parser_tests; -pub mod ruby_test; -pub mod rust_test; -pub mod traversal_tests; diff --git a/crates/goose-mcp/src/developer/analyze/tests/parser_tests.rs b/crates/goose-mcp/src/developer/analyze/tests/parser_tests.rs deleted file mode 100644 index 0b93dec6aa0f..000000000000 --- a/crates/goose-mcp/src/developer/analyze/tests/parser_tests.rs +++ /dev/null @@ -1,305 +0,0 @@ -// Tests for the parser module - -use crate::developer::analyze::parser::{ElementExtractor, ParserManager}; -use std::sync::Arc; - -#[test] -fn test_parser_initialization() { - let manager = ParserManager::new(); - assert!(manager.get_or_create_parser("python").is_ok()); - assert!(manager.get_or_create_parser("rust").is_ok()); - assert!(manager.get_or_create_parser("unknown").is_err()); -} - -#[test] -fn test_parser_caching() { - let manager = ParserManager::new(); - - // First call creates parser - let parser1 = manager.get_or_create_parser("python").unwrap(); - - // Second call should return cached parser - let parser2 = manager.get_or_create_parser("python").unwrap(); - - // They should be the same Arc - assert!(Arc::ptr_eq(&parser1, &parser2)); -} - -#[test] -fn test_parse_python() { - let manager = ParserManager::new(); - let content = "def hello():\n pass"; - - let tree = manager.parse(content, "python").unwrap(); - assert!(tree.root_node().child_count() > 0); -} - -#[test] -fn test_parse_rust() { - let manager = ParserManager::new(); - let content = "fn main() {\n println!(\"Hello\");\n}"; - - let tree = manager.parse(content, "rust").unwrap(); - assert!(tree.root_node().child_count() > 0); -} - -#[test] -fn test_parse_javascript() { - let manager = ParserManager::new(); - let content = "function hello() {\n console.log('Hello');\n}"; - - let tree = manager.parse(content, "javascript").unwrap(); - assert!(tree.root_node().child_count() > 0); -} - -#[test] -fn test_extract_python_elements() { - let manager = ParserManager::new(); - let content = r#" -import os - -class MyClass: - def method(self): - pass - -def main(): - print("hello") -"#; - - let tree = manager.parse(content, "python").unwrap(); - let result = ElementExtractor::extract_elements(&tree, content, "python").unwrap(); - - assert_eq!(result.function_count, 2); // main and method - assert_eq!(result.class_count, 1); // MyClass - assert_eq!(result.import_count, 1); // import os - assert!(result.main_line.is_some()); -} - -#[test] -fn test_extract_rust_elements() { - let manager = ParserManager::new(); - let content = r#" -use std::fs; - -struct MyStruct { - field: i32, -} - -impl MyStruct { - fn new() -> Self { - Self { field: 0 } - } -} - -fn main() { - let s = MyStruct::new(); -} -"#; - - let tree = manager.parse(content, "rust").unwrap(); - let result = ElementExtractor::extract_elements(&tree, content, "rust").unwrap(); - - assert_eq!(result.function_count, 2); // main and new - assert_eq!(result.class_count, 2); // MyStruct (struct) and MyStruct (impl) - assert_eq!(result.import_count, 1); // use std::fs - assert!(result.main_line.is_some()); -} - -#[test] -fn test_extract_with_depth_structure() { - let manager = ParserManager::new(); - let content = r#" -def func1(): - pass - -def func2(): - func1() -"#; - - let tree = manager.parse(content, "python").unwrap(); - let result = - ElementExtractor::extract_with_depth(&tree, content, "python", "structure", None).unwrap(); - - // In structure mode, detailed vectors should be empty but counts preserved - assert_eq!(result.function_count, 2); - assert!(result.functions.is_empty()); - assert!(result.calls.is_empty()); -} - -#[test] -fn test_extract_with_depth_semantic() { - let manager = ParserManager::new(); - let content = r#" -def func1(): - pass - -def func2(): - func1() -"#; - - let tree = manager.parse(content, "python").unwrap(); - let result = - ElementExtractor::extract_with_depth(&tree, content, "python", "semantic", None).unwrap(); - - // In semantic mode, should have both elements and calls - assert_eq!(result.function_count, 2); - assert_eq!(result.functions.len(), 2); - assert!(!result.calls.is_empty()); - assert_eq!(result.calls[0].callee_name, "func1"); -} - -#[test] -fn test_parse_invalid_syntax() { - let manager = ParserManager::new(); - let content = "def invalid syntax here"; - - // Should still parse (tree-sitter is error-tolerant) - let tree = manager.parse(content, "python"); - assert!(tree.is_ok()); -} - -#[test] -fn test_multiple_languages() { - let manager = ParserManager::new(); - - // Test that we can handle multiple languages in the same manager - assert!(manager.get_or_create_parser("python").is_ok()); - assert!(manager.get_or_create_parser("rust").is_ok()); - assert!(manager.get_or_create_parser("javascript").is_ok()); - assert!(manager.get_or_create_parser("go").is_ok()); - assert!(manager.get_or_create_parser("java").is_ok()); - assert!(manager.get_or_create_parser("kotlin").is_ok()); -} - -#[test] -fn test_parse_kotlin() { - let manager = ParserManager::new(); - let content = r#" -package com.example - -import kotlin.math.* - -class Example(val name: String) { - fun greet() { - println("Hello, $name") - } -} - -fun main() { - val example = Example("World") - example.greet() -} -"#; - - let tree = manager.parse(content, "kotlin").unwrap(); - assert!(tree.root_node().child_count() > 0); -} - -#[test] -fn test_extract_kotlin_elements() { - let manager = ParserManager::new(); - let content = r#" -package com.example - -import kotlin.math.* - -class MyClass { - fun method() { - println("method") - } -} - -fun main() { - println("hello") -} - -fun helper() { - main() -} -"#; - - let tree = manager.parse(content, "kotlin").unwrap(); - let result = ElementExtractor::extract_elements(&tree, content, "kotlin").unwrap(); - - assert_eq!(result.function_count, 3); // main, helper, method - assert_eq!(result.class_count, 1); // MyClass - assert!(result.import_count > 0); // import statements - assert!(result.main_line.is_some()); -} - -#[test] -fn test_language_registry() { - use crate::developer::analyze::languages; - - let supported = vec![ - "python", - "rust", - "javascript", - "typescript", - "go", - "java", - "kotlin", - "swift", - "ruby", - ]; - - for lang in supported { - let info = languages::get_language_info(lang); - assert!(info.is_some(), "Language {} should be supported", lang); - - let info = info.unwrap(); - assert!( - !info.element_query.is_empty(), - "{} missing element_query", - lang - ); - assert!(!info.call_query.is_empty(), "{} missing call_query", lang); - assert!( - !info.function_node_kinds.is_empty(), - "{} missing function_node_kinds", - lang - ); - assert!( - !info.function_name_kinds.is_empty(), - "{} missing function_name_kinds", - lang - ); - } - - let js = languages::get_language_info("javascript").unwrap(); - let ts = languages::get_language_info("typescript").unwrap(); - assert_eq!( - js.element_query, ts.element_query, - "JS/TS should share config" - ); - - let go = languages::get_language_info("go").unwrap(); - assert!( - !go.reference_query.is_empty(), - "Go should have reference tracking" - ); - assert!(go.find_method_for_receiver_handler.is_some()); - - let ruby = languages::get_language_info("ruby").unwrap(); - assert!( - !ruby.reference_query.is_empty(), - "Ruby should have reference tracking" - ); - assert!(ruby.find_method_for_receiver_handler.is_some()); - - let rust = languages::get_language_info("rust").unwrap(); - assert!( - rust.extract_function_name_handler.is_some(), - "Rust should have custom handler" - ); - - let swift = languages::get_language_info("swift").unwrap(); - assert!( - swift.extract_function_name_handler.is_some(), - "Swift should have custom handler" - ); - - assert!(languages::get_language_info("unsupported").is_none()); - assert!(languages::get_language_info("").is_none()); - assert!(languages::get_language_info("C++").is_none()); -} diff --git a/crates/goose-mcp/src/developer/analyze/tests/ruby_test.rs b/crates/goose-mcp/src/developer/analyze/tests/ruby_test.rs deleted file mode 100644 index 5f6616604738..000000000000 --- a/crates/goose-mcp/src/developer/analyze/tests/ruby_test.rs +++ /dev/null @@ -1,259 +0,0 @@ -#[cfg(test)] -mod ruby_tests { - use crate::developer::analyze::graph::CallGraph; - use crate::developer::analyze::parser::{ElementExtractor, ParserManager}; - use crate::developer::analyze::types::ReferenceType; - use std::collections::HashSet; - use std::path::PathBuf; - - #[test] - fn test_ruby_basic_parsing() { - let parser = ParserManager::new(); - let source = r#" -require 'json' - -class MyClass - attr_accessor :name - - def initialize(name) - @name = name - end - - def greet - puts "Hello" - end -end -"#; - - let tree = parser.parse(source, "ruby").unwrap(); - let result = ElementExtractor::extract_elements(&tree, source, "ruby").unwrap(); - - assert_eq!(result.class_count, 1); - assert!(result.classes.iter().any(|c| c.name == "MyClass")); - - assert!(result.function_count > 0); - assert!(result.functions.iter().any(|f| f.name == "initialize")); - assert!(result.functions.iter().any(|f| f.name == "greet")); - - assert!(result.import_count > 0); - } - - #[test] - fn test_ruby_attr_methods() { - let parser = ParserManager::new(); - let source = r#" -class Person - attr_reader :age - attr_writer :status - attr_accessor :name -end -"#; - - let tree = parser.parse(source, "ruby").unwrap(); - let result = ElementExtractor::extract_elements(&tree, source, "ruby").unwrap(); - - assert!( - result.function_count >= 3, - "Expected at least 3 functions from attr_* declarations, got {}", - result.function_count - ); - } - - #[test] - fn test_ruby_require_patterns() { - let parser = ParserManager::new(); - let source = r#" -require 'json' -require_relative 'lib/helper' -"#; - - let tree = parser.parse(source, "ruby").unwrap(); - let result = ElementExtractor::extract_elements(&tree, source, "ruby").unwrap(); - - assert_eq!( - result.import_count, 2, - "Should find both require and require_relative" - ); - } - - #[test] - fn test_ruby_method_calls() { - let parser = ParserManager::new(); - let source = r#" -class Example - def test_method - puts "Hello" - JSON.parse("{}") - object.method_call - end -end -"#; - - let tree = parser.parse(source, "ruby").unwrap(); - let result = - ElementExtractor::extract_with_depth(&tree, source, "ruby", "semantic", None).unwrap(); - - assert!(!result.calls.is_empty(), "Should find method calls"); - assert!(result.calls.iter().any(|c| c.callee_name == "puts")); - } - - #[test] - fn test_ruby_reference_tracking() { - let parser = ParserManager::new(); - let source = r#" -class User - attr_accessor :name - - def initialize(name) - @name = name - end - - def greet - puts "Hello, #{@name}" - end -end - -class Post - STATUS_DRAFT = "draft" - STATUS_PUBLISHED = "published" - - def initialize(title) - @title = title - @status = STATUS_DRAFT - end - - def publish - @status = STATUS_PUBLISHED - notify_users(@status) - end -end - -def main - user = User.new("Alice") - post = Post.new("My Title") - post.publish -end -"#; - - let tree = parser.parse(source, "ruby").unwrap(); - let result = - ElementExtractor::extract_with_depth(&tree, source, "ruby", "semantic", None).unwrap(); - - assert_eq!(result.class_count, 2); - let class_names: HashSet<_> = result.classes.iter().map(|c| c.name.as_str()).collect(); - assert!(class_names.contains("User")); - assert!(class_names.contains("Post")); - - assert!(result.function_count > 0); - let method_names: HashSet<_> = result.functions.iter().map(|f| f.name.as_str()).collect(); - assert!(method_names.contains("initialize")); - assert!(method_names.contains("greet")); - assert!(method_names.contains("publish")); - - let constant_refs: Vec<_> = result - .references - .iter() - .filter(|r| r.symbol == "STATUS_DRAFT" || r.symbol == "STATUS_PUBLISHED") - .collect(); - assert!( - !constant_refs.is_empty(), - "Expected to find constant references" - ); - - let instantiations: Vec<_> = result - .references - .iter() - .filter(|r| r.ref_type == ReferenceType::TypeInstantiation) - .collect(); - assert!( - instantiations.len() >= 2, - "Expected at least 2 class instantiations (User.new, Post.new)" - ); - let instantiated_types: HashSet<_> = - instantiations.iter().map(|r| r.symbol.as_str()).collect(); - assert!(instantiated_types.contains("User")); - assert!(instantiated_types.contains("Post")); - - let constant_usages: Vec<_> = result - .references - .iter() - .filter(|r| r.symbol == "STATUS_DRAFT" || r.symbol == "STATUS_PUBLISHED") - .collect(); - assert!( - !constant_usages.is_empty(), - "Expected to find STATUS_* constant usages" - ); - } - - #[test] - fn test_ruby_call_chains() { - let parser = ParserManager::new(); - - let file1 = r#" -class User - def initialize(name) - @name = name - end - - def display - format_output(@name) - end - - def format_output(text) - "User: #{text}" - end -end -"#; - - let file2 = r#" -require_relative 'user' - -def create_user(name) - User.new(name) -end - -def show_user(name) - user = create_user(name) - user.display -end -"#; - - let tree1 = parser.parse(file1, "ruby").unwrap(); - let result1 = - ElementExtractor::extract_with_depth(&tree1, file1, "ruby", "semantic", None).unwrap(); - - let tree2 = parser.parse(file2, "ruby").unwrap(); - let result2 = - ElementExtractor::extract_with_depth(&tree2, file2, "ruby", "semantic", None).unwrap(); - - let results = vec![ - (PathBuf::from("user.rb"), result1), - (PathBuf::from("main.rb"), result2), - ]; - let graph = CallGraph::build_from_results(&results); - - let incoming_user = graph.find_incoming_chains("User", 1); - assert!( - !incoming_user.is_empty(), - "Expected incoming references to User class" - ); - - let outgoing_display = graph.find_outgoing_chains("display", 1); - assert!( - !outgoing_display.is_empty(), - "Expected display to call format_output" - ); - - let outgoing_create = graph.find_outgoing_chains("create_user", 2); - assert!( - !outgoing_create.is_empty(), - "Expected create_user to have call chains" - ); - - let incoming_create = graph.find_incoming_chains("create_user", 1); - assert!( - !incoming_create.is_empty(), - "Expected show_user to call create_user" - ); - } -} diff --git a/crates/goose-mcp/src/developer/analyze/tests/rust_test.rs b/crates/goose-mcp/src/developer/analyze/tests/rust_test.rs deleted file mode 100644 index d0bae3c91d4c..000000000000 --- a/crates/goose-mcp/src/developer/analyze/tests/rust_test.rs +++ /dev/null @@ -1,179 +0,0 @@ -use crate::developer::analyze::graph::CallGraph; -use crate::developer::analyze::parser::{ElementExtractor, ParserManager}; -use crate::developer::analyze::types::{AnalysisResult, ReferenceType}; -use std::collections::HashSet; -use std::path::PathBuf; - -fn parse_and_extract(code: &str) -> AnalysisResult { - let manager = ParserManager::new(); - let tree = manager.parse(code, "rust").unwrap(); - ElementExtractor::extract_with_depth(&tree, code, "rust", "semantic", None).unwrap() -} - -fn build_test_graph(files: Vec<(&str, &str)>) -> CallGraph { - let manager = ParserManager::new(); - let results: Vec<_> = files - .iter() - .map(|(path, code)| { - let tree = manager.parse(code, "rust").unwrap(); - let result = - ElementExtractor::extract_with_depth(&tree, code, "rust", "semantic", None) - .unwrap(); - (PathBuf::from(*path), result) - }) - .collect(); - CallGraph::build_from_results(&results) -} - -#[test] -fn test_rust_self_parameter_type_resolution() { - // Test that self parameters correctly resolve to their impl type - let code = r#" -struct MyStruct { - value: i32, -} - -impl MyStruct { - fn method_with_self(&self) -> i32 { - self.value - } - - fn method_with_mut_self(&mut self) { - self.value += 1; - } - - fn associated_function() -> Self { - MyStruct { value: 0 } - } -} -"#; - - let result = parse_and_extract(code); - - // Find method references with self parameters - let self_methods: Vec<_> = result - .references - .iter() - .filter(|r| r.ref_type == ReferenceType::MethodDefinition) - .collect(); - - // Should find both methods with self parameters - assert_eq!( - self_methods.len(), - 2, - "Expected 2 methods with self parameters" - ); - - // Both should be associated with MyStruct - for method_ref in &self_methods { - assert_eq!( - method_ref.associated_type.as_deref(), - Some("MyStruct"), - "Method {} should be associated with MyStruct", - method_ref.symbol - ); - } - - // Verify the specific methods - let method_names: HashSet<_> = self_methods.iter().map(|r| r.symbol.as_str()).collect(); - assert!(method_names.contains("method_with_self")); - assert!(method_names.contains("method_with_mut_self")); -} - -#[test] -fn test_rust_struct_and_impl_tracking() { - let code = r#" -struct Config { - host: String, - port: u16, -} - -struct Handler { - cfg: Config, -} - -impl Handler { - fn new(cfg: Config) -> Self { - Handler { cfg } - } - - fn start(&self) -> Result<(), String> { - Ok(()) - } -} - -fn main() { - let cfg = Config { host: "localhost".to_string(), port: 8080 }; - let handler = Handler::new(cfg); - let _ = handler.start(); -} -"#; - - let result = parse_and_extract(code); - let graph = build_test_graph(vec![("test.rs", code)]); - - // Test struct extraction (includes impl blocks) - assert_eq!(result.class_count, 3); // Config, Handler, impl Handler - let struct_names: HashSet<_> = result.classes.iter().map(|c| c.name.as_str()).collect(); - assert!(struct_names.contains("Config")); - assert!(struct_names.contains("Handler")); - - // Test method extraction - let method_names: HashSet<_> = result.functions.iter().map(|f| f.name.as_str()).collect(); - assert!(method_names.contains("new")); - assert!(method_names.contains("start")); - assert!(method_names.contains("main")); - - // Test method-to-type associations (only methods with self parameter) - let handler_methods: Vec<_> = result - .references - .iter() - .filter(|r| { - r.ref_type == ReferenceType::MethodDefinition - && r.associated_type.as_deref() == Some("Handler") - }) - .collect(); - assert!( - !handler_methods.is_empty(), - "Expected at least 1 method on Handler (start), found {}", - handler_methods.len() - ); - - // Verify the method is 'start' (new doesn't have self, so it's not tracked) - assert!( - handler_methods.iter().any(|r| r.symbol == "start"), - "Expected to find 'start' method on Handler" - ); - - // Test field type tracking - let field_type_refs: Vec<_> = result - .references - .iter() - .filter(|r| r.ref_type == ReferenceType::FieldType) - .collect(); - assert!( - !field_type_refs.is_empty(), - "Expected to find field type references" - ); - - // Test struct instantiation - let config_literals: Vec<_> = result - .references - .iter() - .filter(|r| r.symbol == "Config" && r.ref_type == ReferenceType::TypeInstantiation) - .collect(); - assert!( - !config_literals.is_empty(), - "Expected to find Config struct literals" - ); - - // Test call graph integration - let incoming = graph.find_incoming_chains("Handler", 1); - assert!( - !incoming.is_empty(), - "Expected to find incoming references to Handler" - ); - - let outgoing = graph.find_outgoing_chains("Handler", 1); - assert!(!outgoing.is_empty(), "Expected to find methods on Handler"); -} diff --git a/crates/goose-mcp/src/developer/analyze/tests/traversal_tests.rs b/crates/goose-mcp/src/developer/analyze/tests/traversal_tests.rs deleted file mode 100644 index 8e99dc0a531d..000000000000 --- a/crates/goose-mcp/src/developer/analyze/tests/traversal_tests.rs +++ /dev/null @@ -1,190 +0,0 @@ -// Tests for the traversal module - -use crate::developer::analyze::tests::fixtures::create_test_gitignore; -use crate::developer::analyze::traversal::FileTraverser; -use ignore::gitignore::Gitignore; -use std::fs; -use std::path::Path; -use tempfile::TempDir; - -#[test] -fn test_is_ignored() { - // Create a temporary directory for testing - let temp_dir = TempDir::new().unwrap(); - let dir_path = temp_dir.path(); - - // Create actual files and directories to test - fs::write(dir_path.join("test.log"), "log content").unwrap(); - fs::write(dir_path.join("test.rs"), "fn main() {}").unwrap(); - - // Create gitignore that ignores .log files - let mut builder = ignore::gitignore::GitignoreBuilder::new(dir_path); - builder.add_line(None, "*.log").unwrap(); - let ignore = builder.build().unwrap(); - - let traverser = FileTraverser::new(&ignore); - - // Test that .log files are ignored and .rs files are not - assert!(traverser.is_ignored(&dir_path.join("test.log"))); - assert!(!traverser.is_ignored(&dir_path.join("test.rs"))); -} - -#[test] -fn test_validate_path() { - let ignore = create_test_gitignore(); - let traverser = FileTraverser::new(&ignore); - - // Test nonexistent path - assert!(traverser - .validate_path(Path::new("/nonexistent/path")) - .is_err()); - - // Test ignored path - assert!(traverser.validate_path(Path::new("test.log")).is_err()); -} - -#[test] -fn test_collect_files() { - let temp_dir = TempDir::new().unwrap(); - let dir_path = temp_dir.path(); - - // Create test files - fs::write(dir_path.join("test.rs"), "fn main() {}").unwrap(); - fs::write(dir_path.join("test.py"), "def main(): pass").unwrap(); - fs::write(dir_path.join("test.txt"), "not code").unwrap(); - - // Create subdirectory with file - let sub_dir = dir_path.join("src"); - fs::create_dir(&sub_dir).unwrap(); - fs::write(sub_dir.join("lib.rs"), "pub fn test() {}").unwrap(); - - let ignore = Gitignore::empty(); - let traverser = FileTraverser::new(&ignore); - - let files = traverser.collect_files_for_focused(dir_path, 0).unwrap(); - - // Should find .rs and .py files but not .txt - assert_eq!(files.len(), 3); - assert!(files.iter().any(|p| p.ends_with("test.rs"))); - assert!(files.iter().any(|p| p.ends_with("test.py"))); - assert!(files.iter().any(|p| p.ends_with("lib.rs"))); -} - -#[test] -fn test_max_depth() { - let temp_dir = TempDir::new().unwrap(); - let dir_path = temp_dir.path(); - - // Create nested structure - fs::write(dir_path.join("root.rs"), "").unwrap(); - - let level1 = dir_path.join("level1"); - fs::create_dir(&level1).unwrap(); - fs::write(level1.join("file1.rs"), "").unwrap(); - - let level2 = level1.join("level2"); - fs::create_dir(&level2).unwrap(); - fs::write(level2.join("file2.rs"), "").unwrap(); - - let level3 = level2.join("level3"); - fs::create_dir(&level3).unwrap(); - fs::write(level3.join("file3.rs"), "").unwrap(); - - let ignore = Gitignore::empty(); - let traverser = FileTraverser::new(&ignore); - - // Test that limiting depth works - exact counts may vary based on implementation - // The important thing is that deeper files are excluded with lower max_depth - - // With a small max_depth, we should find fewer files - let files_limited = traverser.collect_files_for_focused(dir_path, 2).unwrap(); - - // With unlimited depth, we should find all files - let files_unlimited = traverser.collect_files_for_focused(dir_path, 0).unwrap(); - - // The unlimited search should find more files than the limited one - assert!( - files_unlimited.len() > files_limited.len(), - "Unlimited depth should find more files than limited depth" - ); - - // Should always find the root file - assert!(files_unlimited.iter().any(|p| p.ends_with("root.rs"))); - - // With unlimited, should find all 4 files - assert_eq!( - files_unlimited.len(), - 4, - "Should find all 4 files with unlimited depth" - ); -} - -#[test] -fn test_symlink_handling() { - let temp_dir = TempDir::new().unwrap(); - let dir_path = temp_dir.path(); - - // Create a file and directory - fs::write(dir_path.join("target.rs"), "fn main() {}").unwrap(); - let target_dir = dir_path.join("target_dir"); - fs::create_dir(&target_dir).unwrap(); - fs::write(target_dir.join("inner.rs"), "fn test() {}").unwrap(); - - // Create symlinks (if supported by the OS) - #[cfg(unix)] - { - use std::os::unix::fs::symlink; - let _ = symlink(dir_path.join("target.rs"), dir_path.join("link.rs")); - let _ = symlink(&target_dir, dir_path.join("link_dir")); - } - - let ignore = Gitignore::empty(); - let traverser = FileTraverser::new(&ignore); - - // Collect files - symlinks should be handled appropriately - let files = traverser.collect_files_for_focused(dir_path, 0).unwrap(); - - // Should find the actual files - assert!(files.iter().any(|p| p.ends_with("target.rs"))); - assert!(files.iter().any(|p| p.ends_with("inner.rs"))); -} - -#[test] -fn test_empty_directory() { - let temp_dir = TempDir::new().unwrap(); - let dir_path = temp_dir.path(); - - let ignore = Gitignore::empty(); - let traverser = FileTraverser::new(&ignore); - - let files = traverser.collect_files_for_focused(dir_path, 0).unwrap(); - - assert_eq!(files.len(), 0); -} - -#[test] -fn test_gitignore_patterns() { - let temp_dir = TempDir::new().unwrap(); - let dir_path = temp_dir.path(); - - // Create files - fs::write(dir_path.join("test.log"), "log").unwrap(); - fs::write(dir_path.join("debug.log"), "debug").unwrap(); - fs::write(dir_path.join("test.rs"), "fn main() {}").unwrap(); - fs::write(dir_path.join("main.py"), "def main(): pass").unwrap(); - - // Create gitignore that only ignores .log files - let mut builder = ignore::gitignore::GitignoreBuilder::new(dir_path); - builder.add_line(None, "*.log").unwrap(); - let ignore = builder.build().unwrap(); - - let traverser = FileTraverser::new(&ignore); - - let files = traverser.collect_files_for_focused(dir_path, 0).unwrap(); - - // Should find .rs and .py files, but not .log files - assert_eq!(files.len(), 2, "Should find 2 non-log files"); - assert!(files.iter().any(|p| p.ends_with("test.rs"))); - assert!(files.iter().any(|p| p.ends_with("main.py"))); - assert!(!files.iter().any(|p| p.ends_with(".log"))); -} diff --git a/crates/goose-mcp/src/developer/analyze/traversal.rs b/crates/goose-mcp/src/developer/analyze/traversal.rs deleted file mode 100644 index bc0b3701be75..000000000000 --- a/crates/goose-mcp/src/developer/analyze/traversal.rs +++ /dev/null @@ -1,171 +0,0 @@ -use ignore::gitignore::Gitignore; -use rayon::prelude::*; -use rmcp::model::{ErrorCode, ErrorData}; -use std::path::{Path, PathBuf}; - -use crate::developer::analyze::types::{AnalysisResult, EntryType}; -use crate::developer::lang; - -/// Handles file system traversal with ignore patterns -pub struct FileTraverser<'a> { - ignore_patterns: &'a Gitignore, -} - -impl<'a> FileTraverser<'a> { - /// Create a new file traverser with the given ignore patterns - pub fn new(ignore_patterns: &'a Gitignore) -> Self { - Self { ignore_patterns } - } - - /// Check if a path should be ignored - pub fn is_ignored(&self, path: &Path) -> bool { - let ignored = self.ignore_patterns.matched(path, false).is_ignore(); - if ignored { - tracing::trace!("Path {:?} is ignored", path); - } - ignored - } - - /// Validate that a path exists and is not ignored - pub fn validate_path(&self, path: &Path) -> Result<(), ErrorData> { - // Check if path is ignored - if self.is_ignored(path) { - return Err(ErrorData::new( - ErrorCode::INVALID_PARAMS, - format!( - "Access to '{}' is restricted by .gooseignore", - path.display() - ), - None, - )); - } - - // Check if path exists - if !path.exists() { - return Err(ErrorData::new( - ErrorCode::INVALID_PARAMS, - format!("Path '{}' does not exist", path.display()), - None, - )); - } - - Ok(()) - } - - /// Collect all files for focused analysis - pub fn collect_files_for_focused( - &self, - path: &Path, - max_depth: u32, - ) -> Result, ErrorData> { - tracing::debug!( - "Collecting files from {:?} with max_depth {}", - path, - max_depth - ); - - if max_depth == 0 { - tracing::warn!("Unlimited depth traversal requested for {:?}", path); - } - - let files = self.collect_files_recursive(path, 0, max_depth)?; - - tracing::info!("Collected {} files from {:?}", files.len(), path); - Ok(files) - } - - /// Recursively collect files - fn collect_files_recursive( - &self, - path: &Path, - current_depth: u32, - max_depth: u32, - ) -> Result, ErrorData> { - let mut files = Vec::new(); - - // Check if we're at a file (base case) - if path.is_file() { - let lang = lang::get_language_identifier(path); - if !lang.is_empty() { - tracing::trace!("Including file {:?} (language: {})", path, lang); - files.push(path.to_path_buf()); - } - return Ok(files); - } - - // max_depth of 0 means unlimited depth - // current_depth starts at 0, max_depth is the number of directory levels to traverse - if max_depth > 0 && current_depth >= max_depth { - tracing::trace!("Reached max depth {} at {:?}", max_depth, path); - return Ok(files); - } - - let entries = std::fs::read_dir(path).map_err(|e| { - tracing::error!("Failed to read directory {:?}: {}", path, e); - ErrorData::new( - ErrorCode::INTERNAL_ERROR, - format!("Failed to read directory: {}", e), - None, - ) - })?; - - for entry in entries { - let entry = entry.map_err(|e| { - ErrorData::new( - ErrorCode::INTERNAL_ERROR, - format!("Failed to read directory entry: {}", e), - None, - ) - })?; - - let entry_path = entry.path(); - - // Skip ignored paths - if self.is_ignored(&entry_path) { - continue; - } - - if entry_path.is_file() { - // Only include supported file types - let lang = lang::get_language_identifier(&entry_path); - if !lang.is_empty() { - tracing::trace!("Including file {:?} (language: {})", entry_path, lang); - files.push(entry_path); - } - } else if entry_path.is_dir() { - // Recurse into subdirectory - let mut sub_files = - self.collect_files_recursive(&entry_path, current_depth + 1, max_depth)?; - files.append(&mut sub_files); - } - } - - Ok(files) - } - - /// Collect directory results for analysis with parallel processing - pub fn collect_directory_results( - &self, - path: &Path, - max_depth: u32, - analyze_file: F, - ) -> Result, ErrorData> - where - F: Fn(&Path) -> Result + Sync, - { - tracing::debug!("Collecting directory results from {:?}", path); - - // First collect all files to analyze - let files_to_analyze = self.collect_files_recursive(path, 0, max_depth)?; - - // Then analyze them in parallel using Rayon - let results: Result, ErrorData> = files_to_analyze - .par_iter() - .map(|file_path| { - analyze_file(file_path).map(|result| (file_path.clone(), EntryType::File(result))) - }) - .collect(); - - results - } -} diff --git a/crates/goose-mcp/src/developer/analyze/types.rs b/crates/goose-mcp/src/developer/analyze/types.rs deleted file mode 100644 index ec39af645dbd..000000000000 --- a/crates/goose-mcp/src/developer/analyze/types.rs +++ /dev/null @@ -1,176 +0,0 @@ -use rmcp::schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use std::path::PathBuf; - -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -pub struct AnalyzeParams { - pub path: String, - - pub focus: Option, - - /// Call graph depth. 0=where defined, 1=direct callers/callees, 2+=transitive chains - #[serde(default = "default_follow_depth")] - pub follow_depth: u32, - - /// Directory recursion limit. 0=unlimited (warning: fails on binary files) - #[serde(default = "default_max_depth")] - pub max_depth: u32, - - /// Maximum depth for recursive AST traversal (prevents stack overflow in deeply nested code) - #[serde(default)] - pub ast_recursion_limit: Option, - - /// Allow large outputs without warning (default: false) - #[serde(default)] - pub force: bool, -} - -fn default_follow_depth() -> u32 { - 2 -} - -fn default_max_depth() -> u32 { - 3 -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AnalysisResult { - pub functions: Vec, - pub classes: Vec, - pub imports: Vec, - // Semantic analysis fields - pub calls: Vec, - pub references: Vec, - // Structure mode fields (for compact overview) - pub function_count: usize, - pub class_count: usize, - pub line_count: usize, - pub import_count: usize, - pub main_line: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct FunctionInfo { - pub name: String, - pub line: usize, - pub params: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ClassInfo { - pub name: String, - pub line: usize, - pub methods: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CallInfo { - pub caller_name: Option, - pub callee_name: String, - pub line: usize, - pub column: usize, - pub context: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ReferenceInfo { - pub symbol: String, - pub ref_type: ReferenceType, - pub line: usize, - pub context: String, - /// For method definitions, this stores the type to which the method belongs - /// For type usage, this is None - pub associated_type: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub enum ReferenceType { - /// Type/class/struct definition - Definition, - /// Method or function definition on a type (use associated_type to link to type) - MethodDefinition, - /// Function call or method call - Call, - /// Type instantiation (e.g., struct literal, class constructor) - TypeInstantiation, - /// Type used in field declaration - FieldType, - /// Type used in variable declaration - VariableType, - /// Type used in function/method parameter - ParameterType, - /// Import statement - Import, -} - -// Entry type for directory results - cleaner than overloading AnalysisResult -#[derive(Debug, Clone)] -pub enum EntryType { - File(AnalysisResult), - Directory, - SymlinkDir(PathBuf), - SymlinkFile(PathBuf), -} - -// Type alias for complex query results -pub type ElementQueryResult = (Vec, Vec, Vec); - -#[derive(Debug, Clone)] -pub struct CallChain { - pub path: Vec<(PathBuf, usize, String, String)>, // (file, line, from, to) -} - -// Data structure to pass to format_focused_output_with_chains -pub struct FocusedAnalysisData<'a> { - pub focus_symbol: &'a str, - pub follow_depth: u32, - pub files_analyzed: &'a [PathBuf], - pub definitions: &'a [(PathBuf, usize)], - pub incoming_chains: &'a [CallChain], - pub outgoing_chains: &'a [CallChain], -} - -/// Analysis modes -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum AnalysisMode { - Structure, // Directory overview - Semantic, // File details - Focused, // Symbol tracking -} - -impl AnalysisMode { - pub fn as_str(&self) -> &str { - match self { - AnalysisMode::Structure => "structure", - AnalysisMode::Semantic => "semantic", - AnalysisMode::Focused => "focused", - } - } - - pub fn parse(s: &str) -> Self { - match s { - "structure" => AnalysisMode::Structure, - "semantic" => AnalysisMode::Semantic, - "focused" => AnalysisMode::Focused, - _ => AnalysisMode::Structure, - } - } -} - -impl AnalysisResult { - /// Create an empty analysis result with only line count - pub fn empty(line_count: usize) -> Self { - Self { - functions: vec![], - classes: vec![], - imports: vec![], - calls: vec![], - references: vec![], - function_count: 0, - class_count: 0, - line_count, - import_count: 0, - main_line: None, - } - } -} diff --git a/crates/goose-mcp/src/developer/editor_models/EDITOR_API_EXAMPLE.md b/crates/goose-mcp/src/developer/editor_models/EDITOR_API_EXAMPLE.md deleted file mode 100644 index 4dd395eb8d58..000000000000 --- a/crates/goose-mcp/src/developer/editor_models/EDITOR_API_EXAMPLE.md +++ /dev/null @@ -1,84 +0,0 @@ -# Enhanced Code Editing with AI Models - -The developer extension now supports using AI models for enhanced code editing through the `str_replace` command. When configured, it will use an AI model to intelligently apply code changes instead of simple string replacement. - -## Configuration - -Set these environment variables to enable AI-powered code editing: - -```bash -export GOOSE_EDITOR_API_KEY="your-api-key-here" -export GOOSE_EDITOR_HOST="https://api.openai.com/v1" -export GOOSE_EDITOR_MODEL="gpt-4o" -``` - -**All three environment variables must be set and non-empty for the feature to activate.** - -### Supported Providers - -Any OpenAI-compatible API endpoint should work. Examples: - -**OpenAI:** -```bash -export GOOSE_EDITOR_API_KEY="sk-..." -export GOOSE_EDITOR_HOST="https://api.openai.com/v1" -export GOOSE_EDITOR_MODEL="gpt-4o" -``` - -**Anthropic (via OpenAI-compatible proxy):** -```bash -export GOOSE_EDITOR_API_KEY="sk-ant-..." -export GOOSE_EDITOR_HOST="https://api.anthropic.com/v1" -export GOOSE_EDITOR_MODEL="claude-sonnet-4-20250514" -``` - -**Morph:** -```bash -export GOOSE_EDITOR_API_KEY="sk-..." -export GOOSE_EDITOR_HOST="https://api.morphllm.com/v1" -export GOOSE_EDITOR_MODEL="morph-v3-large" -``` - -**Relace** -```bash -export GOOSE_EDITOR_API_KEY="rlc-..." -export GOOSE_EDITOR_HOST="https://instantapply.endpoint.relace.run/v1/apply" -export GOOSE_EDITOR_MODEL="auto" -``` - -**Local/Custom endpoints:** -```bash -export GOOSE_EDITOR_API_KEY="your-key" -export GOOSE_EDITOR_HOST="http://localhost:8000/v1" -export GOOSE_EDITOR_MODEL="your-model" -``` - -## How it works - -When you use the `str_replace` command in the text editor: - -1. **Configuration check**: The system first checks if all three environment variables are properly set and non-empty. - -2. **With AI enabled**: If configured, the system sends the original code and your requested change to the configured AI model, which intelligently applies the change while maintaining code structure, formatting, and context. - -3. **Fallback**: If the AI API is not configured or the API call fails, it falls back to simple string replacement as before. - -4. **User feedback**: The first time you use `str_replace` without AI configuration, you'll see a helpful message explaining how to enable the feature. - -## Benefits - -- **Context-aware editing**: The AI understands code structure and can make more intelligent changes -- **Better formatting**: Maintains consistent code style and formatting -- **Error prevention**: Can catch and fix potential issues during the edit -- **Flexible**: Works with any OpenAI-compatible API -- **Clean implementation**: Uses proper control flow instead of exception handling for configuration checks - -## Implementation Details - -The implementation follows idiomatic Rust patterns: -- Environment variables are checked upfront before attempting API calls -- No exceptions are used for normal control flow -- Clear separation between configured and unconfigured states -- Graceful fallback behavior in all cases - -The feature is completely optional and backwards compatible - if not configured, the system works exactly as before with simple string replacement. \ No newline at end of file diff --git a/crates/goose-mcp/src/developer/editor_models/mod.rs b/crates/goose-mcp/src/developer/editor_models/mod.rs deleted file mode 100644 index 5b8fd1adc26a..000000000000 --- a/crates/goose-mcp/src/developer/editor_models/mod.rs +++ /dev/null @@ -1,98 +0,0 @@ -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; - -/// Enum for different editor models that can perform intelligent code editing -#[derive(Debug, Clone)] -pub enum EditorModel { - MorphLLM(MorphLLMEditor), - OpenAICompatible(OpenAICompatibleEditor), - Relace(RelaceEditor), -} - -impl EditorModel { - /// Call the editor API to perform intelligent code replacement - pub async fn edit_code( - &self, - original_code: &str, - old_str: &str, - update_snippet: &str, - ) -> Result { - 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 - } - } - } - - /// Get the description for the str_replace command when this editor is active - 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(), - } - } -} - -/// Trait for individual editor implementations -pub trait EditorModelImpl { - /// Call the editor API to perform intelligent code replacement - async fn edit_code( - &self, - original_code: &str, - old_str: &str, - update_snippet: &str, - ) -> Result; - - /// Get the description for the str_replace command when this editor is active - fn get_str_replace_description(&self) -> &'static str; -} - -/// Factory function to create the appropriate editor model based on environment variables -pub fn create_editor_model() -> Option { - // Don't use Editor API during tests - if cfg!(test) { - return None; - } - - // Check if basic editor API variables are set - 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; - } - - // Determine which editor to use based on the host - 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, - ))) - } -} diff --git a/crates/goose-mcp/src/developer/editor_models/morphllm_editor.rs b/crates/goose-mcp/src/developer/editor_models/morphllm_editor.rs deleted file mode 100644 index 44170a48cf81..000000000000 --- a/crates/goose-mcp/src/developer/editor_models/morphllm_editor.rs +++ /dev/null @@ -1,310 +0,0 @@ -use super::EditorModelImpl; -use anyhow::Result; -use reqwest::Client; -use serde_json::{json, Value}; - -/// MorphLLM editor that uses the standard chat completions format -#[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, - } - } - - /// Extract content between XML tags - fn extract_tag_content(text: &str, tag_name: &str) -> Option { - 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") { - // Look for instruction tags which help provide hints - if let Some(instruction_content) = - Self::extract_tag_content(update_snippet, "instruction") - { - // Both code and instruction tags found - return format!( - "{}\n{}\n{}", - instruction_content, original_code, code_content - ); - } - // Only code tags found, no instruction - return format!( - "{}\n{}", - original_code, code_content - ); - } - format!( - "{}\n{}", - original_code, update_snippet - ) - } -} - -impl EditorModelImpl for MorphLLMEditor { - async fn edit_code( - &self, - original_code: &str, - _old_str: &str, - update_snippet: &str, - ) -> Result { - // Construct the full URL - 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) - }; - - // Create the client - let client = Client::new(); - - // Parse update_snippet for and tags - let user_prompt = Self::format_user_prompt(original_code, update_snippet); - - // Prepare the request body for OpenAI-compatible API - let body = json!({ - "model": self.model, - "messages": [ - { - "role": "user", - "content": user_prompt - } - ] - }); - - // Send the request - 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)), - }; - - // Process the response - if !response.status().is_success() { - return Err(format!("API error: HTTP {}", response.status())); - } - - // Parse the JSON response - let response_json: Value = match response.json().await { - Ok(json) => json, - Err(e) => return Err(format!("Failed to parse response: {}", e)), - }; - - // Extract the content from the response - 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: - - - new code here you want to add - - - adding new code with error handling - - - 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. - " - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_extract_tag_content_valid() { - let text = "fn main() {}"; - let result = MorphLLMEditor::extract_tag_content(text, "code"); - assert_eq!(result, Some("fn main() {}".to_string())); - } - - #[test] - fn test_extract_tag_content_with_whitespace() { - let text = " I am adding a print statement "; - let result = MorphLLMEditor::extract_tag_content(text, "instruction"); - assert_eq!(result, Some("I am adding a print statement".to_string())); - } - - #[test] - fn test_extract_tag_content_invalid_order() { - let text = "Invalid"; - let result = MorphLLMEditor::extract_tag_content(text, "code"); - assert_eq!(result, None); - } - - #[test] - fn test_extract_tag_content_missing_end_tag() { - let text = "fn main() {}"; - let result = MorphLLMEditor::extract_tag_content(text, "code"); - assert_eq!(result, None); - } - - #[test] - fn test_extract_tag_content_missing_start_tag() { - let text = "fn main() {}"; - let result = MorphLLMEditor::extract_tag_content(text, "code"); - assert_eq!(result, None); - } - - #[test] - fn test_extract_tag_content_nested_tags() { - let text = "fn main() { nested }"; - let result = MorphLLMEditor::extract_tag_content(text, "code"); - assert_eq!(result, Some("fn main() { nested".to_string())); - } - - #[test] - fn test_format_user_prompt_no_tags() { - let original_code = "fn main() {}"; - let update_snippet = "Add error handling"; - let result = MorphLLMEditor::format_user_prompt(original_code, update_snippet); - assert_eq!( - result, - "fn main() {}\nAdd error handling" - ); - } - - #[test] - fn test_format_user_prompt_with_code_tags_only() { - let original_code = "fn main() {}"; - let update_snippet = "fn main() { println!(\"Hello\"); }"; - let result = MorphLLMEditor::format_user_prompt(original_code, update_snippet); - assert_eq!( - result, - "fn main() {}\nfn main() { println!(\"Hello\"); }" - ); - } - - #[test] - fn test_format_user_prompt_with_both_tags() { - let original_code = "fn main() {}"; - let update_snippet = "fn main() { println!(\"Hello\"); }I am adding a print statement"; - let result = MorphLLMEditor::format_user_prompt(original_code, update_snippet); - assert_eq!( - result, - "I am adding a print statement\nfn main() {}\nfn main() { println!(\"Hello\"); }" - ); - } - - #[test] - fn test_format_user_prompt_with_whitespace() { - let original_code = "fn main() {}"; - let update_snippet = " fn main() { println!(\"Hello\"); } I am adding a print statement "; - let result = MorphLLMEditor::format_user_prompt(original_code, update_snippet); - assert_eq!( - result, - "I am adding a print statement\nfn main() {}\nfn main() { println!(\"Hello\"); }" - ); - } - - #[test] - fn test_format_user_prompt_invalid_code_tags() { - let original_code = "fn main() {}"; - let update_snippet = "Invalid"; - let result = MorphLLMEditor::format_user_prompt(original_code, update_snippet); - assert_eq!( - result, - "fn main() {}\nInvalid" - ); - } - - #[test] - fn test_format_user_prompt_invalid_instruction_tags() { - let original_code = "fn main() {}"; - let update_snippet = - "fn main() { println!(\"Hello\"); }Invalid"; - let result = MorphLLMEditor::format_user_prompt(original_code, update_snippet); - assert_eq!( - result, - "fn main() {}\nfn main() { println!(\"Hello\"); }" - ); - } - - #[test] - fn test_format_user_prompt_nested_tags() { - let original_code = "fn main() {}"; - let update_snippet = "fn main() { nested }"; - let result = MorphLLMEditor::format_user_prompt(original_code, update_snippet); - // Should use the first occurrence of and find its matching - assert_eq!( - result, - "fn main() {}\nfn main() { nested" - ); - } - - #[test] - fn test_format_user_prompt_tags_in_different_order() { - let original_code = "fn main() {}"; - let update_snippet = "I am adding a print statementfn main() { println!(\"Hello\"); }"; - let result = MorphLLMEditor::format_user_prompt(original_code, update_snippet); - assert_eq!( - result, - "I am adding a print statement\nfn main() {}\nfn main() { println!(\"Hello\"); }" - ); - } -} diff --git a/crates/goose-mcp/src/developer/editor_models/openai_compatible_editor.rs b/crates/goose-mcp/src/developer/editor_models/openai_compatible_editor.rs deleted file mode 100644 index 52778d559930..000000000000 --- a/crates/goose-mcp/src/developer/editor_models/openai_compatible_editor.rs +++ /dev/null @@ -1,102 +0,0 @@ -use super::EditorModelImpl; -use anyhow::Result; -use reqwest::Client; -use serde_json::{json, Value}; - -/// OpenAI-compatible editor that uses the standard chat completions format -#[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 { - eprintln!("Calling OpenAI-compatible Editor API"); - - // Construct the full URL - 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) - }; - - // Create the client - let client = Client::new(); - - // Format the prompt as specified in the Python example - let user_prompt = format!( - "{}\n{}", - original_code, update_snippet - ); - - // Prepare the request body for OpenAI-compatible API - let body = json!({ - "model": self.model, - "messages": [ - { - "role": "user", - "content": user_prompt - } - ] - }); - - // Send the request - 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)), - }; - - // Process the response - if !response.status().is_success() { - return Err(format!("API error: HTTP {}", response.status())); - } - - // Parse the JSON response - let response_json: Value = match response.json().await { - Ok(json) => json, - Err(e) => return Err(format!("Failed to parse response: {}", e)), - }; - - // Extract the content from the response - 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())?; - - eprintln!("OpenAI-compatible Editor API worked"); - Ok(content.to_string()) - } - - fn get_str_replace_description(&self) -> &'static str { - "Edit the file with the new content." - } -} diff --git a/crates/goose-mcp/src/developer/editor_models/relace_editor.rs b/crates/goose-mcp/src/developer/editor_models/relace_editor.rs deleted file mode 100644 index cc7a6d0b25cb..000000000000 --- a/crates/goose-mcp/src/developer/editor_models/relace_editor.rs +++ /dev/null @@ -1,102 +0,0 @@ -use super::EditorModelImpl; -use anyhow::Result; -use reqwest::Client; -use serde_json::{json, Value}; - -/// Relace-specific editor that uses the predicted outputs convention -#[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 { - eprintln!("Calling Relace Editor API"); - - // Construct the full URL - 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) - }; - - // Create the client - let client = Client::new(); - - // Prepare the request body for Relace API - // The Relace endpoint expects the OpenAI predicted outputs convention - // where the original code is supplied under `prediction` and the - // update snippet is the sole user message. - let body = json!({ - "model": self.model, - "prediction": { - "content": original_code - }, - "messages": [ - { - "role": "user", - "content": update_snippet - } - ] - }); - - // Send the request - 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)), - }; - - // Process the response - if !response.status().is_success() { - return Err(format!("API error: HTTP {}", response.status())); - } - - // Parse the JSON response - let response_json: Value = match response.json().await { - Ok(json) => json, - Err(e) => return Err(format!("Failed to parse response: {}", e)), - }; - - // Extract the content from the response - 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())?; - - eprintln!("Relace Editor API worked"); - 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." - } -} diff --git a/crates/goose-mcp/src/developer/lang.rs b/crates/goose-mcp/src/developer/lang.rs deleted file mode 100644 index 590f065d7c49..000000000000 --- a/crates/goose-mcp/src/developer/lang.rs +++ /dev/null @@ -1,39 +0,0 @@ -use std::path::Path; - -/// Get the markdown language identifier for a file extension -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", - _ => "", - } -} diff --git a/crates/goose-mcp/src/developer/mod.rs b/crates/goose-mcp/src/developer/mod.rs deleted file mode 100644 index 282e638c554d..000000000000 --- a/crates/goose-mcp/src/developer/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -pub mod analyze; -mod editor_models; -mod lang; -pub mod paths; -mod shell; -mod text_editor; - -pub mod rmcp_developer; - -#[cfg(test)] -mod tests; diff --git a/crates/goose-mcp/src/developer/paths.rs b/crates/goose-mcp/src/developer/paths.rs deleted file mode 100644 index 9eb500d77a8d..000000000000 --- a/crates/goose-mcp/src/developer/paths.rs +++ /dev/null @@ -1,115 +0,0 @@ -use crate::subprocess::SubprocessExt; -use anyhow::Result; -use std::env; -use std::path::PathBuf; -use tokio::process::Command; -use tokio::sync::OnceCell; - -static SHELL_PATH_DIRS: OnceCell, anyhow::Error>> = OnceCell::const_new(); - -pub async fn get_shell_path_dirs() -> Result<&'static Vec> { - let result = SHELL_PATH_DIRS - .get_or_init(|| async { - get_shell_path_async() - .await - .map(|path| env::split_paths(&path).collect()) - }) - .await; - - match result { - Ok(dirs) => Ok(dirs), - Err(e) => Err(anyhow::anyhow!( - "Failed to get shell PATH directories: {}", - e - )), - } -} - -async fn get_shell_path_async() -> Result { - let shell = env::var("SHELL").unwrap_or_else(|_| { - if cfg!(windows) { - "cmd".to_string() - } else { - "/bin/bash".to_string() - } - }); - - if cfg!(windows) { - get_windows_path_async(&shell).await - } else { - get_unix_path_async(&shell).await - } - .or_else(|e| { - tracing::warn!( - "Failed to get PATH from shell ({}), falling back to current PATH", - e - ); - env::var("PATH").map_err(|_| anyhow::anyhow!("No PATH variable available")) - }) -} - -async fn get_unix_path_async(shell: &str) -> Result { - let output = Command::new(shell) - .args(["-l", "-i", "-c", "echo $PATH"]) - .output() - .await - .map_err(|e| anyhow::anyhow!("Failed to execute shell command: {}", e))?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(anyhow::anyhow!("Shell command failed: {}", stderr)); - } - - let path = String::from_utf8(output.stdout) - .map_err(|e| anyhow::anyhow!("Invalid UTF-8 in shell output: {}", e))? - .trim() - .to_string(); - - if path.is_empty() { - return Err(anyhow::anyhow!("Shell returned empty PATH")); - } - - Ok(path) -} - -async fn get_windows_path_async(shell: &str) -> Result { - let shell_name = std::path::Path::new(shell) - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("cmd"); - - let output = match shell_name { - "pwsh" | "powershell" => { - Command::new(shell) - .args(["-NoLogo", "-Command", "$env:PATH"]) - .set_no_window() - .output() - .await - } - _ => { - Command::new(shell) - .args(["/c", "echo %PATH%"]) - .set_no_window() - .output() - .await - } - }; - - let output = output.map_err(|e| anyhow::anyhow!("Failed to execute shell command: {}", e))?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(anyhow::anyhow!("Shell command failed: {}", stderr)); - } - - let path = String::from_utf8(output.stdout) - .map_err(|e| anyhow::anyhow!("Invalid UTF-8 in shell output: {}", e))? - .trim() - .to_string(); - - if path.is_empty() { - return Err(anyhow::anyhow!("Shell returned empty PATH")); - } - - Ok(path) -} diff --git a/crates/goose-mcp/src/developer/prompts/unit_test.json b/crates/goose-mcp/src/developer/prompts/unit_test.json deleted file mode 100644 index abef9414f405..000000000000 --- a/crates/goose-mcp/src/developer/prompts/unit_test.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "id": "unit_test", - "template": "Generate or update unit tests for a given source code file.\n\nThe source code file is provided in {source_code}.\nPlease update the existing tests, ensure they are passing, and add any new tests as needed.\n\nThe test suite should:\n- Follow language-specific test naming conventions for {language}\n- Include all necessary imports and annotations\n- Thoroughly test the specified functionality\n- Ensure tests are passing before completion\n- Handle edge cases and error conditions\n- Use clear test names that reflect what is being tested", - "arguments": [ - { - "name": "source_code", - "description": "The source code file content to be tested", - "required": true - }, - { - "name": "language", - "description": "The programming language of the source code", - "required": true - } - ] - } \ No newline at end of file diff --git a/crates/goose-mcp/src/developer/rmcp_developer.rs b/crates/goose-mcp/src/developer/rmcp_developer.rs deleted file mode 100644 index 0a1b3ce147a6..000000000000 --- a/crates/goose-mcp/src/developer/rmcp_developer.rs +++ /dev/null @@ -1,3671 +0,0 @@ -use anyhow::anyhow; -use base64::Engine; -use etcetera::AppStrategy; -use ignore::gitignore::{Gitignore, GitignoreBuilder}; -use include_dir::{include_dir, Dir}; -use indoc::{formatdoc, indoc}; -use once_cell::sync::Lazy; -use rmcp::{ - handler::server::{router::tool::ToolRouter, wrapper::Parameters}, - model::{ - CallToolResult, CancelledNotificationParam, Content, ErrorCode, ErrorData, - GetPromptRequestParams, GetPromptResult, Implementation, ListPromptsResult, LoggingLevel, - LoggingMessageNotificationParam, Meta, PaginatedRequestParams, Prompt, PromptArgument, - PromptMessage, PromptMessageRole, Role, ServerCapabilities, ServerInfo, - }, - schemars::JsonSchema, - service::{NotificationContext, RequestContext}, - tool, tool_handler, tool_router, RoleServer, ServerHandler, -}; - -const WORKING_DIR_HEADER: &str = "agent-working-dir"; -const SESSION_ID_HEADER: &str = "agent-session-id"; - -pub const WORKING_DIR_PLACEHOLDER: &str = "{{WORKING_DIR}}"; - -fn extract_working_dir_from_meta(meta: &Meta) -> Option { - meta.0 - .get(WORKING_DIR_HEADER) - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - .filter(|s| !s.contains('\0')) - .map(PathBuf::from) -} - -fn extract_session_id_from_meta(meta: &Meta) -> Option { - meta.0 - .get(SESSION_ID_HEADER) - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - .filter(|s| !s.contains('\0')) - .map(String::from) -} - -use serde::{Deserialize, Serialize}; -use std::{ - collections::HashMap, - env::join_paths, - ffi::OsString, - future::Future, - io::Cursor, - path::{Path, PathBuf}, - sync::{Arc, Mutex}, -}; -use xcap::{Monitor, Window}; - -use tokio::{ - io::{AsyncBufReadExt, BufReader}, - sync::RwLock, -}; -use tokio_stream::{wrappers::SplitStream, StreamExt as _}; -use tokio_util::sync::CancellationToken; - -use crate::developer::{paths::get_shell_path_dirs, shell::ShellConfig}; - -use super::analyze::{types::AnalyzeParams, CodeAnalyzer}; -use super::editor_models::{create_editor_model, EditorModel}; -use super::shell::{configure_shell_command, expand_path, is_absolute_path, kill_process_group}; -use super::text_editor::{ - text_editor_insert, text_editor_replace, text_editor_undo, text_editor_view, text_editor_write, -}; - -/// Parameters for the screen_capture tool -#[derive(Debug, Serialize, Deserialize, JsonSchema)] -pub struct ScreenCaptureParams { - /// The display number to capture (0 is main display) - #[serde(default)] - pub display: Option, - - /// Optional: the exact title of the window to capture. - /// Use the list_windows tool to find the available windows. - pub window_title: Option, -} - -/// Parameters for the text_editor tool -#[derive(Debug, Serialize, Deserialize, JsonSchema)] -pub struct TextEditorParams { - /// Absolute path to file or directory, e.g. `/repo/file.py` or `/repo`. - pub path: String, - - /// The operation to perform. Allowed options are: `view`, `write`, `str_replace`, `insert`, `undo_edit`. - pub command: String, - - /// Unified diff to apply. Supports editing multiple files simultaneously. Cannot create or delete files - /// Example: "--- a/file\n+++ b/file\n@@ -1,3 +1,3 @@\n context\n-old\n+new\n context" - /// Preferred edit method. - pub diff: Option, - - /// Optional array of two integers specifying the start and end line numbers to view. - /// Line numbers are 1-indexed, and -1 for the end line means read to the end of the file. - /// This parameter only applies when viewing files, not directories. - pub view_range: Option>, - - /// The content to write to the file. Required for `write` command. - pub file_text: Option, - - /// The old string to replace. - pub old_str: Option, - - /// The new string to replace with. Required for `insert` command. - pub new_str: Option, - - /// The line number after which to insert text (0 for beginning). Required for `insert` command. - pub insert_line: Option, -} - -/// Parameters for the shell tool -#[derive(Debug, Serialize, Deserialize, JsonSchema)] -pub struct ShellParams { - /// The command string to execute in the shell - pub command: String, -} - -/// Parameters for the image_processor tool -#[derive(Debug, Serialize, Deserialize, JsonSchema)] -pub struct ImageProcessorParams { - /// Absolute path to the image file to process - pub path: String, -} - -/// Template structure for prompt definitions -#[derive(Debug, Serialize, Deserialize)] -pub struct PromptTemplate { - pub id: String, - pub template: String, - pub arguments: Vec, -} - -/// Template structure for prompt arguments -#[derive(Debug, Serialize, Deserialize)] -pub struct PromptArgumentTemplate { - pub name: String, - pub description: Option, - pub required: Option, -} - -// Embeds the prompts directory to the build -static PROMPTS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/src/developer/prompts"); - -static MACOS_SCREENSHOT_FILENAME_RE: Lazy = Lazy::new(|| { - regex::Regex::new( - r"^Screenshot \d{4}-\d{2}-\d{2} at \d{1,2}\.\d{2}\.\d{2} (AM|PM|am|pm)(?: \(\d+\))?\.png$", - ) - .expect("macOS screenshot filename regex should be valid") -}); - -const DEFAULT_GOOSEIGNORE_CONTENT: &str = concat!( - "# This file is created automatically if no .gooseignore exists.\n", - "# Customize or uncomment the patterns below instead of deleting the file.\n", - "# Removing it will simply cause goose to recreate it on the next start.\n", - "#\n", - "# Suggested patterns you can uncomment if desired:\n", - "# **/.ssh/** # block SSH keys and configs\n", - "# **/*.key # block loose private keys\n", - "# **/*.pem # block certificates/private keys\n", - "# **/.git/** # block git metadata entirely\n", - "# **/target/** # block Rust build artifacts\n", - "# **/node_modules/** # block JS/TS dependencies\n", - "# **/*.db # block local database files\n", - "# **/*.sqlite # block SQLite databases\n", - "#\n", - "\n", - "**/.env\n", - "**/.env.*\n", - "**/secrets.*\n", -); - -/// Loads prompt files from the embedded PROMPTS_DIR and returns a HashMap of prompts. -/// Ensures that each prompt name is unique. -fn load_prompt_files() -> HashMap { - let mut prompts = HashMap::new(); - - for entry in PROMPTS_DIR.files() { - // Only process JSON files - if entry.path().extension().is_none_or(|ext| ext != "json") { - continue; - } - - let prompt_str = String::from_utf8_lossy(entry.contents()).into_owned(); - - let template: PromptTemplate = match serde_json::from_str(&prompt_str) { - Ok(t) => t, - Err(e) => { - eprintln!( - "Failed to parse prompt template in {}: {}", - entry.path().display(), - e - ); - continue; // Skip invalid prompt file - } - }; - - let arguments = template - .arguments - .into_iter() - .map(|arg| PromptArgument { - name: arg.name, - description: arg.description, - required: arg.required, - title: None, - }) - .collect::>(); - - let prompt = Prompt::new(&template.id, Some(&template.template), Some(arguments)); - - if prompts.contains_key(&prompt.name) { - eprintln!("Duplicate prompt name '{}' found. Skipping.", prompt.name); - continue; // Skip duplicate prompt name - } - - prompts.insert(prompt.name.clone(), prompt); - } - - prompts -} - -/// Developer MCP Server using official RMCP SDK -#[derive(Clone)] -pub struct DeveloperServer { - tool_router: ToolRouter, - file_history: Arc>>>, - ignore_patterns: Gitignore, - editor_model: Option, - prompts: HashMap, - code_analyzer: CodeAnalyzer, - #[cfg(test)] - pub running_processes: Arc>>, - #[cfg(not(test))] - running_processes: Arc>>, - bash_env_file: Option, - extend_path_with_shell: bool, -} - -#[tool_handler(router = self.tool_router)] -impl ServerHandler for DeveloperServer { - #[allow(clippy::too_many_lines)] - fn get_info(&self) -> ServerInfo { - let os = std::env::consts::OS; - let in_container = Self::is_definitely_container(); - - let base_instructions = match os { - "windows" => formatdoc! {r#" - The developer extension gives you the capabilities to edit code files and run shell commands, - and can be used to solve a wide range of problems. - - You can use the shell tool to run Windows commands (PowerShell or CMD). - When using paths, you can use either backslashes or forward slashes. - - Use the shell tool as needed to locate files or interact with the project. - - Leverage `analyze` through `return_last_only=true` subagents for deep codebase understanding with lean context - - delegate analysis, retain summaries - - Your windows/screen tools can be used for visual debugging. You should not use these tools unless - prompted to, but you can mention they are available if they are relevant. - - operating system: {os} - current directory: {cwd} - {container_info} - "#, - os=os, - cwd=WORKING_DIR_PLACEHOLDER, - container_info=if in_container { "container: true" } else { "" }, - }, - _ => { - let shell_info = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()); - - formatdoc! {r#" - The developer extension gives you the capabilities to edit code files and run shell commands, - and can be used to solve a wide range of problems. - - You can use the shell tool to run any command that would work on the relevant operating system. - Use the shell tool as needed to locate files or interact with the project. - - Leverage `analyze` through `return_last_only=true` subagents for deep codebase understanding with lean context - - delegate analysis, retain summaries - - Your windows/screen tools can be used for visual debugging. You should not use these tools unless - prompted to, but you can mention they are available if they are relevant. - - Always prefer ripgrep (rg -C 3) to grep. - - operating system: {os} - current directory: {cwd} - shell: {shell} - {container_info} - "#, - os=os, - cwd=WORKING_DIR_PLACEHOLDER, - shell=shell_info, - container_info=if in_container { "container: true" } else { "" }, - } - } - }; - - // Check if editor model exists and augment with custom llm editor tool description - let editor_description = if let Some(ref editor) = self.editor_model { - formatdoc! {r#" - - Additional Text Editor Tool Instructions: - - Perform text editing operations on files. - The `command` parameter specifies the operation to perform. Allowed options are: - - `view`: View the content of a file. - - `write`: Create or overwrite a file with the given content - - `str_replace`: Replace text in one or more files. - - `insert`: Insert text at a specific line location in the file. - - `undo_edit`: Undo the last edit made to a file. - - To use the write command, you must specify `file_text` which will become the new content of the file. Be careful with - existing files! This is a full overwrite, so you must include everything - not just sections you are modifying. - - To use the insert command, you must specify both `insert_line` (the line number after which to insert, 0 for beginning, -1 for end) - and `new_str` (the text to insert). - - To use the str_replace command to edit multiple files, use the `diff` parameter with a unified diff. - To use the str_replace command to edit one file, you must specify both `old_str` and `new_str` - the `old_str` needs to exactly match one - unique section of the original file, including any whitespace. Make sure to include enough context that the match is not - ambiguous. The entire original string will be replaced with `new_str` - - When possible, batch file edits together by using a multi-file unified `diff` within a single str_replace tool call. - - {} - - "#, editor.get_str_replace_description()} - } else { - formatdoc! {r#" - - Additional Text Editor Tool Instructions: - - Perform text editing operations on files. - - The `command` parameter specifies the operation to perform. Allowed options are: - - `view`: View the content of a file. - - `write`: Create or overwrite a file with the given content - - `str_replace`: Replace text in one or more files. - - `insert`: Insert text at a specific line location in the file. - - `undo_edit`: Undo the last edit made to a file. - - To use the write command, you must specify `file_text` which will become the new content of the file. Be careful with - existing files! This is a full overwrite, so you must include everything - not just sections you are modifying. - - To use the str_replace command to edit multiple files, use the `diff` parameter with a unified diff. - To use the str_replace command to edit one file, you must specify both `old_str` and `new_str` - the `old_str` needs to exactly match one - unique section of the original file, including any whitespace. Make sure to include enough context that the match is not - ambiguous. The entire original string will be replaced with `new_str` - - When possible, batch file edits together by using a multi-file unified `diff` within a single str_replace tool call. - - To use the insert command, you must specify both `insert_line` (the line number after which to insert, 0 for beginning, -1 for end) - and `new_str` (the text to insert). - - - "#} - }; - - // Create comprehensive shell tool instructions - let common_shell_instructions = indoc! {r#" - Additional Shell Tool Instructions: - Execute a command in the shell. - - This will return the output and error concatenated into a single string, as - you would see from running on the command line. There will also be an indication - of if the command succeeded or failed. - - Avoid commands that produce a large amount of output, and consider piping those outputs to files. - - **Important**: Each shell command runs in its own process. Things like directory changes or - sourcing files do not persist between tool calls. So you may need to repeat them each time by - stringing together commands. - - If fetching web content, consider adding Accept: text/markdown header - "#}; - - let windows_specific = indoc! {r#" - **Important**: For searching files and code: - - Preferred: Use ripgrep (`rg`) when available - it respects .gitignore and is fast: - - To locate a file by name: `rg --files | rg example.py` - - To locate content inside files: `rg 'class Example'` - - Alternative Windows commands (if ripgrep is not installed): - - To locate a file by name: `dir /s /b example.py` - - To locate content inside files: `findstr /s /i "class Example" *.py` - - Note: Alternative commands may show ignored/hidden files that should be excluded. - - - Multiple commands: Use && to chain commands, avoid newlines - - Example: `cd example && dir` or `activate.bat && pip install numpy` - - **Important**: Use forward slashes in paths (e.g., `C:/Users/name`) to avoid - escape character issues with backslashes, i.e. \n in a path could be - mistaken for a newline. - "#}; - - let unix_specific = indoc! {r#" - If you need to run a long lived command, background it - e.g. `uvicorn main:app &` so that - this tool does not run indefinitely. - - **Important**: Use ripgrep - `rg` - exclusively when you need to locate a file or a code reference, - other solutions may produce too large output because of hidden files! For example *do not* use `find` or `ls -r` - - List files by name: `rg --files | rg ` - - List files that contain a regex: `rg '' -l` - - - Multiple commands: Use && to chain commands, avoid newlines - - Example: `cd example && ls` or `source env/bin/activate && pip install numpy` - "#}; - - let shell_tool_desc = match os { - "windows" => format!("{}{}", common_shell_instructions, windows_specific), - _ => format!("{}{}", common_shell_instructions, unix_specific), - }; - - let instructions = format!("{base_instructions}{editor_description}\n{shell_tool_desc}"); - - ServerInfo { - server_info: Implementation { - name: "goose-developer".to_string(), - version: env!("CARGO_PKG_VERSION").to_owned(), - title: None, - description: None, - icons: None, - website_url: None, - }, - capabilities: ServerCapabilities::builder() - .enable_tools() - .enable_prompts() - .build(), - instructions: Some(instructions), - ..Default::default() - } - } - - // TODO: use the rmcp prompt macros instead when SDK is updated - // Current rmcp version 0.6.0 doesn't support prompt macros yet. - // When upgrading to a newer version that supports it, replace this manual - // implementation with the macro-based approach for better maintainability. - fn list_prompts( - &self, - _request: Option, - _context: RequestContext, - ) -> impl Future> + Send + '_ { - let prompts: Vec = self.prompts.values().cloned().collect(); - std::future::ready(Ok(ListPromptsResult { - prompts, - next_cursor: None, - meta: None, - })) - } - - fn get_prompt( - &self, - request: GetPromptRequestParams, - _context: RequestContext, - ) -> impl Future> + Send + '_ { - let prompt_name = request.name; - let arguments = request.arguments.unwrap_or_default(); - - match self.prompts.get(&prompt_name) { - Some(prompt) => { - // Get the template from the prompt description - let template = prompt.description.clone().unwrap_or_default(); - - // Validate template length - if template.len() > 10000 { - return std::future::ready(Err(ErrorData::new( - ErrorCode::INTERNAL_ERROR, - "Prompt template exceeds maximum allowed length".to_string(), - None, - ))); - } - - // Validate arguments for security (same checks as router) - for (key, value) in &arguments { - // Check for empty or overly long keys/values - if key.is_empty() || key.len() > 1000 { - return std::future::ready(Err(ErrorData::new( - ErrorCode::INVALID_PARAMS, - "Argument keys must be between 1-1000 characters".to_string(), - None, - ))); - } - - let value_str = value.as_str().unwrap_or_default(); - if value_str.len() > 1000 { - return std::future::ready(Err(ErrorData::new( - ErrorCode::INVALID_PARAMS, - "Argument values must not exceed 1000 characters".to_string(), - None, - ))); - } - - // Check for potentially dangerous patterns - let dangerous_patterns = ["../", "//", "\\\\", "