grep_tool.rs

   1use crate::schema::json_schema_for;
   2use action_log::ActionLog;
   3use anyhow::{Result, anyhow};
   4use assistant_tool::{Tool, ToolResult};
   5use futures::StreamExt;
   6use gpui::{AnyWindowHandle, App, Entity, Task};
   7use language::{OffsetRangeExt, ParseStatus, Point};
   8use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat};
   9use project::{
  10    Project, WorktreeSettings,
  11    search::{SearchQuery, SearchResult},
  12};
  13use schemars::JsonSchema;
  14use serde::{Deserialize, Serialize};
  15use settings::Settings;
  16use std::{cmp, fmt::Write, sync::Arc};
  17use ui::IconName;
  18use util::RangeExt;
  19use util::markdown::MarkdownInlineCode;
  20use util::paths::PathMatcher;
  21
  22#[derive(Debug, Serialize, Deserialize, JsonSchema)]
  23pub struct GrepToolInput {
  24    /// A regex pattern to search for in the entire project. Note that the regex
  25    /// will be parsed by the Rust `regex` crate.
  26    ///
  27    /// Do NOT specify a path here! This will only be matched against the code **content**.
  28    pub regex: String,
  29
  30    /// A glob pattern for the paths of files to include in the search.
  31    /// Supports standard glob patterns like "**/*.rs" or "src/**/*.ts".
  32    /// If omitted, all files in the project will be searched.
  33    pub include_pattern: Option<String>,
  34
  35    /// Optional starting position for paginated results (0-based).
  36    /// When not provided, starts from the beginning.
  37    #[serde(default)]
  38    pub offset: u32,
  39
  40    /// Whether the regex is case-sensitive. Defaults to false (case-insensitive).
  41    #[serde(default)]
  42    pub case_sensitive: bool,
  43}
  44
  45impl GrepToolInput {
  46    /// Which page of search results this is.
  47    pub fn page(&self) -> u32 {
  48        1 + (self.offset / RESULTS_PER_PAGE)
  49    }
  50}
  51
  52const RESULTS_PER_PAGE: u32 = 20;
  53
  54pub struct GrepTool;
  55
  56impl Tool for GrepTool {
  57    fn name(&self) -> String {
  58        "grep".into()
  59    }
  60
  61    fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity<Project>, _: &App) -> bool {
  62        false
  63    }
  64
  65    fn may_perform_edits(&self) -> bool {
  66        false
  67    }
  68
  69    fn description(&self) -> String {
  70        include_str!("./grep_tool/description.md").into()
  71    }
  72
  73    fn icon(&self) -> IconName {
  74        IconName::ToolRegex
  75    }
  76
  77    fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result<serde_json::Value> {
  78        json_schema_for::<GrepToolInput>(format)
  79    }
  80
  81    fn ui_text(&self, input: &serde_json::Value) -> String {
  82        match serde_json::from_value::<GrepToolInput>(input.clone()) {
  83            Ok(input) => {
  84                let page = input.page();
  85                let regex_str = MarkdownInlineCode(&input.regex);
  86                let case_info = if input.case_sensitive {
  87                    " (case-sensitive)"
  88                } else {
  89                    ""
  90                };
  91
  92                if page > 1 {
  93                    format!("Get page {page} of search results for regex {regex_str}{case_info}")
  94                } else {
  95                    format!("Search files for regex {regex_str}{case_info}")
  96                }
  97            }
  98            Err(_) => "Search with regex".to_string(),
  99        }
 100    }
 101
 102    fn run(
 103        self: Arc<Self>,
 104        input: serde_json::Value,
 105        _request: Arc<LanguageModelRequest>,
 106        project: Entity<Project>,
 107        _action_log: Entity<ActionLog>,
 108        _model: Arc<dyn LanguageModel>,
 109        _window: Option<AnyWindowHandle>,
 110        cx: &mut App,
 111    ) -> ToolResult {
 112        const CONTEXT_LINES: u32 = 2;
 113        const MAX_ANCESTOR_LINES: u32 = 10;
 114
 115        let input = match serde_json::from_value::<GrepToolInput>(input) {
 116            Ok(input) => input,
 117            Err(error) => {
 118                return Task::ready(Err(anyhow!("Failed to parse input: {error}"))).into();
 119            }
 120        };
 121
 122        let include_matcher = match PathMatcher::new(
 123            input
 124                .include_pattern
 125                .as_ref()
 126                .into_iter()
 127                .collect::<Vec<_>>(),
 128            project.read(cx).path_style(cx),
 129        ) {
 130            Ok(matcher) => matcher,
 131            Err(error) => {
 132                return Task::ready(Err(anyhow!("invalid include glob pattern: {error}"))).into();
 133            }
 134        };
 135
 136        // Exclude global file_scan_exclusions and private_files settings
 137        let exclude_matcher = {
 138            let global_settings = WorktreeSettings::get_global(cx);
 139            let exclude_patterns = global_settings
 140                .file_scan_exclusions
 141                .sources()
 142                .iter()
 143                .chain(global_settings.private_files.sources().iter());
 144
 145            match PathMatcher::new(exclude_patterns, project.read(cx).path_style(cx)) {
 146                Ok(matcher) => matcher,
 147                Err(error) => {
 148                    return Task::ready(Err(anyhow!("invalid exclude pattern: {error}"))).into();
 149                }
 150            }
 151        };
 152
 153        let query = match SearchQuery::regex(
 154            &input.regex,
 155            false,
 156            input.case_sensitive,
 157            false,
 158            false,
 159            include_matcher,
 160            exclude_matcher,
 161            true, // Always match file include pattern against *full project paths* that start with a project root.
 162            None,
 163        ) {
 164            Ok(query) => query,
 165            Err(error) => return Task::ready(Err(error)).into(),
 166        };
 167
 168        let results = project.update(cx, |project, cx| project.search(query, cx));
 169
 170        cx.spawn(async move |cx|  {
 171            futures::pin_mut!(results);
 172
 173            let mut output = String::new();
 174            let mut skips_remaining = input.offset;
 175            let mut matches_found = 0;
 176            let mut has_more_matches = false;
 177
 178            'outer: while let Some(SearchResult::Buffer { buffer, ranges }) = results.next().await {
 179                if ranges.is_empty() {
 180                    continue;
 181                }
 182
 183                let Ok((Some(path), mut parse_status)) = buffer.read_with(cx, |buffer, cx| {
 184                    (buffer.file().map(|file| file.full_path(cx)), buffer.parse_status())
 185                }) else {
 186                    continue;
 187                };
 188
 189                // Check if this file should be excluded based on its worktree settings
 190                if let Ok(Some(project_path)) = project.read_with(cx, |project, cx| {
 191                    project.find_project_path(&path, cx)
 192                })
 193                    && cx.update(|cx| {
 194                        let worktree_settings = WorktreeSettings::get(Some((&project_path).into()), cx);
 195                        worktree_settings.is_path_excluded(&project_path.path)
 196                            || worktree_settings.is_path_private(&project_path.path)
 197                    }).unwrap_or(false) {
 198                        continue;
 199                    }
 200
 201                while *parse_status.borrow() != ParseStatus::Idle {
 202                    parse_status.changed().await?;
 203                }
 204
 205                let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?;
 206
 207                let mut ranges = ranges
 208                    .into_iter()
 209                    .map(|range| {
 210                        let matched = range.to_point(&snapshot);
 211                        let matched_end_line_len = snapshot.line_len(matched.end.row);
 212                        let full_lines = Point::new(matched.start.row, 0)..Point::new(matched.end.row, matched_end_line_len);
 213                        let symbols = snapshot.symbols_containing(matched.start, None);
 214
 215                        if let Some(ancestor_node) = snapshot.syntax_ancestor(full_lines.clone()) {
 216                            let full_ancestor_range = ancestor_node.byte_range().to_point(&snapshot);
 217                            let end_row = full_ancestor_range.end.row.min(full_ancestor_range.start.row + MAX_ANCESTOR_LINES);
 218                            let end_col = snapshot.line_len(end_row);
 219                            let capped_ancestor_range = Point::new(full_ancestor_range.start.row, 0)..Point::new(end_row, end_col);
 220
 221                            if capped_ancestor_range.contains_inclusive(&full_lines) {
 222                                return (capped_ancestor_range, Some(full_ancestor_range), symbols)
 223                            }
 224                        }
 225
 226                        let mut matched = matched;
 227                        matched.start.column = 0;
 228                        matched.start.row =
 229                            matched.start.row.saturating_sub(CONTEXT_LINES);
 230                        matched.end.row = cmp::min(
 231                            snapshot.max_point().row,
 232                            matched.end.row + CONTEXT_LINES,
 233                        );
 234                        matched.end.column = snapshot.line_len(matched.end.row);
 235
 236                        (matched, None, symbols)
 237                    })
 238                    .peekable();
 239
 240                let mut file_header_written = false;
 241
 242                while let Some((mut range, ancestor_range, parent_symbols)) = ranges.next(){
 243                    if skips_remaining > 0 {
 244                        skips_remaining -= 1;
 245                        continue;
 246                    }
 247
 248                    // We'd already found a full page of matches, and we just found one more.
 249                    if matches_found >= RESULTS_PER_PAGE {
 250                        has_more_matches = true;
 251                        break 'outer;
 252                    }
 253
 254                    while let Some((next_range, _, _)) = ranges.peek() {
 255                        if range.end.row >= next_range.start.row {
 256                            range.end = next_range.end;
 257                            ranges.next();
 258                        } else {
 259                            break;
 260                        }
 261                    }
 262
 263                    if !file_header_written {
 264                        writeln!(output, "\n## Matches in {}", path.display())?;
 265                        file_header_written = true;
 266                    }
 267
 268                    let end_row = range.end.row;
 269                    output.push_str("\n### ");
 270
 271                    for symbol in parent_symbols {
 272                        write!(output, "{} › ", symbol.text)?;
 273                    }
 274
 275                    if range.start.row == end_row {
 276                        writeln!(output, "L{}", range.start.row + 1)?;
 277                    } else {
 278                        writeln!(output, "L{}-{}", range.start.row + 1, end_row + 1)?;
 279                    }
 280
 281                    output.push_str("```\n");
 282                    output.extend(snapshot.text_for_range(range));
 283                    output.push_str("\n```\n");
 284
 285                    if let Some(ancestor_range) = ancestor_range
 286                        && end_row < ancestor_range.end.row {
 287                            let remaining_lines = ancestor_range.end.row - end_row;
 288                            writeln!(output, "\n{} lines remaining in ancestor node. Read the file to see all.", remaining_lines)?;
 289                        }
 290
 291                    matches_found += 1;
 292                }
 293            }
 294
 295            if matches_found == 0 {
 296                Ok("No matches found".to_string().into())
 297            } else if has_more_matches {
 298                Ok(format!(
 299                    "Showing matches {}-{} (there were more matches found; use offset: {} to see next page):\n{output}",
 300                    input.offset + 1,
 301                    input.offset + matches_found,
 302                    input.offset + RESULTS_PER_PAGE,
 303                ).into())
 304            } else {
 305                Ok(format!("Found {matches_found} matches:\n{output}").into())
 306            }
 307        }).into()
 308    }
 309}
 310
 311#[cfg(test)]
 312mod tests {
 313    use super::*;
 314    use assistant_tool::Tool;
 315    use gpui::{AppContext, TestAppContext, UpdateGlobal};
 316    use language::{Language, LanguageConfig, LanguageMatcher};
 317    use language_model::fake_provider::FakeLanguageModel;
 318    use project::{FakeFs, Project};
 319    use serde_json::json;
 320    use settings::SettingsStore;
 321    use unindent::Unindent;
 322    use util::path;
 323
 324    #[gpui::test]
 325    async fn test_grep_tool_with_include_pattern(cx: &mut TestAppContext) {
 326        init_test(cx);
 327        cx.executor().allow_parking();
 328
 329        let fs = FakeFs::new(cx.executor());
 330        fs.insert_tree(
 331            path!("/root"),
 332            serde_json::json!({
 333                "src": {
 334                    "main.rs": "fn main() {\n    println!(\"Hello, world!\");\n}",
 335                    "utils": {
 336                        "helper.rs": "fn helper() {\n    println!(\"I'm a helper!\");\n}",
 337                    },
 338                },
 339                "tests": {
 340                    "test_main.rs": "fn test_main() {\n    assert!(true);\n}",
 341                }
 342            }),
 343        )
 344        .await;
 345
 346        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
 347
 348        // Test with include pattern for Rust files inside the root of the project
 349        let input = serde_json::to_value(GrepToolInput {
 350            regex: "println".to_string(),
 351            include_pattern: Some("root/**/*.rs".to_string()),
 352            offset: 0,
 353            case_sensitive: false,
 354        })
 355        .unwrap();
 356
 357        let result = run_grep_tool(input, project.clone(), cx).await;
 358        assert!(result.contains("main.rs"), "Should find matches in main.rs");
 359        assert!(
 360            result.contains("helper.rs"),
 361            "Should find matches in helper.rs"
 362        );
 363        assert!(
 364            !result.contains("test_main.rs"),
 365            "Should not include test_main.rs even though it's a .rs file (because it doesn't have the pattern)"
 366        );
 367
 368        // Test with include pattern for src directory only
 369        let input = serde_json::to_value(GrepToolInput {
 370            regex: "fn".to_string(),
 371            include_pattern: Some("root/**/src/**".to_string()),
 372            offset: 0,
 373            case_sensitive: false,
 374        })
 375        .unwrap();
 376
 377        let result = run_grep_tool(input, project.clone(), cx).await;
 378        assert!(
 379            result.contains("main.rs"),
 380            "Should find matches in src/main.rs"
 381        );
 382        assert!(
 383            result.contains("helper.rs"),
 384            "Should find matches in src/utils/helper.rs"
 385        );
 386        assert!(
 387            !result.contains("test_main.rs"),
 388            "Should not include test_main.rs as it's not in src directory"
 389        );
 390
 391        // Test with empty include pattern (should default to all files)
 392        let input = serde_json::to_value(GrepToolInput {
 393            regex: "fn".to_string(),
 394            include_pattern: None,
 395            offset: 0,
 396            case_sensitive: false,
 397        })
 398        .unwrap();
 399
 400        let result = run_grep_tool(input, project.clone(), cx).await;
 401        assert!(result.contains("main.rs"), "Should find matches in main.rs");
 402        assert!(
 403            result.contains("helper.rs"),
 404            "Should find matches in helper.rs"
 405        );
 406        assert!(
 407            result.contains("test_main.rs"),
 408            "Should include test_main.rs"
 409        );
 410    }
 411
 412    #[gpui::test]
 413    async fn test_grep_tool_with_case_sensitivity(cx: &mut TestAppContext) {
 414        init_test(cx);
 415        cx.executor().allow_parking();
 416
 417        let fs = FakeFs::new(cx.executor());
 418        fs.insert_tree(
 419            path!("/root"),
 420            serde_json::json!({
 421                "case_test.txt": "This file has UPPERCASE and lowercase text.\nUPPERCASE patterns should match only with case_sensitive: true",
 422            }),
 423        )
 424        .await;
 425
 426        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
 427
 428        // Test case-insensitive search (default)
 429        let input = serde_json::to_value(GrepToolInput {
 430            regex: "uppercase".to_string(),
 431            include_pattern: Some("**/*.txt".to_string()),
 432            offset: 0,
 433            case_sensitive: false,
 434        })
 435        .unwrap();
 436
 437        let result = run_grep_tool(input, project.clone(), cx).await;
 438        assert!(
 439            result.contains("UPPERCASE"),
 440            "Case-insensitive search should match uppercase"
 441        );
 442
 443        // Test case-sensitive search
 444        let input = serde_json::to_value(GrepToolInput {
 445            regex: "uppercase".to_string(),
 446            include_pattern: Some("**/*.txt".to_string()),
 447            offset: 0,
 448            case_sensitive: true,
 449        })
 450        .unwrap();
 451
 452        let result = run_grep_tool(input, project.clone(), cx).await;
 453        assert!(
 454            !result.contains("UPPERCASE"),
 455            "Case-sensitive search should not match uppercase"
 456        );
 457
 458        // Test case-sensitive search
 459        let input = serde_json::to_value(GrepToolInput {
 460            regex: "LOWERCASE".to_string(),
 461            include_pattern: Some("**/*.txt".to_string()),
 462            offset: 0,
 463            case_sensitive: true,
 464        })
 465        .unwrap();
 466
 467        let result = run_grep_tool(input, project.clone(), cx).await;
 468
 469        assert!(
 470            !result.contains("lowercase"),
 471            "Case-sensitive search should match lowercase"
 472        );
 473
 474        // Test case-sensitive search for lowercase pattern
 475        let input = serde_json::to_value(GrepToolInput {
 476            regex: "lowercase".to_string(),
 477            include_pattern: Some("**/*.txt".to_string()),
 478            offset: 0,
 479            case_sensitive: true,
 480        })
 481        .unwrap();
 482
 483        let result = run_grep_tool(input, project.clone(), cx).await;
 484        assert!(
 485            result.contains("lowercase"),
 486            "Case-sensitive search should match lowercase text"
 487        );
 488    }
 489
 490    /// Helper function to set up a syntax test environment
 491    async fn setup_syntax_test(cx: &mut TestAppContext) -> Entity<Project> {
 492        use unindent::Unindent;
 493        init_test(cx);
 494        cx.executor().allow_parking();
 495
 496        let fs = FakeFs::new(cx.executor());
 497
 498        // Create test file with syntax structures
 499        fs.insert_tree(
 500            path!("/root"),
 501            serde_json::json!({
 502                "test_syntax.rs": r#"
 503                    fn top_level_function() {
 504                        println!("This is at the top level");
 505                    }
 506
 507                    mod feature_module {
 508                        pub mod nested_module {
 509                            pub fn nested_function(
 510                                first_arg: String,
 511                                second_arg: i32,
 512                            ) {
 513                                println!("Function in nested module");
 514                                println!("{first_arg}");
 515                                println!("{second_arg}");
 516                            }
 517                        }
 518                    }
 519
 520                    struct MyStruct {
 521                        field1: String,
 522                        field2: i32,
 523                    }
 524
 525                    impl MyStruct {
 526                        fn method_with_block() {
 527                            let condition = true;
 528                            if condition {
 529                                println!("Inside if block");
 530                            }
 531                        }
 532
 533                        fn long_function() {
 534                            println!("Line 1");
 535                            println!("Line 2");
 536                            println!("Line 3");
 537                            println!("Line 4");
 538                            println!("Line 5");
 539                            println!("Line 6");
 540                            println!("Line 7");
 541                            println!("Line 8");
 542                            println!("Line 9");
 543                            println!("Line 10");
 544                            println!("Line 11");
 545                            println!("Line 12");
 546                        }
 547                    }
 548
 549                    trait Processor {
 550                        fn process(&self, input: &str) -> String;
 551                    }
 552
 553                    impl Processor for MyStruct {
 554                        fn process(&self, input: &str) -> String {
 555                            format!("Processed: {}", input)
 556                        }
 557                    }
 558                "#.unindent().trim(),
 559            }),
 560        )
 561        .await;
 562
 563        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
 564
 565        project.update(cx, |project, _cx| {
 566            project.languages().add(rust_lang().into())
 567        });
 568
 569        project
 570    }
 571
 572    #[gpui::test]
 573    async fn test_grep_top_level_function(cx: &mut TestAppContext) {
 574        let project = setup_syntax_test(cx).await;
 575
 576        // Test: Line at the top level of the file
 577        let input = serde_json::to_value(GrepToolInput {
 578            regex: "This is at the top level".to_string(),
 579            include_pattern: Some("**/*.rs".to_string()),
 580            offset: 0,
 581            case_sensitive: false,
 582        })
 583        .unwrap();
 584
 585        let result = run_grep_tool(input, project.clone(), cx).await;
 586        let expected = r#"
 587            Found 1 matches:
 588
 589            ## Matches in root/test_syntax.rs
 590
 591            ### fn top_level_function › L1-3
 592            ```
 593            fn top_level_function() {
 594                println!("This is at the top level");
 595            }
 596            ```
 597            "#
 598        .unindent();
 599        assert_eq!(result, expected);
 600    }
 601
 602    #[gpui::test]
 603    async fn test_grep_function_body(cx: &mut TestAppContext) {
 604        let project = setup_syntax_test(cx).await;
 605
 606        // Test: Line inside a function body
 607        let input = serde_json::to_value(GrepToolInput {
 608            regex: "Function in nested module".to_string(),
 609            include_pattern: Some("**/*.rs".to_string()),
 610            offset: 0,
 611            case_sensitive: false,
 612        })
 613        .unwrap();
 614
 615        let result = run_grep_tool(input, project.clone(), cx).await;
 616        let expected = r#"
 617            Found 1 matches:
 618
 619            ## Matches in root/test_syntax.rs
 620
 621            ### mod feature_module › pub mod nested_module › pub fn nested_function › L10-14
 622            ```
 623                    ) {
 624                        println!("Function in nested module");
 625                        println!("{first_arg}");
 626                        println!("{second_arg}");
 627                    }
 628            ```
 629            "#
 630        .unindent();
 631        assert_eq!(result, expected);
 632    }
 633
 634    #[gpui::test]
 635    async fn test_grep_function_args_and_body(cx: &mut TestAppContext) {
 636        let project = setup_syntax_test(cx).await;
 637
 638        // Test: Line with a function argument
 639        let input = serde_json::to_value(GrepToolInput {
 640            regex: "second_arg".to_string(),
 641            include_pattern: Some("**/*.rs".to_string()),
 642            offset: 0,
 643            case_sensitive: false,
 644        })
 645        .unwrap();
 646
 647        let result = run_grep_tool(input, project.clone(), cx).await;
 648        let expected = r#"
 649            Found 1 matches:
 650
 651            ## Matches in root/test_syntax.rs
 652
 653            ### mod feature_module › pub mod nested_module › pub fn nested_function › L7-14
 654            ```
 655                    pub fn nested_function(
 656                        first_arg: String,
 657                        second_arg: i32,
 658                    ) {
 659                        println!("Function in nested module");
 660                        println!("{first_arg}");
 661                        println!("{second_arg}");
 662                    }
 663            ```
 664            "#
 665        .unindent();
 666        assert_eq!(result, expected);
 667    }
 668
 669    #[gpui::test]
 670    async fn test_grep_if_block(cx: &mut TestAppContext) {
 671        use unindent::Unindent;
 672        let project = setup_syntax_test(cx).await;
 673
 674        // Test: Line inside an if block
 675        let input = serde_json::to_value(GrepToolInput {
 676            regex: "Inside if block".to_string(),
 677            include_pattern: Some("**/*.rs".to_string()),
 678            offset: 0,
 679            case_sensitive: false,
 680        })
 681        .unwrap();
 682
 683        let result = run_grep_tool(input, project.clone(), cx).await;
 684        let expected = r#"
 685            Found 1 matches:
 686
 687            ## Matches in root/test_syntax.rs
 688
 689            ### impl MyStruct › fn method_with_block › L26-28
 690            ```
 691                    if condition {
 692                        println!("Inside if block");
 693                    }
 694            ```
 695            "#
 696        .unindent();
 697        assert_eq!(result, expected);
 698    }
 699
 700    #[gpui::test]
 701    async fn test_grep_long_function_top(cx: &mut TestAppContext) {
 702        use unindent::Unindent;
 703        let project = setup_syntax_test(cx).await;
 704
 705        // Test: Line in the middle of a long function - should show message about remaining lines
 706        let input = serde_json::to_value(GrepToolInput {
 707            regex: "Line 5".to_string(),
 708            include_pattern: Some("**/*.rs".to_string()),
 709            offset: 0,
 710            case_sensitive: false,
 711        })
 712        .unwrap();
 713
 714        let result = run_grep_tool(input, project.clone(), cx).await;
 715        let expected = r#"
 716            Found 1 matches:
 717
 718            ## Matches in root/test_syntax.rs
 719
 720            ### impl MyStruct › fn long_function › L31-41
 721            ```
 722                fn long_function() {
 723                    println!("Line 1");
 724                    println!("Line 2");
 725                    println!("Line 3");
 726                    println!("Line 4");
 727                    println!("Line 5");
 728                    println!("Line 6");
 729                    println!("Line 7");
 730                    println!("Line 8");
 731                    println!("Line 9");
 732                    println!("Line 10");
 733            ```
 734
 735            3 lines remaining in ancestor node. Read the file to see all.
 736            "#
 737        .unindent();
 738        assert_eq!(result, expected);
 739    }
 740
 741    #[gpui::test]
 742    async fn test_grep_long_function_bottom(cx: &mut TestAppContext) {
 743        use unindent::Unindent;
 744        let project = setup_syntax_test(cx).await;
 745
 746        // Test: Line in the long function
 747        let input = serde_json::to_value(GrepToolInput {
 748            regex: "Line 12".to_string(),
 749            include_pattern: Some("**/*.rs".to_string()),
 750            offset: 0,
 751            case_sensitive: false,
 752        })
 753        .unwrap();
 754
 755        let result = run_grep_tool(input, project.clone(), cx).await;
 756        let expected = r#"
 757            Found 1 matches:
 758
 759            ## Matches in root/test_syntax.rs
 760
 761            ### impl MyStruct › fn long_function › L41-45
 762            ```
 763                    println!("Line 10");
 764                    println!("Line 11");
 765                    println!("Line 12");
 766                }
 767            }
 768            ```
 769            "#
 770        .unindent();
 771        assert_eq!(result, expected);
 772    }
 773
 774    async fn run_grep_tool(
 775        input: serde_json::Value,
 776        project: Entity<Project>,
 777        cx: &mut TestAppContext,
 778    ) -> String {
 779        let tool = Arc::new(GrepTool);
 780        let action_log = cx.new(|_cx| ActionLog::new(project.clone()));
 781        let model = Arc::new(FakeLanguageModel::default());
 782        let task =
 783            cx.update(|cx| tool.run(input, Arc::default(), project, action_log, model, None, cx));
 784
 785        match task.output.await {
 786            Ok(result) => {
 787                if cfg!(windows) {
 788                    result.content.as_str().unwrap().replace("root\\", "root/")
 789                } else {
 790                    result.content.as_str().unwrap().to_string()
 791                }
 792            }
 793            Err(e) => panic!("Failed to run grep tool: {}", e),
 794        }
 795    }
 796
 797    fn init_test(cx: &mut TestAppContext) {
 798        cx.update(|cx| {
 799            let settings_store = SettingsStore::test(cx);
 800            cx.set_global(settings_store);
 801            language::init(cx);
 802            Project::init_settings(cx);
 803        });
 804    }
 805
 806    fn rust_lang() -> Language {
 807        Language::new(
 808            LanguageConfig {
 809                name: "Rust".into(),
 810                matcher: LanguageMatcher {
 811                    path_suffixes: vec!["rs".to_string()],
 812                    ..Default::default()
 813                },
 814                ..Default::default()
 815            },
 816            Some(tree_sitter_rust::LANGUAGE.into()),
 817        )
 818        .with_outline_query(include_str!("../../languages/src/rust/outline.scm"))
 819        .unwrap()
 820    }
 821
 822    #[gpui::test]
 823    async fn test_grep_security_boundaries(cx: &mut TestAppContext) {
 824        init_test(cx);
 825
 826        let fs = FakeFs::new(cx.executor());
 827
 828        fs.insert_tree(
 829            path!("/"),
 830            json!({
 831                "project_root": {
 832                    "allowed_file.rs": "fn main() { println!(\"This file is in the project\"); }",
 833                    ".mysecrets": "SECRET_KEY=abc123\nfn secret() { /* private */ }",
 834                    ".secretdir": {
 835                        "config": "fn special_configuration() { /* excluded */ }"
 836                    },
 837                    ".mymetadata": "fn custom_metadata() { /* excluded */ }",
 838                    "subdir": {
 839                        "normal_file.rs": "fn normal_file_content() { /* Normal */ }",
 840                        "special.privatekey": "fn private_key_content() { /* private */ }",
 841                        "data.mysensitive": "fn sensitive_data() { /* private */ }"
 842                    }
 843                },
 844                "outside_project": {
 845                    "sensitive_file.rs": "fn outside_function() { /* This file is outside the project */ }"
 846                }
 847            }),
 848        )
 849        .await;
 850
 851        cx.update(|cx| {
 852            use gpui::UpdateGlobal;
 853            use settings::SettingsStore;
 854            SettingsStore::update_global(cx, |store, cx| {
 855                store.update_user_settings(cx, |settings| {
 856                    settings.project.worktree.file_scan_exclusions = Some(vec![
 857                        "**/.secretdir".to_string(),
 858                        "**/.mymetadata".to_string(),
 859                    ]);
 860                    settings.project.worktree.private_files = Some(
 861                        vec![
 862                            "**/.mysecrets".to_string(),
 863                            "**/*.privatekey".to_string(),
 864                            "**/*.mysensitive".to_string(),
 865                        ]
 866                        .into(),
 867                    );
 868                });
 869            });
 870        });
 871
 872        let project = Project::test(fs.clone(), [path!("/project_root").as_ref()], cx).await;
 873        let action_log = cx.new(|_| ActionLog::new(project.clone()));
 874        let model = Arc::new(FakeLanguageModel::default());
 875
 876        // Searching for files outside the project worktree should return no results
 877        let result = cx
 878            .update(|cx| {
 879                let input = json!({
 880                    "regex": "outside_function"
 881                });
 882                Arc::new(GrepTool)
 883                    .run(
 884                        input,
 885                        Arc::default(),
 886                        project.clone(),
 887                        action_log.clone(),
 888                        model.clone(),
 889                        None,
 890                        cx,
 891                    )
 892                    .output
 893            })
 894            .await;
 895        let results = result.unwrap();
 896        let paths = extract_paths_from_results(results.content.as_str().unwrap());
 897        assert!(
 898            paths.is_empty(),
 899            "grep_tool should not find files outside the project worktree"
 900        );
 901
 902        // Searching within the project should succeed
 903        let result = cx
 904            .update(|cx| {
 905                let input = json!({
 906                    "regex": "main"
 907                });
 908                Arc::new(GrepTool)
 909                    .run(
 910                        input,
 911                        Arc::default(),
 912                        project.clone(),
 913                        action_log.clone(),
 914                        model.clone(),
 915                        None,
 916                        cx,
 917                    )
 918                    .output
 919            })
 920            .await;
 921        let results = result.unwrap();
 922        let paths = extract_paths_from_results(results.content.as_str().unwrap());
 923        assert!(
 924            paths.iter().any(|p| p.contains("allowed_file.rs")),
 925            "grep_tool should be able to search files inside worktrees"
 926        );
 927
 928        // Searching files that match file_scan_exclusions should return no results
 929        let result = cx
 930            .update(|cx| {
 931                let input = json!({
 932                    "regex": "special_configuration"
 933                });
 934                Arc::new(GrepTool)
 935                    .run(
 936                        input,
 937                        Arc::default(),
 938                        project.clone(),
 939                        action_log.clone(),
 940                        model.clone(),
 941                        None,
 942                        cx,
 943                    )
 944                    .output
 945            })
 946            .await;
 947        let results = result.unwrap();
 948        let paths = extract_paths_from_results(results.content.as_str().unwrap());
 949        assert!(
 950            paths.is_empty(),
 951            "grep_tool should not search files in .secretdir (file_scan_exclusions)"
 952        );
 953
 954        let result = cx
 955            .update(|cx| {
 956                let input = json!({
 957                    "regex": "custom_metadata"
 958                });
 959                Arc::new(GrepTool)
 960                    .run(
 961                        input,
 962                        Arc::default(),
 963                        project.clone(),
 964                        action_log.clone(),
 965                        model.clone(),
 966                        None,
 967                        cx,
 968                    )
 969                    .output
 970            })
 971            .await;
 972        let results = result.unwrap();
 973        let paths = extract_paths_from_results(results.content.as_str().unwrap());
 974        assert!(
 975            paths.is_empty(),
 976            "grep_tool should not search .mymetadata files (file_scan_exclusions)"
 977        );
 978
 979        // Searching private files should return no results
 980        let result = cx
 981            .update(|cx| {
 982                let input = json!({
 983                    "regex": "SECRET_KEY"
 984                });
 985                Arc::new(GrepTool)
 986                    .run(
 987                        input,
 988                        Arc::default(),
 989                        project.clone(),
 990                        action_log.clone(),
 991                        model.clone(),
 992                        None,
 993                        cx,
 994                    )
 995                    .output
 996            })
 997            .await;
 998        let results = result.unwrap();
 999        let paths = extract_paths_from_results(results.content.as_str().unwrap());
1000        assert!(
1001            paths.is_empty(),
1002            "grep_tool should not search .mysecrets (private_files)"
1003        );
1004
1005        let result = cx
1006            .update(|cx| {
1007                let input = json!({
1008                    "regex": "private_key_content"
1009                });
1010                Arc::new(GrepTool)
1011                    .run(
1012                        input,
1013                        Arc::default(),
1014                        project.clone(),
1015                        action_log.clone(),
1016                        model.clone(),
1017                        None,
1018                        cx,
1019                    )
1020                    .output
1021            })
1022            .await;
1023        let results = result.unwrap();
1024        let paths = extract_paths_from_results(results.content.as_str().unwrap());
1025        assert!(
1026            paths.is_empty(),
1027            "grep_tool should not search .privatekey files (private_files)"
1028        );
1029
1030        let result = cx
1031            .update(|cx| {
1032                let input = json!({
1033                    "regex": "sensitive_data"
1034                });
1035                Arc::new(GrepTool)
1036                    .run(
1037                        input,
1038                        Arc::default(),
1039                        project.clone(),
1040                        action_log.clone(),
1041                        model.clone(),
1042                        None,
1043                        cx,
1044                    )
1045                    .output
1046            })
1047            .await;
1048        let results = result.unwrap();
1049        let paths = extract_paths_from_results(results.content.as_str().unwrap());
1050        assert!(
1051            paths.is_empty(),
1052            "grep_tool should not search .mysensitive files (private_files)"
1053        );
1054
1055        // Searching a normal file should still work, even with private_files configured
1056        let result = cx
1057            .update(|cx| {
1058                let input = json!({
1059                    "regex": "normal_file_content"
1060                });
1061                Arc::new(GrepTool)
1062                    .run(
1063                        input,
1064                        Arc::default(),
1065                        project.clone(),
1066                        action_log.clone(),
1067                        model.clone(),
1068                        None,
1069                        cx,
1070                    )
1071                    .output
1072            })
1073            .await;
1074        let results = result.unwrap();
1075        let paths = extract_paths_from_results(results.content.as_str().unwrap());
1076        assert!(
1077            paths.iter().any(|p| p.contains("normal_file.rs")),
1078            "Should be able to search normal files"
1079        );
1080
1081        // Path traversal attempts with .. in include_pattern should not escape project
1082        let result = cx
1083            .update(|cx| {
1084                let input = json!({
1085                    "regex": "outside_function",
1086                    "include_pattern": "../outside_project/**/*.rs"
1087                });
1088                Arc::new(GrepTool)
1089                    .run(
1090                        input,
1091                        Arc::default(),
1092                        project.clone(),
1093                        action_log.clone(),
1094                        model.clone(),
1095                        None,
1096                        cx,
1097                    )
1098                    .output
1099            })
1100            .await;
1101        let results = result.unwrap();
1102        let paths = extract_paths_from_results(results.content.as_str().unwrap());
1103        assert!(
1104            paths.is_empty(),
1105            "grep_tool should not allow escaping project boundaries with relative paths"
1106        );
1107    }
1108
1109    #[gpui::test]
1110    async fn test_grep_with_multiple_worktree_settings(cx: &mut TestAppContext) {
1111        init_test(cx);
1112
1113        let fs = FakeFs::new(cx.executor());
1114
1115        // Create first worktree with its own private files
1116        fs.insert_tree(
1117            path!("/worktree1"),
1118            json!({
1119                ".zed": {
1120                    "settings.json": r#"{
1121                        "file_scan_exclusions": ["**/fixture.*"],
1122                        "private_files": ["**/secret.rs"]
1123                    }"#
1124                },
1125                "src": {
1126                    "main.rs": "fn main() { let secret_key = \"hidden\"; }",
1127                    "secret.rs": "const API_KEY: &str = \"secret_value\";",
1128                    "utils.rs": "pub fn get_config() -> String { \"config\".to_string() }"
1129                },
1130                "tests": {
1131                    "test.rs": "fn test_secret() { assert!(true); }",
1132                    "fixture.sql": "SELECT * FROM secret_table;"
1133                }
1134            }),
1135        )
1136        .await;
1137
1138        // Create second worktree with different private files
1139        fs.insert_tree(
1140            path!("/worktree2"),
1141            json!({
1142                ".zed": {
1143                    "settings.json": r#"{
1144                        "file_scan_exclusions": ["**/internal.*"],
1145                        "private_files": ["**/private.js", "**/data.json"]
1146                    }"#
1147                },
1148                "lib": {
1149                    "public.js": "export function getSecret() { return 'public'; }",
1150                    "private.js": "const SECRET_KEY = \"private_value\";",
1151                    "data.json": "{\"secret_data\": \"hidden\"}"
1152                },
1153                "docs": {
1154                    "README.md": "# Documentation with secret info",
1155                    "internal.md": "Internal secret documentation"
1156                }
1157            }),
1158        )
1159        .await;
1160
1161        // Set global settings
1162        cx.update(|cx| {
1163            SettingsStore::update_global(cx, |store, cx| {
1164                store.update_user_settings(cx, |settings| {
1165                    settings.project.worktree.file_scan_exclusions =
1166                        Some(vec!["**/.git".to_string(), "**/node_modules".to_string()]);
1167                    settings.project.worktree.private_files =
1168                        Some(vec!["**/.env".to_string()].into());
1169                });
1170            });
1171        });
1172
1173        let project = Project::test(
1174            fs.clone(),
1175            [path!("/worktree1").as_ref(), path!("/worktree2").as_ref()],
1176            cx,
1177        )
1178        .await;
1179
1180        // Wait for worktrees to be fully scanned
1181        cx.executor().run_until_parked();
1182
1183        let action_log = cx.new(|_| ActionLog::new(project.clone()));
1184        let model = Arc::new(FakeLanguageModel::default());
1185
1186        // Search for "secret" - should exclude files based on worktree-specific settings
1187        let result = cx
1188            .update(|cx| {
1189                let input = json!({
1190                    "regex": "secret",
1191                    "case_sensitive": false
1192                });
1193                Arc::new(GrepTool)
1194                    .run(
1195                        input,
1196                        Arc::default(),
1197                        project.clone(),
1198                        action_log.clone(),
1199                        model.clone(),
1200                        None,
1201                        cx,
1202                    )
1203                    .output
1204            })
1205            .await
1206            .unwrap();
1207
1208        let content = result.content.as_str().unwrap();
1209        let paths = extract_paths_from_results(content);
1210
1211        // Should find matches in non-private files
1212        assert!(
1213            paths.iter().any(|p| p.contains("main.rs")),
1214            "Should find 'secret' in worktree1/src/main.rs"
1215        );
1216        assert!(
1217            paths.iter().any(|p| p.contains("test.rs")),
1218            "Should find 'secret' in worktree1/tests/test.rs"
1219        );
1220        assert!(
1221            paths.iter().any(|p| p.contains("public.js")),
1222            "Should find 'secret' in worktree2/lib/public.js"
1223        );
1224        assert!(
1225            paths.iter().any(|p| p.contains("README.md")),
1226            "Should find 'secret' in worktree2/docs/README.md"
1227        );
1228
1229        // Should NOT find matches in private/excluded files based on worktree settings
1230        assert!(
1231            !paths.iter().any(|p| p.contains("secret.rs")),
1232            "Should not search in worktree1/src/secret.rs (local private_files)"
1233        );
1234        assert!(
1235            !paths.iter().any(|p| p.contains("fixture.sql")),
1236            "Should not search in worktree1/tests/fixture.sql (local file_scan_exclusions)"
1237        );
1238        assert!(
1239            !paths.iter().any(|p| p.contains("private.js")),
1240            "Should not search in worktree2/lib/private.js (local private_files)"
1241        );
1242        assert!(
1243            !paths.iter().any(|p| p.contains("data.json")),
1244            "Should not search in worktree2/lib/data.json (local private_files)"
1245        );
1246        assert!(
1247            !paths.iter().any(|p| p.contains("internal.md")),
1248            "Should not search in worktree2/docs/internal.md (local file_scan_exclusions)"
1249        );
1250
1251        // Test with `include_pattern` specific to one worktree
1252        let result = cx
1253            .update(|cx| {
1254                let input = json!({
1255                    "regex": "secret",
1256                    "include_pattern": "worktree1/**/*.rs"
1257                });
1258                Arc::new(GrepTool)
1259                    .run(
1260                        input,
1261                        Arc::default(),
1262                        project.clone(),
1263                        action_log.clone(),
1264                        model.clone(),
1265                        None,
1266                        cx,
1267                    )
1268                    .output
1269            })
1270            .await
1271            .unwrap();
1272
1273        let content = result.content.as_str().unwrap();
1274        let paths = extract_paths_from_results(content);
1275
1276        // Should only find matches in worktree1 *.rs files (excluding private ones)
1277        assert!(
1278            paths.iter().any(|p| p.contains("main.rs")),
1279            "Should find match in worktree1/src/main.rs"
1280        );
1281        assert!(
1282            paths.iter().any(|p| p.contains("test.rs")),
1283            "Should find match in worktree1/tests/test.rs"
1284        );
1285        assert!(
1286            !paths.iter().any(|p| p.contains("secret.rs")),
1287            "Should not find match in excluded worktree1/src/secret.rs"
1288        );
1289        assert!(
1290            paths.iter().all(|p| !p.contains("worktree2")),
1291            "Should not find any matches in worktree2"
1292        );
1293    }
1294
1295    // Helper function to extract file paths from grep results
1296    fn extract_paths_from_results(results: &str) -> Vec<String> {
1297        results
1298            .lines()
1299            .filter(|line| line.starts_with("## Matches in "))
1300            .map(|line| {
1301                line.strip_prefix("## Matches in ")
1302                    .unwrap()
1303                    .trim()
1304                    .to_string()
1305            })
1306            .collect()
1307    }
1308}